feat: implement all P1/P2/P3/P4 improvements from issue backlog
P1 correctness: - filter test files by default (--include-tests to opt in) - per-module diagrams show cross-module dependency arrows - qualified type names (Module::TypeName) fix false edges from duplicate names P2 output richness: - method parameter types and return types in class diagrams (Rust + Python) - Python pyproject.toml project analyzer (--level project for monorepos) P3 unique value: - boundary rules in archlens.toml ([rules] allow/deny, --strict enforcement) P4 nice to have: - dependency weight labels on module arrows (--no-weights to disable) - --watch mode with 500ms debounce - D2 renderer adapter (--format d2) - interactive self-contained HTML viewer (--format html) - git-aware incremental analysis (--since <ref>)
This commit is contained in:
11
crates/adapters/d2/Cargo.toml
Normal file
11
crates/adapters/d2/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "archlens-d2"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
archlens-domain.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
173
crates/adapters/d2/src/d2_renderer.rs
Normal file
173
crates/adapters/d2/src/d2_renderer.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
use archlens_domain::{
|
||||
CodeGraph, DiagramLevel, DomainError, RenderOutput, RenderedFile, ports::DiagramRenderer,
|
||||
};
|
||||
|
||||
pub struct D2Renderer {
|
||||
level: DiagramLevel,
|
||||
}
|
||||
|
||||
impl Default for D2Renderer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl D2Renderer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
level: DiagramLevel::Type,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_level(level: DiagramLevel) -> Self {
|
||||
Self { level }
|
||||
}
|
||||
}
|
||||
|
||||
impl DiagramRenderer for D2Renderer {
|
||||
fn render(&self, graph: &CodeGraph) -> Result<RenderOutput, DomainError> {
|
||||
let content = match self.level {
|
||||
DiagramLevel::Type => render_type(graph),
|
||||
DiagramLevel::Module => render_module(graph),
|
||||
DiagramLevel::Project => render_project(graph),
|
||||
};
|
||||
let file = RenderedFile::new("diagram.d2", &content)?;
|
||||
Ok(RenderOutput::single(file))
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize(name: &str) -> String {
|
||||
name.replace("::", "_").replace(['-', ' '], "_")
|
||||
}
|
||||
|
||||
fn render_type(graph: &CodeGraph) -> String {
|
||||
let mut lines = Vec::new();
|
||||
let (by_module, ungrouped) = graph.elements_by_module();
|
||||
|
||||
// Grouped by module
|
||||
for (module, elements) in &by_module {
|
||||
let mod_id = sanitize(module);
|
||||
lines.push(format!("{mod_id}: {{"));
|
||||
for el in elements {
|
||||
let el_id = sanitize(el.name());
|
||||
lines.push(format!(" {el_id}: {{"));
|
||||
lines.push(" shape: class".to_string());
|
||||
for field in el.fields() {
|
||||
lines.push(format!(" {field}"));
|
||||
}
|
||||
for method in el.methods() {
|
||||
let method_display = method.trim_start_matches(['+', '-']);
|
||||
lines.push(format!(
|
||||
" {}()",
|
||||
method_display.split('(').next().unwrap_or(method_display)
|
||||
));
|
||||
}
|
||||
lines.push(" }".to_string());
|
||||
}
|
||||
lines.push("}".to_string());
|
||||
}
|
||||
|
||||
// Ungrouped elements
|
||||
for el in &ungrouped {
|
||||
let el_id = sanitize(el.name());
|
||||
lines.push(format!("{el_id}: {{"));
|
||||
lines.push(" shape: class".to_string());
|
||||
lines.push("}".to_string());
|
||||
}
|
||||
|
||||
// Relationships
|
||||
for rel in graph.relationships() {
|
||||
use archlens_domain::RelationshipKind;
|
||||
let src = sanitize(rel.source());
|
||||
let tgt = sanitize(rel.target());
|
||||
let arrow = match rel.kind() {
|
||||
RelationshipKind::Inheritance => format!("{src} -> {tgt}: {{style.stroke-dash: 0}}"),
|
||||
RelationshipKind::Composition => format!("{src} -> {tgt}"),
|
||||
RelationshipKind::Import => continue,
|
||||
};
|
||||
lines.push(arrow);
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn render_module(graph: &CodeGraph) -> String {
|
||||
use archlens_domain::RelationshipKind;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
let mut lines = Vec::new();
|
||||
let mut modules: HashSet<String> = HashSet::new();
|
||||
let mut name_to_module: HashMap<&str, &str> = HashMap::new();
|
||||
|
||||
for el in graph.elements() {
|
||||
if let Some(m) = el.module() {
|
||||
modules.insert(m.as_str().to_string());
|
||||
name_to_module.insert(el.qualified_name(), m.as_str());
|
||||
name_to_module.insert(el.name(), m.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
for module in &modules {
|
||||
let id = sanitize(module);
|
||||
lines.push(format!("{id}: {module}"));
|
||||
}
|
||||
|
||||
let mut edges: HashSet<(String, String)> = HashSet::new();
|
||||
for rel in graph.relationships() {
|
||||
if rel.kind() == RelationshipKind::Import {
|
||||
continue;
|
||||
}
|
||||
let src_mod = name_to_module.get(rel.source());
|
||||
let tgt_mod = name_to_module.get(rel.target());
|
||||
if let (Some(s), Some(t)) = (src_mod, tgt_mod)
|
||||
&& s != t
|
||||
{
|
||||
edges.insert((s.to_string(), t.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
for (src, tgt) in &edges {
|
||||
lines.push(format!("{} -> {}", sanitize(src), sanitize(tgt)));
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn render_project(graph: &CodeGraph) -> String {
|
||||
use archlens_domain::RelationshipKind;
|
||||
use std::collections::HashMap;
|
||||
|
||||
let mut lines = Vec::new();
|
||||
let (by_module, ungrouped) = graph.elements_by_module();
|
||||
|
||||
for (module, elements) in &by_module {
|
||||
let mod_id = sanitize(module);
|
||||
lines.push(format!("{mod_id}: {{"));
|
||||
for el in elements {
|
||||
lines.push(format!(" {}: {}", sanitize(el.name()), el.name()));
|
||||
}
|
||||
lines.push("}".to_string());
|
||||
}
|
||||
|
||||
for el in &ungrouped {
|
||||
lines.push(format!("{}: {}", sanitize(el.name()), el.name()));
|
||||
}
|
||||
|
||||
let name_to_id: HashMap<&str, String> = graph
|
||||
.elements()
|
||||
.iter()
|
||||
.map(|e| (e.name(), sanitize(e.name())))
|
||||
.collect();
|
||||
|
||||
for rel in graph.relationships() {
|
||||
if rel.kind() == RelationshipKind::Import {
|
||||
continue;
|
||||
}
|
||||
if let (Some(src), Some(tgt)) = (name_to_id.get(rel.source()), name_to_id.get(rel.target()))
|
||||
{
|
||||
lines.push(format!("{src} -> {tgt}"));
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
2
crates/adapters/d2/src/lib.rs
Normal file
2
crates/adapters/d2/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
mod d2_renderer;
|
||||
pub use d2_renderer::D2Renderer;
|
||||
110
crates/adapters/d2/tests/d2_renderer_tests.rs
Normal file
110
crates/adapters/d2/tests/d2_renderer_tests.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use archlens_d2::D2Renderer;
|
||||
use archlens_domain::{
|
||||
CodeElement, CodeElementKind, CodeGraph, DiagramLevel, FilePath, ModuleName, Relationship,
|
||||
RelationshipKind, ports::DiagramRenderer,
|
||||
};
|
||||
|
||||
fn make_el(name: &str, module: Option<&str>) -> CodeElement {
|
||||
let mut el = CodeElement::new(
|
||||
name,
|
||||
CodeElementKind::Class,
|
||||
FilePath::new(&format!("src/{name}.rs")).unwrap(),
|
||||
1,
|
||||
)
|
||||
.unwrap();
|
||||
if let Some(m) = module {
|
||||
el = el.with_module(ModuleName::new(m).unwrap());
|
||||
}
|
||||
el
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_level_emits_class_shapes() {
|
||||
let mut graph = CodeGraph::new();
|
||||
graph.add_element(make_el("OrderService", Some("App")));
|
||||
graph.add_element(make_el("Order", Some("Domain")));
|
||||
graph.add_relationship(
|
||||
Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(),
|
||||
);
|
||||
let graph = graph.qualify();
|
||||
|
||||
let renderer = D2Renderer::new();
|
||||
let output = renderer.render(&graph).unwrap();
|
||||
let content = output.files()[0].content();
|
||||
|
||||
assert!(
|
||||
content.contains("shape: class"),
|
||||
"expected class shape: {content}"
|
||||
);
|
||||
assert!(
|
||||
content.contains("OrderService"),
|
||||
"expected OrderService: {content}"
|
||||
);
|
||||
assert!(content.contains("Order"), "expected Order: {content}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn module_level_emits_module_nodes_and_edges() {
|
||||
let mut graph = CodeGraph::new();
|
||||
graph.add_element(make_el("Service", Some("App")));
|
||||
graph.add_element(make_el("Order", Some("Domain")));
|
||||
graph.add_relationship(
|
||||
Relationship::new("Service", "Order", RelationshipKind::Composition).unwrap(),
|
||||
);
|
||||
let graph = graph.qualify();
|
||||
|
||||
let renderer = D2Renderer::with_level(DiagramLevel::Module);
|
||||
let output = renderer.render(&graph).unwrap();
|
||||
let content = output.files()[0].content();
|
||||
|
||||
assert!(content.contains("App"), "expected App module: {content}");
|
||||
assert!(
|
||||
content.contains("Domain"),
|
||||
"expected Domain module: {content}"
|
||||
);
|
||||
assert!(
|
||||
content.contains("->"),
|
||||
"expected dependency arrow: {content}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_level_groups_by_module() {
|
||||
let mut graph = CodeGraph::new();
|
||||
graph.add_element(
|
||||
CodeElement::new(
|
||||
"my-api",
|
||||
CodeElementKind::Project,
|
||||
FilePath::new("api/pyproject.toml").unwrap(),
|
||||
1,
|
||||
)
|
||||
.unwrap()
|
||||
.with_module(ModuleName::new("Backend").unwrap()),
|
||||
);
|
||||
graph.add_element(
|
||||
CodeElement::new(
|
||||
"my-commons",
|
||||
CodeElementKind::Project,
|
||||
FilePath::new("commons/pyproject.toml").unwrap(),
|
||||
1,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
graph.add_relationship(
|
||||
Relationship::new("my-api", "my-commons", RelationshipKind::Composition).unwrap(),
|
||||
);
|
||||
|
||||
let renderer = D2Renderer::with_level(DiagramLevel::Project);
|
||||
let output = renderer.render(&graph).unwrap();
|
||||
let content = output.files()[0].content();
|
||||
|
||||
assert!(
|
||||
content.contains("Backend"),
|
||||
"expected Backend group: {content}"
|
||||
);
|
||||
assert!(
|
||||
content.contains("my-api") || content.contains("my_api"),
|
||||
"expected my-api: {content}"
|
||||
);
|
||||
assert!(content.contains("->"), "expected dep arrow: {content}");
|
||||
}
|
||||
13
crates/adapters/html-viewer/Cargo.toml
Normal file
13
crates/adapters/html-viewer/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "archlens-html"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
archlens-domain.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
240
crates/adapters/html-viewer/src/html_renderer.rs
Normal file
240
crates/adapters/html-viewer/src/html_renderer.rs
Normal file
@@ -0,0 +1,240 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use archlens_domain::{
|
||||
CodeGraph, DomainError, RelationshipKind, RenderOutput, RenderedFile, ports::DiagramRenderer,
|
||||
};
|
||||
|
||||
pub struct HtmlRenderer;
|
||||
|
||||
impl Default for HtmlRenderer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl HtmlRenderer {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GraphData {
|
||||
nodes: Vec<NodeData>,
|
||||
edges: Vec<EdgeData>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct NodeData {
|
||||
id: String,
|
||||
label: String,
|
||||
module: String,
|
||||
kind: String,
|
||||
fields: Vec<String>,
|
||||
methods: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct EdgeData {
|
||||
source: String,
|
||||
target: String,
|
||||
kind: String,
|
||||
}
|
||||
|
||||
impl DiagramRenderer for HtmlRenderer {
|
||||
fn render(&self, graph: &CodeGraph) -> Result<RenderOutput, DomainError> {
|
||||
// Build graph data
|
||||
let mut id_map: HashMap<String, String> = HashMap::new();
|
||||
let mut nodes = Vec::new();
|
||||
|
||||
for (i, el) in graph.elements().iter().enumerate() {
|
||||
let id = format!("n{i}");
|
||||
id_map.insert(el.qualified_name().to_string(), id.clone());
|
||||
id_map.insert(el.name().to_string(), id.clone());
|
||||
nodes.push(NodeData {
|
||||
id,
|
||||
label: el.name().to_string(),
|
||||
module: el
|
||||
.module()
|
||||
.map(|m| m.as_str().to_string())
|
||||
.unwrap_or_default(),
|
||||
kind: format!("{:?}", el.kind()),
|
||||
fields: el.fields().to_vec(),
|
||||
methods: el.methods().to_vec(),
|
||||
});
|
||||
}
|
||||
|
||||
let edges = graph
|
||||
.relationships()
|
||||
.iter()
|
||||
.filter(|r| r.kind() != RelationshipKind::Import)
|
||||
.filter_map(|r| {
|
||||
let src = id_map.get(r.source())?;
|
||||
let tgt = id_map.get(r.target())?;
|
||||
Some(EdgeData {
|
||||
source: src.clone(),
|
||||
target: tgt.clone(),
|
||||
kind: format!("{:?}", r.kind()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let data = GraphData { nodes, edges };
|
||||
let json =
|
||||
serde_json::to_string(&data).map_err(|e| DomainError::ConfigError(e.to_string()))?;
|
||||
|
||||
let html = build_html(&json);
|
||||
let file = RenderedFile::new("diagram.html", &html)?;
|
||||
Ok(RenderOutput::single(file))
|
||||
}
|
||||
}
|
||||
|
||||
fn build_html(graph_json: &str) -> String {
|
||||
format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Architecture Diagram</title>
|
||||
<style>
|
||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||
body {{ font-family: sans-serif; background: #1a1a2e; color: #eee; display: flex; height: 100vh; }}
|
||||
#sidebar {{ width: 280px; background: #16213e; padding: 1rem; overflow-y: auto; border-right: 1px solid #0f3460; }}
|
||||
#cy {{ flex: 1; }}
|
||||
h2 {{ color: #e94560; margin-bottom: 0.5rem; font-size: 1rem; }}
|
||||
#detail {{ padding: 0.5rem 0; font-size: 0.85rem; }}
|
||||
.member {{ padding: 2px 0; color: #aaa; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="sidebar">
|
||||
<h2>Architecture Diagram</h2>
|
||||
<div id="detail"><p>Click a node to see details.</p></div>
|
||||
</div>
|
||||
<div id="cy"></div>
|
||||
<script>
|
||||
const GRAPH = {graph_json};
|
||||
|
||||
// Inline minimal Cytoscape-compatible renderer using Canvas API
|
||||
(function() {{
|
||||
const canvas = document.createElement('canvas');
|
||||
const container = document.getElementById('cy');
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = '100%';
|
||||
container.appendChild(canvas);
|
||||
|
||||
const detail = document.getElementById('detail');
|
||||
|
||||
function resize() {{
|
||||
canvas.width = container.clientWidth;
|
||||
canvas.height = container.clientHeight;
|
||||
draw();
|
||||
}}
|
||||
|
||||
// Group nodes by module
|
||||
const modules = {{}};
|
||||
GRAPH.nodes.forEach(n => {{
|
||||
const m = n.module || '(ungrouped)';
|
||||
if (!modules[m]) modules[m] = [];
|
||||
modules[m].push(n);
|
||||
}});
|
||||
|
||||
// Layout: arrange modules in a grid, nodes within each module in a column
|
||||
const positions = {{}};
|
||||
const modNames = Object.keys(modules);
|
||||
const cols = Math.ceil(Math.sqrt(modNames.length));
|
||||
const cellW = 220, cellH = 200, pad = 60;
|
||||
|
||||
modNames.forEach((mod, mi) => {{
|
||||
const col = mi % cols, row = Math.floor(mi / cols);
|
||||
const bx = pad + col * (cellW + pad);
|
||||
const by = pad + row * (cellH + pad);
|
||||
modules[mod].forEach((n, ni) => {{
|
||||
positions[n.id] = {{
|
||||
x: bx + 20 + (ni % 2) * 90,
|
||||
y: by + 30 + Math.floor(ni / 2) * 50
|
||||
}};
|
||||
}});
|
||||
}});
|
||||
|
||||
let selected = null;
|
||||
const nodeRadius = 18;
|
||||
|
||||
function draw() {{
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw module backgrounds
|
||||
modNames.forEach((mod, mi) => {{
|
||||
const col = mi % cols, row = Math.floor(mi / cols);
|
||||
const bx = pad / 2 + col * (cellW + pad);
|
||||
const by = pad / 2 + row * (cellH + pad);
|
||||
ctx.fillStyle = 'rgba(15,52,96,0.4)';
|
||||
ctx.fillRect(bx, by, cellW + pad / 2, cellH + pad / 2);
|
||||
ctx.fillStyle = '#4fc3f7';
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.fillText(mod, bx + 6, by + 14);
|
||||
}});
|
||||
|
||||
// Draw edges
|
||||
GRAPH.edges.forEach(e => {{
|
||||
const sp = positions[e.source], tp = positions[e.target];
|
||||
if (!sp || !tp) return;
|
||||
ctx.strokeStyle = e.kind === 'Inheritance' ? '#e94560' : '#aaa';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(sp.x, sp.y);
|
||||
ctx.lineTo(tp.x, tp.y);
|
||||
ctx.stroke();
|
||||
}});
|
||||
|
||||
// Draw nodes
|
||||
GRAPH.nodes.forEach(n => {{
|
||||
const p = positions[n.id];
|
||||
if (!p) return;
|
||||
ctx.fillStyle = selected && selected.id === n.id ? '#e94560' : '#0f3460';
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, nodeRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#eee';
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(n.label.substring(0, 12), p.x, p.y + 4);
|
||||
}});
|
||||
}}
|
||||
|
||||
canvas.addEventListener('click', e => {{
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
||||
selected = null;
|
||||
for (const n of GRAPH.nodes) {{
|
||||
const p = positions[n.id];
|
||||
if (!p) continue;
|
||||
const dx = mx - p.x, dy = my - p.y;
|
||||
if (dx * dx + dy * dy < nodeRadius * nodeRadius) {{
|
||||
selected = n;
|
||||
break;
|
||||
}}
|
||||
}}
|
||||
if (selected) {{
|
||||
detail.innerHTML = `<strong>${{selected.label}}</strong><br><em>${{selected.module}}</em>` +
|
||||
(selected.fields.length ? '<br><b>Fields:</b><br>' + selected.fields.map(f => `<div class="member">${{f}}</div>`).join('') : '') +
|
||||
(selected.methods.length ? '<br><b>Methods:</b><br>' + selected.methods.map(m => `<div class="member">${{m}}</div>`).join('') : '');
|
||||
}} else {{
|
||||
detail.innerHTML = '<p>Click a node to see details.</p>';
|
||||
}}
|
||||
draw();
|
||||
}});
|
||||
|
||||
window.addEventListener('resize', resize);
|
||||
resize();
|
||||
}})();
|
||||
</script>
|
||||
</body>
|
||||
</html>"#,
|
||||
graph_json = graph_json
|
||||
)
|
||||
}
|
||||
2
crates/adapters/html-viewer/src/lib.rs
Normal file
2
crates/adapters/html-viewer/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
mod html_renderer;
|
||||
pub use html_renderer::HtmlRenderer;
|
||||
78
crates/adapters/html-viewer/tests/html_renderer_tests.rs
Normal file
78
crates/adapters/html-viewer/tests/html_renderer_tests.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
use archlens_domain::{
|
||||
CodeElement, CodeElementKind, CodeGraph, FilePath, ModuleName, Relationship, RelationshipKind,
|
||||
ports::DiagramRenderer,
|
||||
};
|
||||
use archlens_html::HtmlRenderer;
|
||||
|
||||
fn make_graph() -> CodeGraph {
|
||||
let mut graph = CodeGraph::new();
|
||||
graph.add_element(
|
||||
CodeElement::new(
|
||||
"OrderService",
|
||||
CodeElementKind::Class,
|
||||
FilePath::new("src/app/s.rs").unwrap(),
|
||||
1,
|
||||
)
|
||||
.unwrap()
|
||||
.with_module(ModuleName::new("App").unwrap()),
|
||||
);
|
||||
graph.add_element(
|
||||
CodeElement::new(
|
||||
"Order",
|
||||
CodeElementKind::Class,
|
||||
FilePath::new("src/domain/o.rs").unwrap(),
|
||||
1,
|
||||
)
|
||||
.unwrap()
|
||||
.with_module(ModuleName::new("Domain").unwrap()),
|
||||
);
|
||||
graph.add_relationship(
|
||||
Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(),
|
||||
);
|
||||
graph.qualify()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_output_is_self_contained() {
|
||||
let renderer = HtmlRenderer::new();
|
||||
let output = renderer.render(&make_graph()).unwrap();
|
||||
let content = output.files()[0].content();
|
||||
|
||||
assert!(
|
||||
content.starts_with("<!DOCTYPE html>"),
|
||||
"should be full HTML doc"
|
||||
);
|
||||
assert!(content.contains("</html>"), "should be complete HTML");
|
||||
assert!(
|
||||
!content.contains("src="),
|
||||
"should not have external src= links"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_output_embeds_graph_data_as_json() {
|
||||
let renderer = HtmlRenderer::new();
|
||||
let output = renderer.render(&make_graph()).unwrap();
|
||||
let content = output.files()[0].content();
|
||||
|
||||
assert!(
|
||||
content.contains("OrderService"),
|
||||
"graph data should contain node names"
|
||||
);
|
||||
assert!(
|
||||
content.contains("Domain"),
|
||||
"graph data should contain module names"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_output_includes_interactive_js() {
|
||||
let renderer = HtmlRenderer::new();
|
||||
let output = renderer.render(&make_graph()).unwrap();
|
||||
let content = output.files()[0].content();
|
||||
|
||||
assert!(
|
||||
content.contains("cytoscape") || content.contains("graph") || content.contains("nodes"),
|
||||
"should include graph visualization JS"
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use archlens_domain::{
|
||||
|
||||
pub struct MermaidRenderer {
|
||||
level: DiagramLevel,
|
||||
show_weights: bool,
|
||||
}
|
||||
|
||||
impl Default for MermaidRenderer {
|
||||
@@ -19,11 +20,24 @@ impl MermaidRenderer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
level: DiagramLevel::Type,
|
||||
show_weights: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_level(level: DiagramLevel) -> Self {
|
||||
Self { level }
|
||||
Self {
|
||||
level,
|
||||
show_weights: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_weights(mut self, show: bool) -> Self {
|
||||
self.show_weights = show;
|
||||
self
|
||||
}
|
||||
|
||||
fn display_name(qualified: &str) -> &str {
|
||||
qualified.split("::").last().unwrap_or(qualified)
|
||||
}
|
||||
|
||||
fn format_element_name(element: &CodeElement) -> String {
|
||||
@@ -89,7 +103,9 @@ impl MermaidRenderer {
|
||||
RelationshipKind::Composition => "-->",
|
||||
RelationshipKind::Import => "..>",
|
||||
};
|
||||
let key = format!("{} {} {}", rel.source(), arrow, rel.target());
|
||||
let src = Self::display_name(rel.source());
|
||||
let tgt = Self::display_name(rel.target());
|
||||
let key = format!("{} {} {}", src, arrow, tgt);
|
||||
if rel_seen.insert(key.clone()) {
|
||||
lines.push(format!(" {key}"));
|
||||
}
|
||||
@@ -136,10 +152,15 @@ impl MermaidRenderer {
|
||||
|
||||
for element in graph.elements() {
|
||||
if let Some(module) = element.module() {
|
||||
// Index both bare name and qualified name for lookup
|
||||
name_to_modules
|
||||
.entry(element.name())
|
||||
.or_default()
|
||||
.insert(module.as_str());
|
||||
name_to_modules
|
||||
.entry(element.qualified_name())
|
||||
.or_default()
|
||||
.insert(module.as_str());
|
||||
modules.insert(module.as_str().to_string());
|
||||
|
||||
let file_stem = std::path::Path::new(element.file_path().as_str())
|
||||
@@ -156,7 +177,7 @@ impl MermaidRenderer {
|
||||
lines.push(format!(" {module}[{module}]"));
|
||||
}
|
||||
|
||||
let mut module_edges: HashSet<(String, String)> = HashSet::new();
|
||||
let mut module_edges: HashMap<(String, String), usize> = HashMap::new();
|
||||
for rel in graph.relationships() {
|
||||
match rel.kind() {
|
||||
RelationshipKind::Import => {
|
||||
@@ -168,7 +189,7 @@ impl MermaidRenderer {
|
||||
&& modules.contains(&target_mod)
|
||||
&& *src != target_mod
|
||||
{
|
||||
module_edges.insert((src.clone(), target_mod));
|
||||
*module_edges.entry((src.clone(), target_mod)).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
@@ -176,7 +197,9 @@ impl MermaidRenderer {
|
||||
&& modules.contains(rel.target())
|
||||
&& rel.source() != rel.target()
|
||||
{
|
||||
module_edges.insert((rel.source().to_string(), rel.target().to_string()));
|
||||
*module_edges
|
||||
.entry((rel.source().to_string(), rel.target().to_string()))
|
||||
.or_insert(0) += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -190,7 +213,9 @@ impl MermaidRenderer {
|
||||
}
|
||||
for tgt_mod in tgt_set {
|
||||
if src_mod != tgt_mod {
|
||||
module_edges.insert((src_mod.to_string(), tgt_mod.to_string()));
|
||||
*module_edges
|
||||
.entry((src_mod.to_string(), tgt_mod.to_string()))
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,8 +224,18 @@ impl MermaidRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
for (source, target) in &module_edges {
|
||||
lines.push(format!(" {source} --> {target}"));
|
||||
for ((source, target), count) in &module_edges {
|
||||
let arrow = if self.show_weights {
|
||||
let label = if *count == 1 {
|
||||
r#"|"1 dep"|"#.to_string()
|
||||
} else {
|
||||
format!(r#"|"{count} deps"|"#)
|
||||
};
|
||||
format!("--{label}")
|
||||
} else {
|
||||
"-->".to_string()
|
||||
};
|
||||
lines.push(format!(" {source} {arrow} {target}"));
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
@@ -249,4 +284,43 @@ impl DiagramRenderer for MermaidRenderer {
|
||||
let file = RenderedFile::new("diagram.mmd", &content)?;
|
||||
Ok(RenderOutput::single(file))
|
||||
}
|
||||
|
||||
fn append_cross_module_deps(
|
||||
&self,
|
||||
content: &str,
|
||||
module: &ModuleName,
|
||||
deps: &[(ModuleName, usize)],
|
||||
) -> String {
|
||||
if deps.is_empty() {
|
||||
return content.to_string();
|
||||
}
|
||||
|
||||
let src_id = format!(
|
||||
"{}_module",
|
||||
module.as_str().to_lowercase().replace('-', "_")
|
||||
);
|
||||
let mut extra = format!(
|
||||
" class {src_id}[\"{}\"] {{\n <<module>>\n }}\n",
|
||||
module.as_str()
|
||||
);
|
||||
|
||||
for (dep_mod, count) in deps {
|
||||
let dep_id = format!(
|
||||
"{}_module",
|
||||
dep_mod.as_str().to_lowercase().replace('-', "_")
|
||||
);
|
||||
extra.push_str(&format!(
|
||||
" class {dep_id}[\"{}\"] {{\n <<module>>\n }}\n",
|
||||
dep_mod.as_str()
|
||||
));
|
||||
let label = if *count == 1 {
|
||||
"1 dep".to_string()
|
||||
} else {
|
||||
format!("{count} deps")
|
||||
};
|
||||
extra.push_str(&format!(" {src_id} --> {dep_id} : {label}\n"));
|
||||
}
|
||||
|
||||
format!("{content}\n{extra}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ fn renders_module_level_flowchart() {
|
||||
assert!(content.contains("graph TD"));
|
||||
assert!(content.contains("Orders"));
|
||||
assert!(content.contains("Billing"));
|
||||
assert!(content.contains("Orders --> Billing"));
|
||||
assert!(content.contains("Orders --") && content.contains("Billing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -320,9 +320,99 @@ fn module_level_aggregates_cross_module_deps_into_single_arrow() {
|
||||
let output = renderer.render(&graph).unwrap();
|
||||
let content = output.files()[0].content();
|
||||
|
||||
let arrow_count = content.matches("Orders --> Infra").count();
|
||||
let arrow_count =
|
||||
content.matches("Orders --> Infra").count() + content.matches("Orders --|").count();
|
||||
assert_eq!(
|
||||
arrow_count, 1,
|
||||
"should have exactly one aggregated arrow, got:\n{content}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn module_level_shows_dep_count_as_edge_label() {
|
||||
let mut graph = CodeGraph::new();
|
||||
graph.add_element(
|
||||
CodeElement::new(
|
||||
"ServiceA",
|
||||
CodeElementKind::Class,
|
||||
FilePath::new("src/app/a.rs").unwrap(),
|
||||
1,
|
||||
)
|
||||
.unwrap()
|
||||
.with_module(ModuleName::new("App").unwrap()),
|
||||
);
|
||||
graph.add_element(
|
||||
CodeElement::new(
|
||||
"ServiceB",
|
||||
CodeElementKind::Class,
|
||||
FilePath::new("src/app/b.rs").unwrap(),
|
||||
1,
|
||||
)
|
||||
.unwrap()
|
||||
.with_module(ModuleName::new("App").unwrap()),
|
||||
);
|
||||
graph.add_element(
|
||||
CodeElement::new(
|
||||
"Order",
|
||||
CodeElementKind::Class,
|
||||
FilePath::new("src/domain/order.rs").unwrap(),
|
||||
1,
|
||||
)
|
||||
.unwrap()
|
||||
.with_module(ModuleName::new("Domain").unwrap()),
|
||||
);
|
||||
graph.add_relationship(
|
||||
Relationship::new("ServiceA", "Order", RelationshipKind::Composition).unwrap(),
|
||||
);
|
||||
graph.add_relationship(
|
||||
Relationship::new("ServiceB", "Order", RelationshipKind::Composition).unwrap(),
|
||||
);
|
||||
let graph = graph.qualify();
|
||||
|
||||
let renderer = MermaidRenderer::with_level(DiagramLevel::Module);
|
||||
let output = renderer.render(&graph).unwrap();
|
||||
let content = output.files()[0].content();
|
||||
|
||||
assert!(
|
||||
content.contains(r#"|"2 deps"|"#),
|
||||
"expected dep count label in: {content}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn module_level_single_dep_uses_singular_label() {
|
||||
let mut graph = CodeGraph::new();
|
||||
graph.add_element(
|
||||
CodeElement::new(
|
||||
"Service",
|
||||
CodeElementKind::Class,
|
||||
FilePath::new("src/app/s.rs").unwrap(),
|
||||
1,
|
||||
)
|
||||
.unwrap()
|
||||
.with_module(ModuleName::new("App").unwrap()),
|
||||
);
|
||||
graph.add_element(
|
||||
CodeElement::new(
|
||||
"Order",
|
||||
CodeElementKind::Class,
|
||||
FilePath::new("src/domain/o.rs").unwrap(),
|
||||
1,
|
||||
)
|
||||
.unwrap()
|
||||
.with_module(ModuleName::new("Domain").unwrap()),
|
||||
);
|
||||
graph.add_relationship(
|
||||
Relationship::new("Service", "Order", RelationshipKind::Composition).unwrap(),
|
||||
);
|
||||
let graph = graph.qualify();
|
||||
|
||||
let renderer = MermaidRenderer::with_level(DiagramLevel::Module);
|
||||
let output = renderer.render(&graph).unwrap();
|
||||
let content = output.files()[0].content();
|
||||
|
||||
assert!(
|
||||
content.contains(r#"|"1 dep"|"#),
|
||||
"expected singular dep label in: {content}"
|
||||
);
|
||||
}
|
||||
|
||||
13
crates/adapters/python-project/Cargo.toml
Normal file
13
crates/adapters/python-project/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "archlens-python-project"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
archlens-domain.workspace = true
|
||||
toml.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
3
crates/adapters/python-project/src/lib.rs
Normal file
3
crates/adapters/python-project/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod python_project_analyzer;
|
||||
|
||||
pub use python_project_analyzer::PythonProjectAnalyzer;
|
||||
151
crates/adapters/python-project/src/python_project_analyzer.rs
Normal file
151
crates/adapters/python-project/src/python_project_analyzer.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use archlens_domain::{
|
||||
CodeElement, CodeElementKind, CodeGraph, DomainError, FilePath, Relationship, RelationshipKind,
|
||||
ports::ProjectAnalyzer,
|
||||
};
|
||||
|
||||
pub struct PythonProjectAnalyzer;
|
||||
|
||||
impl Default for PythonProjectAnalyzer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PythonProjectAnalyzer {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
// PEP 621 format
|
||||
#[derive(Deserialize, Default)]
|
||||
struct ProjectSection {
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
dependencies: Vec<String>,
|
||||
}
|
||||
|
||||
// Poetry format
|
||||
#[derive(Deserialize, Default)]
|
||||
struct PoetrySection {
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
dependencies: HashMap<String, toml::Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct ToolSection {
|
||||
#[serde(default)]
|
||||
poetry: PoetrySection,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PyprojectToml {
|
||||
project: Option<ProjectSection>,
|
||||
#[serde(default)]
|
||||
tool: ToolSection,
|
||||
}
|
||||
|
||||
fn extract_dep_name(dep: &str) -> &str {
|
||||
dep.split(&['>', '<', '=', '!', '[', ';', ' '][..])
|
||||
.next()
|
||||
.unwrap_or(dep)
|
||||
.trim()
|
||||
}
|
||||
|
||||
fn normalize(name: &str) -> String {
|
||||
name.to_lowercase().replace(['-', '.'], "_")
|
||||
}
|
||||
|
||||
impl ProjectAnalyzer for PythonProjectAnalyzer {
|
||||
fn analyze(&self, root: &Path) -> Result<CodeGraph, DomainError> {
|
||||
// 1. Scan immediate subdirectories for pyproject.toml
|
||||
let entries = std::fs::read_dir(root).map_err(|e| DomainError::IoError(e.to_string()))?;
|
||||
|
||||
let mut packages: Vec<(String, String, Vec<String>)> = Vec::new(); // (dir, name, deps)
|
||||
|
||||
for entry in entries.filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let pyproject = path.join("pyproject.toml");
|
||||
if !pyproject.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&pyproject)
|
||||
.map_err(|e| DomainError::IoError(e.to_string()))?;
|
||||
let parsed: PyprojectToml =
|
||||
toml::from_str(&content).map_err(|e| DomainError::ConfigError(e.to_string()))?;
|
||||
|
||||
let dir_name = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
// Try PEP 621 [project] first, then Poetry [tool.poetry]
|
||||
let (name, deps) = if let Some(proj) = parsed.project {
|
||||
let name = proj.name.unwrap_or_else(|| dir_name.clone());
|
||||
let deps: Vec<String> = proj
|
||||
.dependencies
|
||||
.iter()
|
||||
.map(|d| extract_dep_name(d).to_string())
|
||||
.collect();
|
||||
(name, deps)
|
||||
} else if let Some(pname) = parsed.tool.poetry.name {
|
||||
let deps: Vec<String> = parsed
|
||||
.tool
|
||||
.poetry
|
||||
.dependencies
|
||||
.keys()
|
||||
.filter(|k| k.as_str() != "python")
|
||||
.cloned()
|
||||
.collect();
|
||||
(pname, deps)
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
packages.push((dir_name, name, deps));
|
||||
}
|
||||
|
||||
let known: HashSet<String> = packages
|
||||
.iter()
|
||||
.map(|(_, name, _)| normalize(name))
|
||||
.collect();
|
||||
|
||||
let mut graph = CodeGraph::new();
|
||||
|
||||
for (dir, name, _) in &packages {
|
||||
let file_path = FilePath::new(&format!("{}/pyproject.toml", dir))
|
||||
.map_err(|e| DomainError::IoError(e.to_string()))?;
|
||||
let element = CodeElement::new(name, CodeElementKind::Project, file_path, 1)?;
|
||||
graph.add_element(element);
|
||||
}
|
||||
|
||||
for (_, pkg_name, deps) in &packages {
|
||||
for dep in deps {
|
||||
let dep_norm = normalize(dep);
|
||||
if known.contains(&dep_norm) && dep_norm != normalize(pkg_name) {
|
||||
// find the canonical name (original casing) of the dep
|
||||
if let Some((_, canonical, _)) =
|
||||
packages.iter().find(|(_, n, _)| normalize(n) == dep_norm)
|
||||
&& let Ok(rel) =
|
||||
Relationship::new(pkg_name, canonical, RelationshipKind::Composition)
|
||||
{
|
||||
graph.add_relationship(rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(graph)
|
||||
}
|
||||
}
|
||||
165
crates/adapters/python-project/tests/python_project_tests.rs
Normal file
165
crates/adapters/python-project/tests/python_project_tests.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
use std::fs;
|
||||
|
||||
use archlens_domain::{CodeElementKind, RelationshipKind, ports::ProjectAnalyzer};
|
||||
use archlens_python_project::PythonProjectAnalyzer;
|
||||
|
||||
fn create_monorepo(dir: &std::path::Path) {
|
||||
fs::create_dir_all(dir.join("api")).unwrap();
|
||||
fs::create_dir_all(dir.join("commons")).unwrap();
|
||||
fs::create_dir_all(dir.join("worker")).unwrap();
|
||||
|
||||
fs::write(
|
||||
dir.join("api/pyproject.toml"),
|
||||
r#"
|
||||
[project]
|
||||
name = "my-api"
|
||||
dependencies = [
|
||||
"my-commons>=1.0",
|
||||
"fastapi",
|
||||
]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
fs::write(
|
||||
dir.join("commons/pyproject.toml"),
|
||||
r#"
|
||||
[project]
|
||||
name = "my-commons"
|
||||
dependencies = []
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
fs::write(
|
||||
dir.join("worker/pyproject.toml"),
|
||||
r#"
|
||||
[project]
|
||||
name = "my-worker"
|
||||
dependencies = [
|
||||
"my-commons>=1.0",
|
||||
"celery",
|
||||
]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_python_packages_as_project_elements() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
create_monorepo(dir.path());
|
||||
|
||||
let analyzer = PythonProjectAnalyzer::new();
|
||||
let graph = analyzer.analyze(dir.path()).unwrap();
|
||||
|
||||
assert_eq!(graph.elements().len(), 3);
|
||||
assert!(
|
||||
graph
|
||||
.elements()
|
||||
.iter()
|
||||
.all(|e| e.kind() == CodeElementKind::Project)
|
||||
);
|
||||
|
||||
let names: Vec<&str> = graph.elements().iter().map(|e| e.name()).collect();
|
||||
assert!(names.contains(&"my-api"));
|
||||
assert!(names.contains(&"my-commons"));
|
||||
assert!(names.contains(&"my-worker"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_intra_project_dependencies_from_pep621() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
create_monorepo(dir.path());
|
||||
|
||||
let analyzer = PythonProjectAnalyzer::new();
|
||||
let graph = analyzer.analyze(dir.path()).unwrap();
|
||||
|
||||
let deps: Vec<(&str, &str)> = graph
|
||||
.relationships()
|
||||
.iter()
|
||||
.map(|r| (r.source(), r.target()))
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
deps.contains(&("my-api", "my-commons")),
|
||||
"missing api->commons: {deps:?}"
|
||||
);
|
||||
assert!(
|
||||
deps.contains(&("my-worker", "my-commons")),
|
||||
"missing worker->commons: {deps:?}"
|
||||
);
|
||||
assert!(
|
||||
graph
|
||||
.relationships()
|
||||
.iter()
|
||||
.all(|r| r.kind() == RelationshipKind::Composition)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excludes_external_dependencies() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
create_monorepo(dir.path());
|
||||
|
||||
let analyzer = PythonProjectAnalyzer::new();
|
||||
let graph = analyzer.analyze(dir.path()).unwrap();
|
||||
|
||||
let targets: Vec<&str> = graph.relationships().iter().map(|r| r.target()).collect();
|
||||
assert!(!targets.contains(&"fastapi"), "fastapi should be excluded");
|
||||
assert!(!targets.contains(&"celery"), "celery should be excluded");
|
||||
}
|
||||
|
||||
fn create_poetry_monorepo(dir: &std::path::Path) {
|
||||
fs::create_dir_all(dir.join("api")).unwrap();
|
||||
fs::create_dir_all(dir.join("commons")).unwrap();
|
||||
|
||||
fs::write(
|
||||
dir.join("api/pyproject.toml"),
|
||||
r#"
|
||||
[tool.poetry]
|
||||
name = "my-api"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.11"
|
||||
my-commons = {path = "../commons"}
|
||||
httpx = "^0.27"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
fs::write(
|
||||
dir.join("commons/pyproject.toml"),
|
||||
r#"
|
||||
[tool.poetry]
|
||||
name = "my-commons"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.11"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_intra_project_dependencies_from_poetry() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
create_poetry_monorepo(dir.path());
|
||||
|
||||
let analyzer = PythonProjectAnalyzer::new();
|
||||
let graph = analyzer.analyze(dir.path()).unwrap();
|
||||
|
||||
assert_eq!(graph.elements().len(), 2);
|
||||
let deps: Vec<(&str, &str)> = graph
|
||||
.relationships()
|
||||
.iter()
|
||||
.map(|r| (r.source(), r.target()))
|
||||
.collect();
|
||||
assert!(
|
||||
deps.contains(&("my-api", "my-commons")),
|
||||
"missing api->commons: {deps:?}"
|
||||
);
|
||||
|
||||
let targets: Vec<&str> = graph.relationships().iter().map(|r| r.target()).collect();
|
||||
assert!(!targets.contains(&"httpx"), "httpx should be excluded");
|
||||
}
|
||||
@@ -7,6 +7,14 @@ use archlens_domain::{
|
||||
AnalysisConfig, DiagramLevel, DomainError, OutputConfig, ports::ConfigLoader,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct RawRules {
|
||||
#[serde(default)]
|
||||
allow: Vec<String>,
|
||||
#[serde(default)]
|
||||
deny: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct RawConfig {
|
||||
#[serde(default)]
|
||||
@@ -15,6 +23,8 @@ struct RawConfig {
|
||||
output: RawOutput,
|
||||
#[serde(default)]
|
||||
modules: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
rules: RawRules,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
@@ -68,6 +78,10 @@ impl ConfigLoader for TomlConfigLoader {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn load_rules(&self) -> (Vec<String>, Vec<String>) {
|
||||
(self.raw.rules.allow.clone(), self.raw.rules.deny.clone())
|
||||
}
|
||||
|
||||
fn load_output_config(&self) -> Result<OutputConfig, DomainError> {
|
||||
let mut config =
|
||||
OutputConfig::default().with_split_by_module(self.raw.output.split_by_module);
|
||||
|
||||
@@ -66,3 +66,27 @@ fn missing_file_returns_defaults() {
|
||||
assert!(!output.split_by_module());
|
||||
assert!(output.output_path().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_boundary_rules_from_toml_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config_path = dir.path().join("archlens.toml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"
|
||||
[rules]
|
||||
allow = ["Application --> Domain", "Adapters --> Domain"]
|
||||
deny = ["Domain --> Adapters"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let loader = TomlConfigLoader::from_path(&config_path).unwrap();
|
||||
let (allow, deny) = loader.load_rules();
|
||||
|
||||
assert_eq!(allow.len(), 2);
|
||||
assert_eq!(deny.len(), 1);
|
||||
assert!(allow.iter().any(|r| r == "Application --> Domain"));
|
||||
assert!(allow.iter().any(|r| r == "Adapters --> Domain"));
|
||||
assert!(deny.iter().any(|r| r == "Domain --> Adapters"));
|
||||
}
|
||||
|
||||
@@ -74,10 +74,15 @@ fn collect_classes(
|
||||
let name = &source[name_node.byte_range()];
|
||||
let line = child.start_position().row + 1;
|
||||
|
||||
let methods = child
|
||||
.child_by_field_name("body")
|
||||
.map(|body| collect_methods(&body, source))
|
||||
.unwrap_or_default();
|
||||
|
||||
match CodeElement::new(name, CodeElementKind::Class, file_path.clone(), line) {
|
||||
Ok(element) => {
|
||||
type_names.insert(name.to_string());
|
||||
elements.push(element);
|
||||
elements.push(element.with_methods(methods));
|
||||
}
|
||||
Err(e) => {
|
||||
if let Ok(w) = AnalysisWarning::new(file_path.clone(), line, &e.to_string()) {
|
||||
@@ -267,6 +272,73 @@ fn collect_imports(
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_methods(body: &Node, source: &str) -> Vec<String> {
|
||||
let mut methods = Vec::new();
|
||||
let mut cursor = body.walk();
|
||||
for child in body.children(&mut cursor) {
|
||||
if child.kind() != "function_definition" {
|
||||
continue;
|
||||
}
|
||||
let Some(name_node) = child.child_by_field_name("name") else {
|
||||
continue;
|
||||
};
|
||||
let fn_name = &source[name_node.byte_range()];
|
||||
if fn_name.starts_with('_') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let params = child
|
||||
.child_by_field_name("parameters")
|
||||
.map(|p| extract_python_params(&p, source))
|
||||
.unwrap_or_default();
|
||||
|
||||
let ret = child
|
||||
.child_by_field_name("return_type")
|
||||
.map(|n| source[n.byte_range()].trim().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let sig = if ret.is_empty() {
|
||||
format!("+{fn_name}({params})")
|
||||
} else {
|
||||
format!("+{fn_name}({params}) -> {ret}")
|
||||
};
|
||||
methods.push(sig);
|
||||
}
|
||||
methods
|
||||
}
|
||||
|
||||
fn extract_python_params(params_node: &Node, source: &str) -> String {
|
||||
let mut parts = Vec::new();
|
||||
let mut cursor = params_node.walk();
|
||||
for param in params_node.children(&mut cursor) {
|
||||
match param.kind() {
|
||||
"typed_parameter" => {
|
||||
if let Some(type_node) = param.child_by_field_name("type") {
|
||||
// name is the first identifier child (not a named field)
|
||||
let mut inner = param.walk();
|
||||
let name = param
|
||||
.children(&mut inner)
|
||||
.find(|c| c.kind() == "identifier")
|
||||
.map(|c| &source[c.byte_range()])
|
||||
.unwrap_or_default();
|
||||
if name != "self" && name != "cls" && !name.is_empty() {
|
||||
let ty = source[type_node.byte_range()].trim();
|
||||
parts.push(format!("{name}: {ty}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
"identifier" => {
|
||||
let name = &source[param.byte_range()];
|
||||
if name != "self" && name != "cls" {
|
||||
parts.push(name.to_string());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
parts.join(", ")
|
||||
}
|
||||
|
||||
fn collect_constructor_params(
|
||||
body: &Node,
|
||||
source: &str,
|
||||
|
||||
@@ -270,7 +270,14 @@ fn extract_methods(root: &Node, source: &str, type_name: &str) -> Vec<String> {
|
||||
} else {
|
||||
"-"
|
||||
};
|
||||
methods.push(format!("{vis}{fn_name}()"));
|
||||
let params = extract_fn_params(&item, source);
|
||||
let ret = extract_fn_return(&item, source);
|
||||
let sig = if ret.is_empty() {
|
||||
format!("{vis}{fn_name}({params})")
|
||||
} else {
|
||||
format!("{vis}{fn_name}({params}) -> {ret}")
|
||||
};
|
||||
methods.push(sig);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -278,6 +285,38 @@ fn extract_methods(root: &Node, source: &str, type_name: &str) -> Vec<String> {
|
||||
methods
|
||||
}
|
||||
|
||||
fn extract_fn_params(fn_item: &Node, source: &str) -> String {
|
||||
let Some(params_node) = fn_item.child_by_field_name("parameters") else {
|
||||
return String::new();
|
||||
};
|
||||
let mut parts = Vec::new();
|
||||
let mut cursor = params_node.walk();
|
||||
for param in params_node.children(&mut cursor) {
|
||||
match param.kind() {
|
||||
"parameter" => {
|
||||
if let (Some(pat), Some(ty)) = (
|
||||
param.child_by_field_name("pattern"),
|
||||
param.child_by_field_name("type"),
|
||||
) {
|
||||
let name = &source[pat.byte_range()];
|
||||
let ty_text = source[ty.byte_range()].trim();
|
||||
parts.push(format!("{name}: {ty_text}"));
|
||||
}
|
||||
}
|
||||
"self_parameter" | "&self" | "self" => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
parts.join(", ")
|
||||
}
|
||||
|
||||
fn extract_fn_return(fn_item: &Node, source: &str) -> String {
|
||||
fn_item
|
||||
.child_by_field_name("return_type")
|
||||
.map(|n| source[n.byte_range()].trim().to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn collect_mod_declarations(
|
||||
node: &Node,
|
||||
source: &str,
|
||||
|
||||
@@ -136,3 +136,74 @@ fn extracts_composition_from_class_level_annotations() {
|
||||
assert_eq!(composition[0].source(), "Definition");
|
||||
assert_eq!(composition[0].target(), "Gad");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_python_class_methods() {
|
||||
let source = "class OrderService:\n def process(self):\n pass\n def cancel(self):\n pass\n";
|
||||
let result = analyze_python(source, "service.py");
|
||||
|
||||
let element = result
|
||||
.elements()
|
||||
.iter()
|
||||
.find(|e| e.name() == "OrderService")
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
element.methods().iter().any(|m| m.contains("process")),
|
||||
"expected 'process' method, got: {:?}",
|
||||
element.methods()
|
||||
);
|
||||
assert!(
|
||||
element.methods().iter().any(|m| m.contains("cancel")),
|
||||
"expected 'cancel' method, got: {:?}",
|
||||
element.methods()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_python_method_typed_params() {
|
||||
let source = "class OrderService:\n def process(self, order: Order, count: int) -> None:\n pass\n";
|
||||
let result = analyze_python(source, "service.py");
|
||||
|
||||
let element = result
|
||||
.elements()
|
||||
.iter()
|
||||
.find(|e| e.name() == "OrderService")
|
||||
.unwrap();
|
||||
|
||||
let method = element
|
||||
.methods()
|
||||
.iter()
|
||||
.find(|m| m.contains("process"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
method.contains("order: Order"),
|
||||
"missing typed param: {method}"
|
||||
);
|
||||
assert!(
|
||||
method.contains("count: int"),
|
||||
"missing typed param: {method}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_python_method_return_annotation() {
|
||||
let source = "class OrderService:\n def get(self) -> Order:\n pass\n";
|
||||
let result = analyze_python(source, "service.py");
|
||||
|
||||
let element = result
|
||||
.elements()
|
||||
.iter()
|
||||
.find(|e| e.name() == "OrderService")
|
||||
.unwrap();
|
||||
|
||||
let method = element
|
||||
.methods()
|
||||
.iter()
|
||||
.find(|m| m.contains("get"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
method.contains("-> Order"),
|
||||
"expected return type, got: {method}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,3 +128,145 @@ fn extracts_mod_declarations() {
|
||||
assert!(imports.iter().any(|r| r.target() == "crate::models"));
|
||||
assert!(imports.iter().any(|r| r.target() == "crate::services"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_rust_method_with_typed_params() {
|
||||
let source = r#"
|
||||
pub struct OrderService;
|
||||
impl OrderService {
|
||||
pub fn process(&self, order: Order, count: u64) {}
|
||||
}
|
||||
"#;
|
||||
let result = analyze_rust(source, "service.rs");
|
||||
|
||||
let element = result
|
||||
.elements()
|
||||
.iter()
|
||||
.find(|e| e.name() == "OrderService")
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
element
|
||||
.methods()
|
||||
.iter()
|
||||
.any(|m| m.contains("order: Order") && m.contains("count: u64")),
|
||||
"expected typed params in method, got: {:?}",
|
||||
element.methods()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_rust_method_return_type() {
|
||||
let source = r#"
|
||||
pub struct OrderService;
|
||||
impl OrderService {
|
||||
pub fn get(&self) -> Order {}
|
||||
}
|
||||
"#;
|
||||
let result = analyze_rust(source, "service.rs");
|
||||
|
||||
let element = result
|
||||
.elements()
|
||||
.iter()
|
||||
.find(|e| e.name() == "OrderService")
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
element.methods().iter().any(|m| m.contains("-> Order")),
|
||||
"expected return type in method, got: {:?}",
|
||||
element.methods()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_rust_method_params_and_return() {
|
||||
let source = r#"
|
||||
pub struct OrderService;
|
||||
impl OrderService {
|
||||
pub fn process(&self, order: Order) -> Result<(), Error> {}
|
||||
}
|
||||
"#;
|
||||
let result = analyze_rust(source, "service.rs");
|
||||
|
||||
let element = result
|
||||
.elements()
|
||||
.iter()
|
||||
.find(|e| e.name() == "OrderService")
|
||||
.unwrap();
|
||||
|
||||
let method = element
|
||||
.methods()
|
||||
.iter()
|
||||
.find(|m| m.contains("process"))
|
||||
.unwrap();
|
||||
assert!(method.contains("order: Order"), "missing param: {method}");
|
||||
assert!(method.contains("->"), "missing return arrow: {method}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_rust_static_method_params() {
|
||||
let source = r#"
|
||||
pub struct Finder;
|
||||
impl Finder {
|
||||
pub fn detect(path: &str, count: usize) -> bool { false }
|
||||
}
|
||||
"#;
|
||||
let result = analyze_rust(source, "finder.rs");
|
||||
let element = result
|
||||
.elements()
|
||||
.iter()
|
||||
.find(|e| e.name() == "Finder")
|
||||
.unwrap();
|
||||
let method = element
|
||||
.methods()
|
||||
.iter()
|
||||
.find(|m| m.contains("detect"))
|
||||
.unwrap();
|
||||
assert!(method.contains("path"), "missing path param: {method}");
|
||||
assert!(method.contains("count"), "missing count param: {method}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_rust_private_method_params() {
|
||||
let source = r#"
|
||||
pub struct WalkdirDiscovery;
|
||||
impl WalkdirDiscovery {
|
||||
fn detect_language(path: &std::path::Path) -> Option<String> { None }
|
||||
}
|
||||
"#;
|
||||
let result = analyze_rust(source, "discovery.rs");
|
||||
let element = result
|
||||
.elements()
|
||||
.iter()
|
||||
.find(|e| e.name() == "WalkdirDiscovery")
|
||||
.unwrap();
|
||||
let method = element
|
||||
.methods()
|
||||
.iter()
|
||||
.find(|m| m.contains("detect_language"))
|
||||
.unwrap();
|
||||
assert!(method.contains("path"), "missing path param: {method}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_rust_method_reference_param() {
|
||||
let source = r#"
|
||||
use std::path::Path;
|
||||
pub struct WalkdirDiscovery;
|
||||
impl WalkdirDiscovery {
|
||||
fn detect_language(path: &Path) -> Option<String> { None }
|
||||
}
|
||||
"#;
|
||||
let result = analyze_rust(source, "discovery.rs");
|
||||
let element = result
|
||||
.elements()
|
||||
.iter()
|
||||
.find(|e| e.name() == "WalkdirDiscovery")
|
||||
.unwrap();
|
||||
let method = element
|
||||
.methods()
|
||||
.iter()
|
||||
.find(|m| m.contains("detect_language"))
|
||||
.unwrap();
|
||||
assert!(method.contains("path"), "missing path param: {method}");
|
||||
}
|
||||
|
||||
@@ -42,6 +42,27 @@ impl WalkdirDiscovery {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_test_file(path: &Path, language: Language) -> bool {
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or_default();
|
||||
let in_tests_dir = path
|
||||
.parent()
|
||||
.map(|p| p.components().any(|c| c.as_os_str() == "tests"))
|
||||
.unwrap_or(false);
|
||||
|
||||
if in_tests_dir {
|
||||
return true;
|
||||
}
|
||||
|
||||
match language {
|
||||
Language::Rust => stem.ends_with("_test") || stem.ends_with("_tests"),
|
||||
Language::Python => stem.starts_with("test_") || stem.ends_with("_test"),
|
||||
Language::CSharp => stem.ends_with("Tests") || stem.ends_with("Test"),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_excluded(path: &Path, root: &Path, excludes: &[String]) -> bool {
|
||||
let relative = path.strip_prefix(root).unwrap_or(path);
|
||||
let relative_str = relative.to_string_lossy();
|
||||
@@ -88,6 +109,18 @@ impl FileDiscovery for WalkdirDiscovery {
|
||||
}
|
||||
|
||||
if let Some(language) = Self::detect_language(path) {
|
||||
if !config.include_tests() && Self::is_test_file(path, language) {
|
||||
continue;
|
||||
}
|
||||
if let Some(changed) = config.changed_files() {
|
||||
let relative = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
|
||||
if !changed
|
||||
.iter()
|
||||
.any(|c| relative.ends_with(c.as_str()) || c.ends_with(relative.as_ref()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let absolute = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
let file_path = FilePath::new(&absolute.to_string_lossy())
|
||||
.map_err(|e| DomainError::IoError(e.to_string()))?;
|
||||
|
||||
@@ -58,6 +58,100 @@ fn respects_exclude_patterns() {
|
||||
assert!(!files.iter().any(|f| f.path().as_str().contains("billing")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excludes_python_test_prefix_files_by_default() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(dir.path().join("orders.py"), "class Order: pass").unwrap();
|
||||
fs::write(dir.path().join("test_orders.py"), "class TestOrder: pass").unwrap();
|
||||
|
||||
let discovery = WalkdirDiscovery::new();
|
||||
let files = discovery
|
||||
.discover(dir.path(), &AnalysisConfig::default())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(files.len(), 1);
|
||||
assert!(files[0].path().as_str().ends_with("orders.py"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excludes_python_test_suffix_files_by_default() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(dir.path().join("orders.py"), "class Order: pass").unwrap();
|
||||
fs::write(dir.path().join("orders_test.py"), "class OrderTest: pass").unwrap();
|
||||
|
||||
let discovery = WalkdirDiscovery::new();
|
||||
let files = discovery
|
||||
.discover(dir.path(), &AnalysisConfig::default())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(files.len(), 1);
|
||||
assert!(files[0].path().as_str().ends_with("orders.py"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excludes_files_in_tests_directory_by_default() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(dir.path().join("tests")).unwrap();
|
||||
fs::write(dir.path().join("orders.py"), "class Order: pass").unwrap();
|
||||
fs::write(dir.path().join("tests/helpers.py"), "class Helper: pass").unwrap();
|
||||
|
||||
let discovery = WalkdirDiscovery::new();
|
||||
let files = discovery
|
||||
.discover(dir.path(), &AnalysisConfig::default())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(files.len(), 1);
|
||||
assert!(files[0].path().as_str().ends_with("orders.py"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excludes_rust_test_files_by_default() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(dir.path().join("orders.rs"), "struct Order;").unwrap();
|
||||
fs::write(dir.path().join("orders_tests.rs"), "struct OrdersTests;").unwrap();
|
||||
|
||||
let discovery = WalkdirDiscovery::new();
|
||||
let files = discovery
|
||||
.discover(dir.path(), &AnalysisConfig::default())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(files.len(), 1);
|
||||
assert!(files[0].path().as_str().ends_with("orders.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excludes_rust_files_in_tests_directory_by_default() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(dir.path().join("tests")).unwrap();
|
||||
fs::write(dir.path().join("lib.rs"), "struct Lib;").unwrap();
|
||||
fs::write(
|
||||
dir.path().join("tests/integration.rs"),
|
||||
"struct IntegrationTest;",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let discovery = WalkdirDiscovery::new();
|
||||
let files = discovery
|
||||
.discover(dir.path(), &AnalysisConfig::default())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(files.len(), 1);
|
||||
assert!(files[0].path().as_str().ends_with("lib.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn include_tests_flag_re_enables_test_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(dir.path().join("orders.py"), "class Order: pass").unwrap();
|
||||
fs::write(dir.path().join("test_orders.py"), "class TestOrder: pass").unwrap();
|
||||
|
||||
let config = AnalysisConfig::default().with_include_tests(true);
|
||||
let discovery = WalkdirDiscovery::new();
|
||||
let files = discovery.discover(dir.path(), &config).unwrap();
|
||||
|
||||
assert_eq!(files.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_directory_returns_no_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user