feat: implement all P1/P2/P3/P4 improvements from issue backlog
Some checks failed
CI / Check / Test (push) Failing after 1m33s
Architecture Docs / Generate diagrams (push) Successful in 3m21s

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:
2026-06-17 09:50:50 +02:00
parent 27197062eb
commit fdd85011a4
42 changed files with 2767 additions and 92 deletions

View 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

View 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")
}

View File

@@ -0,0 +1,2 @@
mod d2_renderer;
pub use d2_renderer::D2Renderer;

View 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}");
}

View 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

View 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
)
}

View File

@@ -0,0 +1,2 @@
mod html_renderer;
pub use html_renderer::HtmlRenderer;

View 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"
);
}

View File

@@ -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}")
}
}

View File

@@ -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}"
);
}

View 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

View File

@@ -0,0 +1,3 @@
mod python_project_analyzer;
pub use python_project_analyzer::PythonProjectAnalyzer;

View 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)
}
}

View 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");
}

View File

@@ -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);

View File

@@ -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"));
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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}"
);
}

View File

@@ -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}");
}

View File

@@ -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()))?;

View File

@@ -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();