Skip to main content

lean_ctx/tools/
ctx_architecture.rs

1//! `ctx_architecture` — Graph-based architecture analysis tool.
2//!
3//! Discovers module clusters, dependency layers, entrypoints, cycles,
4//! and structural patterns from the Property Graph.
5
6use std::collections::{HashMap, HashSet, VecDeque};
7use std::path::Path;
8
9use crate::core::property_graph::CodeGraph;
10use crate::core::tokens::count_tokens;
11use serde_json::{json, Value};
12
13/// Dispatches architecture analysis actions (overview, clusters, layers, cycles, entrypoints, module).
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15enum OutputFormat {
16    Text,
17    Json,
18}
19
20fn parse_format(format: Option<&str>) -> Result<OutputFormat, String> {
21    let f = format.unwrap_or("text").trim().to_lowercase();
22    match f.as_str() {
23        "text" => Ok(OutputFormat::Text),
24        "json" => Ok(OutputFormat::Json),
25        _ => Err("Error: format must be text|json".to_string()),
26    }
27}
28
29pub fn handle(action: &str, path: Option<&str>, root: &str, format: Option<&str>) -> String {
30    let fmt = match parse_format(format) {
31        Ok(f) => f,
32        Err(e) => return e,
33    };
34
35    match action {
36        "overview" => handle_overview(root, fmt),
37        "clusters" => handle_clusters(root, fmt),
38        "communities" => handle_communities(root, fmt),
39        "layers" => handle_layers(root, fmt),
40        "cycles" => handle_cycles(root, fmt),
41        "entrypoints" => handle_entrypoints(root, fmt),
42        "hotspots" => handle_hotspots(root, fmt),
43        "health" => handle_health(root, fmt),
44        "module" => handle_module(path, root, fmt),
45        _ => "Unknown action. Use: overview, clusters, communities, layers, cycles, entrypoints, hotspots, health, module"
46            .to_string(),
47    }
48}
49
50fn open_graph(root: &str) -> Result<CodeGraph, String> {
51    CodeGraph::open(root).map_err(|e| format!("Failed to open graph: {e}"))
52}
53
54struct GraphData {
55    forward: HashMap<String, Vec<String>>,
56    reverse: HashMap<String, Vec<String>>,
57    all_files: HashSet<String>,
58}
59
60fn ensure_graph_built(root: &str) {
61    let Ok(graph) = CodeGraph::open(root) else {
62        return;
63    };
64    if graph.node_count().unwrap_or(0) == 0 {
65        drop(graph);
66        let result = crate::tools::ctx_impact::handle("build", None, root, None, None);
67        tracing::info!(
68            "Auto-built graph for architecture: {}",
69            &result[..result.len().min(100)]
70        );
71    }
72}
73
74fn load_graph_data(graph: &CodeGraph) -> Result<GraphData, String> {
75    let nodes = graph.node_count().map_err(|e| format!("{e}"))?;
76    if nodes == 0 {
77        return Err(
78            "Graph is empty after auto-build. No supported source files found.".to_string(),
79        );
80    }
81
82    let conn = &graph.connection();
83    let mut stmt = conn
84        .prepare(
85            "SELECT DISTINCT n_src.file_path, n_tgt.file_path
86         FROM edges e
87         JOIN nodes n_src ON e.source_id = n_src.id
88         JOIN nodes n_tgt ON e.target_id = n_tgt.id
89         WHERE e.kind = 'imports'
90           AND n_src.kind = 'file' AND n_tgt.kind = 'file'
91           AND n_src.file_path != n_tgt.file_path",
92        )
93        .map_err(|e| format!("{e}"))?;
94
95    let mut forward: HashMap<String, Vec<String>> = HashMap::new();
96    let mut reverse: HashMap<String, Vec<String>> = HashMap::new();
97    let mut all_files: HashSet<String> = HashSet::new();
98
99    let rows = stmt
100        .query_map([], |row| {
101            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
102        })
103        .map_err(|e| format!("{e}"))?;
104
105    for row in rows {
106        let (src, tgt) = row.map_err(|e| format!("{e}"))?;
107        all_files.insert(src.clone());
108        all_files.insert(tgt.clone());
109        forward.entry(src.clone()).or_default().push(tgt.clone());
110        reverse.entry(tgt).or_default().push(src);
111    }
112
113    let mut file_stmt = conn
114        .prepare("SELECT DISTINCT file_path FROM nodes WHERE kind = 'file'")
115        .map_err(|e| format!("{e}"))?;
116    let file_rows = file_stmt
117        .query_map([], |row| row.get::<_, String>(0))
118        .map_err(|e| format!("{e}"))?;
119    for f in file_rows.flatten() {
120        all_files.insert(f);
121    }
122
123    for deps in forward.values_mut() {
124        deps.sort();
125        deps.dedup();
126    }
127    for deps in reverse.values_mut() {
128        deps.sort();
129        deps.dedup();
130    }
131
132    Ok(GraphData {
133        forward,
134        reverse,
135        all_files,
136    })
137}
138
139fn handle_overview(root: &str, fmt: OutputFormat) -> String {
140    ensure_graph_built(root);
141
142    let graph = match open_graph(root) {
143        Ok(g) => g,
144        Err(e) => return e,
145    };
146
147    let data = match load_graph_data(&graph) {
148        Ok(d) => d,
149        Err(e) => return e,
150    };
151
152    let clusters = compute_clusters(&data);
153    let layers = compute_layers(&data);
154    let entrypoints = find_entrypoints(&data);
155    let cycles = find_cycles(&data);
156
157    let files_total = data.all_files.len();
158    let import_edges = data.forward.values().map(std::vec::Vec::len).sum::<usize>();
159
160    let clusters_total = clusters.len();
161    let clusters_limit = crate::core::budgets::ARCHITECTURE_OVERVIEW_CLUSTERS_LIMIT.max(1);
162    let clusters_truncated = clusters_total > clusters_limit;
163
164    let layers_total = layers.len();
165    let layers_limit = crate::core::budgets::ARCHITECTURE_OVERVIEW_LAYERS_LIMIT.max(1);
166    let layers_truncated = layers_total > layers_limit;
167
168    let entrypoints_total = entrypoints.len();
169    let entrypoints_limit = crate::core::budgets::ARCHITECTURE_OVERVIEW_ENTRYPOINTS_LIMIT.max(1);
170    let entrypoints_truncated = entrypoints_total > entrypoints_limit;
171
172    let cycles_total = cycles.len();
173    let cycles_limit = crate::core::budgets::ARCHITECTURE_OVERVIEW_CYCLES_LIMIT.max(1);
174    let cycles_truncated = cycles_total > cycles_limit;
175
176    match fmt {
177        OutputFormat::Json => {
178            let root_path = Path::new(root);
179            let clusters_json: Vec<Value> = clusters
180                .iter()
181                .take(clusters_limit)
182                .map(|c| {
183                    json!({
184                        "dir": common_prefix(&c.files),
185                        "file_count": c.files.len(),
186                        "internal_edges": c.internal_edges
187                    })
188                })
189                .collect();
190
191            let layers_json: Vec<Value> = layers
192                .iter()
193                .take(layers_limit)
194                .map(|l| {
195                    json!({
196                        "depth": l.depth,
197                        "file_count": l.files.len()
198                    })
199                })
200                .collect();
201
202            let entrypoints_json: Vec<Value> = entrypoints
203                .iter()
204                .take(entrypoints_limit)
205                .map(|ep| {
206                    let imports = data.forward.get(ep).map_or(0, std::vec::Vec::len);
207                    json!({ "file": ep, "imports": imports })
208                })
209                .collect();
210
211            let cycles_json: Vec<Value> = cycles
212                .iter()
213                .take(cycles_limit)
214                .map(|c| json!({ "path": c, "len": c.len().saturating_sub(1) }))
215                .collect();
216
217            let v = json!({
218                "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
219                "tool": "ctx_architecture",
220                "action": "overview",
221                "project": project_meta(root),
222                "graph": graph_summary(root_path),
223                "graph_meta": crate::core::property_graph::load_meta(root),
224                "files_total": files_total,
225                "import_edges": import_edges,
226                "clusters_total": clusters_total,
227                "clusters": clusters_json,
228                "clusters_truncated": clusters_truncated,
229                "layers_total": layers_total,
230                "layers": layers_json,
231                "layers_truncated": layers_truncated,
232                "entrypoints_total": entrypoints_total,
233                "entrypoints": entrypoints_json,
234                "entrypoints_truncated": entrypoints_truncated,
235                "cycles_total": cycles_total,
236                "cycles": cycles_json,
237                "cycles_truncated": cycles_truncated
238            });
239            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
240        }
241        OutputFormat::Text => {
242            let mut result = format!(
243                "Architecture Overview ({files_total} files, {import_edges} import edges)\n"
244            );
245
246            result.push_str(&format!("\nClusters: {clusters_total}\n"));
247            for (i, cluster) in clusters.iter().enumerate().take(clusters_limit) {
248                let dir = common_prefix(&cluster.files);
249                result.push_str(&format!(
250                    "  #{}: {} files ({})\n",
251                    i + 1,
252                    cluster.files.len(),
253                    dir
254                ));
255            }
256            if clusters_truncated {
257                result.push_str(&format!(
258                    "  ... +{} more\n",
259                    clusters_total - clusters_limit
260                ));
261            }
262
263            result.push_str(&format!("\nLayers: {layers_total}\n"));
264            for layer in layers.iter().take(layers_limit) {
265                result.push_str(&format!(
266                    "  L{}: {} files\n",
267                    layer.depth,
268                    layer.files.len()
269                ));
270            }
271            if layers_truncated {
272                result.push_str(&format!("  ... +{} more\n", layers_total - layers_limit));
273            }
274
275            result.push_str(&format!("\nEntrypoints: {entrypoints_total}\n"));
276            for ep in entrypoints.iter().take(entrypoints_limit) {
277                result.push_str(&format!("  {ep}\n"));
278            }
279            if entrypoints_truncated {
280                result.push_str(&format!(
281                    "  ... +{} more\n",
282                    entrypoints_total - entrypoints_limit
283                ));
284            }
285
286            result.push_str(&format!("\nCycles: {cycles_total}\n"));
287            for cycle in cycles.iter().take(cycles_limit) {
288                result.push_str(&format!("  {}\n", cycle.join(" -> ")));
289            }
290            if cycles_truncated {
291                result.push_str(&format!("  ... +{} more\n", cycles_total - cycles_limit));
292            }
293
294            let tokens = count_tokens(&result);
295            format!("{result}[ctx_architecture: {tokens} tok]")
296        }
297    }
298}
299
300fn handle_clusters(root: &str, fmt: OutputFormat) -> String {
301    ensure_graph_built(root);
302    let graph = match open_graph(root) {
303        Ok(g) => g,
304        Err(e) => return e,
305    };
306
307    let data = match load_graph_data(&graph) {
308        Ok(d) => d,
309        Err(e) => return e,
310    };
311
312    let clusters = compute_clusters(&data);
313    let total = clusters.len();
314    let limit = crate::core::budgets::ARCHITECTURE_CLUSTERS_LIMIT.max(1);
315    let file_limit = crate::core::budgets::ARCHITECTURE_CLUSTER_FILES_LIMIT.max(1);
316    let truncated = total > limit;
317
318    match fmt {
319        OutputFormat::Json => {
320            let root_path = Path::new(root);
321            let items: Vec<Value> = clusters
322                .iter()
323                .take(limit)
324                .map(|c| {
325                    let dir = common_prefix(&c.files);
326                    let files_total = c.files.len();
327                    let files_truncated = files_total > file_limit;
328                    let mut files = c.files.clone();
329                    if files_truncated {
330                        files.truncate(file_limit);
331                    }
332                    json!({
333                        "dir": dir,
334                        "file_count": files_total,
335                        "internal_edges": c.internal_edges,
336                        "files": files,
337                        "files_truncated": files_truncated
338                    })
339                })
340                .collect();
341            let v = json!({
342                "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
343                "tool": "ctx_architecture",
344                "action": "clusters",
345                "project": project_meta(root),
346                "graph": graph_summary(root_path),
347                "graph_meta": crate::core::property_graph::load_meta(root),
348                "clusters_total": total,
349                "clusters": items,
350                "truncated": truncated
351            });
352            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
353        }
354        OutputFormat::Text => {
355            let mut result = format!("Module Clusters ({total}):\n");
356
357            for (i, cluster) in clusters.iter().take(limit).enumerate() {
358                let dir = common_prefix(&cluster.files);
359                result.push_str(&format!(
360                    "\n#{} — {} ({} files, {} internal edges)\n",
361                    i + 1,
362                    dir,
363                    cluster.files.len(),
364                    cluster.internal_edges
365                ));
366                for file in cluster.files.iter().take(file_limit) {
367                    result.push_str(&format!("  {file}\n"));
368                }
369                if cluster.files.len() > file_limit {
370                    result.push_str(&format!(
371                        "  ... +{} more\n",
372                        cluster.files.len() - file_limit
373                    ));
374                }
375            }
376            if truncated {
377                result.push_str(&format!("\n... +{} more clusters\n", total - limit));
378            }
379
380            let tokens = count_tokens(&result);
381            format!("{result}[ctx_architecture clusters: {tokens} tok]")
382        }
383    }
384}
385
386fn handle_communities(root: &str, fmt: OutputFormat) -> String {
387    ensure_graph_built(root);
388    let graph = match open_graph(root) {
389        Ok(g) => g,
390        Err(e) => return e,
391    };
392
393    let result = crate::core::community::detect_communities(graph.connection());
394
395    match fmt {
396        OutputFormat::Json => {
397            let root_path = Path::new(root);
398            let comms: Vec<Value> = result
399                .communities
400                .iter()
401                .take(30)
402                .map(|c| {
403                    json!({
404                        "id": c.id,
405                        "file_count": c.files.len(),
406                        "files": c.files.iter().take(20).collect::<Vec<_>>(),
407                        "internal_edges": c.internal_edges,
408                        "external_edges": c.external_edges,
409                        "cohesion": (c.cohesion * 100.0).round() / 100.0,
410                    })
411                })
412                .collect();
413            let v = json!({
414                "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
415                "tool": "ctx_architecture",
416                "action": "communities",
417                "project": project_meta(root),
418                "graph": graph_summary(root_path),
419                "modularity": (result.modularity * 1000.0).round() / 1000.0,
420                "node_count": result.node_count,
421                "edge_count": result.edge_count,
422                "community_count": result.communities.len(),
423                "communities": comms
424            });
425            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
426        }
427        OutputFormat::Text => {
428            let mut out = format!(
429                "Community Detection (Louvain) — {} communities, modularity {:.3}\n\n",
430                result.communities.len(),
431                result.modularity
432            );
433            for c in result.communities.iter().take(20) {
434                out.push_str(&format!(
435                    "  Community #{}: {} files, cohesion {:.0}%, {} internal / {} external edges\n",
436                    c.id,
437                    c.files.len(),
438                    c.cohesion * 100.0,
439                    c.internal_edges,
440                    c.external_edges
441                ));
442                for f in c.files.iter().take(10) {
443                    out.push_str(&format!("    {f}\n"));
444                }
445                if c.files.len() > 10 {
446                    out.push_str(&format!("    ... +{} more\n", c.files.len() - 10));
447                }
448            }
449            if result.communities.len() > 20 {
450                out.push_str(&format!(
451                    "\n  ... +{} more communities\n",
452                    result.communities.len() - 20
453                ));
454            }
455            let tokens = count_tokens(&out);
456            format!("{out}\n[ctx_architecture communities: {tokens} tok]")
457        }
458    }
459}
460
461fn handle_layers(root: &str, fmt: OutputFormat) -> String {
462    ensure_graph_built(root);
463    let graph = match open_graph(root) {
464        Ok(g) => g,
465        Err(e) => return e,
466    };
467
468    let data = match load_graph_data(&graph) {
469        Ok(d) => d,
470        Err(e) => return e,
471    };
472
473    let layers = compute_layers(&data);
474    let total = layers.len();
475    let limit = crate::core::budgets::ARCHITECTURE_LAYERS_LIMIT.max(1);
476    let file_limit = crate::core::budgets::ARCHITECTURE_LAYER_FILES_LIMIT.max(1);
477    let truncated = total > limit;
478
479    match fmt {
480        OutputFormat::Json => {
481            let root_path = Path::new(root);
482            let items: Vec<Value> = layers
483                .iter()
484                .take(limit)
485                .map(|l| {
486                    let files_total = l.files.len();
487                    let files_truncated = files_total > file_limit;
488                    let mut files = l.files.clone();
489                    if files_truncated {
490                        files.truncate(file_limit);
491                    }
492                    json!({
493                        "depth": l.depth,
494                        "file_count": files_total,
495                        "files": files,
496                        "files_truncated": files_truncated
497                    })
498                })
499                .collect();
500            let v = json!({
501                "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
502                "tool": "ctx_architecture",
503                "action": "layers",
504                "project": project_meta(root),
505                "graph": graph_summary(root_path),
506                "graph_meta": crate::core::property_graph::load_meta(root),
507                "layers_total": total,
508                "layers": items,
509                "truncated": truncated
510            });
511            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
512        }
513        OutputFormat::Text => {
514            let mut result = format!("Dependency Layers ({total}):\n");
515
516            for layer in layers.iter().take(limit) {
517                result.push_str(&format!(
518                    "\nLayer {} ({} files):\n",
519                    layer.depth,
520                    layer.files.len()
521                ));
522                for file in layer.files.iter().take(file_limit) {
523                    result.push_str(&format!("  {file}\n"));
524                }
525                if layer.files.len() > file_limit {
526                    result.push_str(&format!("  ... +{} more\n", layer.files.len() - file_limit));
527                }
528            }
529            if truncated {
530                result.push_str(&format!("\n... +{} more layers\n", total - limit));
531            }
532
533            let tokens = count_tokens(&result);
534            format!("{result}[ctx_architecture layers: {tokens} tok]")
535        }
536    }
537}
538
539fn handle_cycles(root: &str, fmt: OutputFormat) -> String {
540    ensure_graph_built(root);
541    let graph = match open_graph(root) {
542        Ok(g) => g,
543        Err(e) => return e,
544    };
545
546    let data = match load_graph_data(&graph) {
547        Ok(d) => d,
548        Err(e) => return e,
549    };
550
551    let cycles = find_cycles(&data);
552    if cycles.is_empty() {
553        return match fmt {
554            OutputFormat::Json => {
555                let root_path = Path::new(root);
556                let v = json!({
557                    "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
558                    "tool": "ctx_architecture",
559                    "action": "cycles",
560                    "project": project_meta(root),
561                    "graph": graph_summary(root_path),
562                    "graph_meta": crate::core::property_graph::load_meta(root),
563                    "cycles_total": 0,
564                    "cycles": []
565                });
566                serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
567            }
568            OutputFormat::Text => "No dependency cycles found.".to_string(),
569        };
570    }
571
572    let total = cycles.len();
573    let limit = crate::core::budgets::ARCHITECTURE_CYCLES_LIMIT.max(1);
574    let truncated = total > limit;
575
576    match fmt {
577        OutputFormat::Json => {
578            let root_path = Path::new(root);
579            let items: Vec<Value> = cycles
580                .iter()
581                .take(limit)
582                .map(|c| json!({ "path": c, "len": c.len().saturating_sub(1) }))
583                .collect();
584            let v = json!({
585                "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
586                "tool": "ctx_architecture",
587                "action": "cycles",
588                "project": project_meta(root),
589                "graph": graph_summary(root_path),
590                "graph_meta": crate::core::property_graph::load_meta(root),
591                "cycles_total": total,
592                "cycles": items,
593                "truncated": truncated
594            });
595            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
596        }
597        OutputFormat::Text => {
598            let mut result = format!("Dependency Cycles ({total}):\n");
599            for (i, cycle) in cycles.iter().take(limit).enumerate() {
600                result.push_str(&format!("\n#{}: {}\n", i + 1, cycle.join(" -> ")));
601            }
602            if truncated {
603                result.push_str(&format!("\n... +{} more cycles\n", total - limit));
604            }
605
606            let tokens = count_tokens(&result);
607            format!("{result}[ctx_architecture cycles: {tokens} tok]")
608        }
609    }
610}
611
612fn handle_entrypoints(root: &str, fmt: OutputFormat) -> String {
613    ensure_graph_built(root);
614    let graph = match open_graph(root) {
615        Ok(g) => g,
616        Err(e) => return e,
617    };
618
619    let data = match load_graph_data(&graph) {
620        Ok(d) => d,
621        Err(e) => return e,
622    };
623
624    let entrypoints = find_entrypoints(&data);
625    let total = entrypoints.len();
626    let limit = crate::core::budgets::ARCHITECTURE_ENTRYPOINTS_LIMIT.max(1);
627    let truncated = total > limit;
628
629    match fmt {
630        OutputFormat::Json => {
631            let root_path = Path::new(root);
632            let items: Vec<Value> = entrypoints
633                .iter()
634                .take(limit)
635                .map(|ep| {
636                    let imports = data.forward.get(ep).map_or(0, std::vec::Vec::len);
637                    json!({ "file": ep, "imports": imports })
638                })
639                .collect();
640            let v = json!({
641                "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
642                "tool": "ctx_architecture",
643                "action": "entrypoints",
644                "project": project_meta(root),
645                "graph": graph_summary(root_path),
646                "graph_meta": crate::core::property_graph::load_meta(root),
647                "entrypoints_total": total,
648                "entrypoints": items,
649                "truncated": truncated
650            });
651            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
652        }
653        OutputFormat::Text => {
654            let mut result = format!("Entrypoints ({total} — files with no dependents):\n");
655            for ep in entrypoints.iter().take(limit) {
656                let dep_count = data.forward.get(ep).map_or(0, std::vec::Vec::len);
657                result.push_str(&format!("  {ep} (imports {dep_count} files)\n"));
658            }
659            if truncated {
660                result.push_str(&format!("  ... +{} more\n", total - limit));
661            }
662
663            let tokens = count_tokens(&result);
664            format!("{result}[ctx_architecture entrypoints: {tokens} tok]")
665        }
666    }
667}
668
669fn handle_hotspots(root: &str, fmt: OutputFormat) -> String {
670    ensure_graph_built(root);
671    let graph = match open_graph(root) {
672        Ok(g) => g,
673        Err(e) => return e,
674    };
675
676    let data = match load_graph_data(&graph) {
677        Ok(d) => d,
678        Err(e) => return e,
679    };
680
681    let pr_input = crate::core::pagerank::PageRankInput {
682        files: data.all_files.clone(),
683        forward: data.forward.clone(),
684    };
685    let pagerank = crate::core::pagerank::compute(&pr_input, 0.85, 30);
686    let cfg = crate::core::smells::SmellConfig::default();
687    let findings = crate::core::smells::scan_all(graph.connection(), &cfg);
688
689    let mut smell_count: HashMap<String, usize> = HashMap::new();
690    for f in &findings {
691        *smell_count.entry(f.file_path.clone()).or_default() += 1;
692    }
693
694    let mut hotspots: Vec<(String, f64, f64, usize, usize)> = pagerank
695        .iter()
696        .map(|(file, &rank)| {
697            let in_edges = data.reverse.get(file).map_or(0, Vec::len);
698            let out_edges = data.forward.get(file).map_or(0, Vec::len);
699            let smells = smell_count.get(file).copied().unwrap_or(0);
700            let score = rank * 0.4
701                + (in_edges + out_edges) as f64 * 0.01 * 0.3
702                + smells as f64 * 0.05 * 0.3;
703            (file.clone(), score, rank, in_edges + out_edges, smells)
704        })
705        .collect();
706
707    hotspots.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
708
709    let limit = 30;
710    match fmt {
711        OutputFormat::Json => {
712            let items: Vec<Value> = hotspots
713                .iter()
714                .take(limit)
715                .map(|(file, score, rank, edges, smells)| {
716                    json!({
717                        "file": file,
718                        "score": (score * 1000.0).round() / 1000.0,
719                        "pagerank": (rank * 10000.0).round() / 10000.0,
720                        "edges": edges,
721                        "smells": smells
722                    })
723                })
724                .collect();
725            let v = json!({
726                "tool": "ctx_architecture",
727                "action": "hotspots",
728                "total_files": data.all_files.len(),
729                "hotspots": items
730            });
731            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
732        }
733        OutputFormat::Text => {
734            let mut result = format!(
735                "Hotspots ({} files analyzed)\n\n  {:<50} {:>8} {:>8} {:>6} {:>6}\n",
736                data.all_files.len(),
737                "File",
738                "Score",
739                "PageRank",
740                "Edges",
741                "Smells"
742            );
743            result.push_str(&format!("  {}\n", "-".repeat(82)));
744            for (file, score, rank, edges, smells) in hotspots.iter().take(limit) {
745                let display = if file.len() > 48 {
746                    format!("...{}", &file[file.len() - 45..])
747                } else {
748                    file.clone()
749                };
750                result.push_str(&format!(
751                    "  {display:<50} {score:>8.3} {rank:>8.4} {edges:>6} {smells:>6}\n"
752                ));
753            }
754            if hotspots.len() > limit {
755                result.push_str(&format!("\n  ... +{} more\n", hotspots.len() - limit));
756            }
757            let tokens = count_tokens(&result);
758            format!("{result}\n[ctx_architecture hotspots: {tokens} tok]")
759        }
760    }
761}
762
763fn handle_health(root: &str, fmt: OutputFormat) -> String {
764    ensure_graph_built(root);
765    let graph = match open_graph(root) {
766        Ok(g) => g,
767        Err(e) => return e,
768    };
769
770    let data = match load_graph_data(&graph) {
771        Ok(d) => d,
772        Err(e) => return e,
773    };
774
775    let communities = crate::core::community::detect_communities(graph.connection());
776    let cfg = crate::core::smells::SmellConfig::default();
777    let findings = crate::core::smells::scan_all(graph.connection(), &cfg);
778    let summary = crate::core::smells::summarize(&findings);
779    let cycles = find_cycles(&data);
780    let layers = compute_layers(&data);
781
782    let total_smells: usize = summary.iter().map(|s| s.findings).sum();
783    let files = data.all_files.len();
784    let edges = data.forward.values().map(Vec::len).sum::<usize>();
785
786    let smell_density = if files > 0 {
787        total_smells as f64 / files as f64
788    } else {
789        0.0
790    };
791    let avg_cohesion = if communities.communities.is_empty() {
792        0.0
793    } else {
794        communities
795            .communities
796            .iter()
797            .map(|c| c.cohesion)
798            .sum::<f64>()
799            / communities.communities.len() as f64
800    };
801
802    let health_score = compute_health_score(
803        smell_density,
804        avg_cohesion,
805        communities.modularity,
806        cycles.len(),
807        files,
808    );
809
810    let grade = match health_score {
811        s if s >= 90.0 => "A",
812        s if s >= 80.0 => "B",
813        s if s >= 65.0 => "C",
814        s if s >= 50.0 => "D",
815        _ => "F",
816    };
817
818    match fmt {
819        OutputFormat::Json => {
820            let smell_items: Vec<Value> = summary
821                .iter()
822                .filter(|s| s.findings > 0)
823                .map(|s| json!({"rule": s.rule, "findings": s.findings}))
824                .collect();
825            let v = json!({
826                "tool": "ctx_architecture",
827                "action": "health",
828                "health_score": (health_score * 10.0).round() / 10.0,
829                "grade": grade,
830                "files": files,
831                "edges": edges,
832                "total_smells": total_smells,
833                "smell_density": (smell_density * 100.0).round() / 100.0,
834                "modularity": (communities.modularity * 1000.0).round() / 1000.0,
835                "avg_cohesion": (avg_cohesion * 100.0).round() / 100.0,
836                "communities": communities.communities.len(),
837                "cycles": cycles.len(),
838                "layers": layers.len(),
839                "smells_by_rule": smell_items
840            });
841            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
842        }
843        OutputFormat::Text => {
844            let mut result = format!(
845                "Architecture Health Report\n\n  Score:       {health_score:.0}/100 (Grade: {grade})\n  Files:       {files}\n  Edges:       {edges}\n"
846            );
847            result.push_str(&format!(
848                "  Communities: {} (modularity {:.3}, avg cohesion {:.0}%)\n",
849                communities.communities.len(),
850                communities.modularity,
851                avg_cohesion * 100.0
852            ));
853            result.push_str(&format!(
854                "  Cycles:      {}\n  Layers:      {}\n  Smells:      {} (density {:.2}/file)\n",
855                cycles.len(),
856                layers.len(),
857                total_smells,
858                smell_density
859            ));
860
861            if total_smells > 0 {
862                result.push_str("\n  Smell breakdown:\n");
863                for s in &summary {
864                    if s.findings > 0 {
865                        result.push_str(&format!("    {:<25} {:>3}\n", s.rule, s.findings));
866                    }
867                }
868            }
869
870            let tokens = count_tokens(&result);
871            format!("{result}\n[ctx_architecture health: {tokens} tok]")
872        }
873    }
874}
875
876fn compute_health_score(
877    smell_density: f64,
878    avg_cohesion: f64,
879    modularity: f64,
880    cycle_count: usize,
881    file_count: usize,
882) -> f64 {
883    let smell_penalty = (smell_density * 10.0).min(30.0);
884    let cohesion_bonus = avg_cohesion * 20.0;
885    let modularity_bonus = modularity.max(0.0) * 30.0;
886    let cycle_penalty = (cycle_count as f64 * 5.0).min(20.0);
887    let size_factor = if file_count > 1000 { 0.95 } else { 1.0 };
888
889    let raw =
890        (50.0 + cohesion_bonus + modularity_bonus - smell_penalty - cycle_penalty) * size_factor;
891    raw.clamp(0.0, 100.0)
892}
893
894fn handle_module(path: Option<&str>, root: &str, fmt: OutputFormat) -> String {
895    let Some(target) = path else {
896        return "path is required for 'module' action".to_string();
897    };
898
899    ensure_graph_built(root);
900    let graph = match open_graph(root) {
901        Ok(g) => g,
902        Err(e) => return e,
903    };
904
905    let data = match load_graph_data(&graph) {
906        Ok(d) => d,
907        Err(e) => return e,
908    };
909
910    let canon_root = crate::core::pathutil::safe_canonicalize(std::path::Path::new(root))
911        .map_or_else(|_| root.to_string(), |p| p.to_string_lossy().to_string());
912    let canon_target = crate::core::pathutil::safe_canonicalize(std::path::Path::new(target))
913        .map_or_else(|_| target.to_string(), |p| p.to_string_lossy().to_string());
914    let root_slash = if canon_root.ends_with('/') {
915        canon_root.clone()
916    } else {
917        format!("{canon_root}/")
918    };
919    let rel = canon_target
920        .strip_prefix(&root_slash)
921        .or_else(|| canon_target.strip_prefix(&canon_root))
922        .unwrap_or(&canon_target)
923        .trim_start_matches('/');
924
925    let prefix = if rel.contains('/') {
926        rel.rsplitn(2, '/').last().unwrap_or(rel)
927    } else {
928        rel
929    };
930
931    let mut module_files: Vec<String> = data
932        .all_files
933        .iter()
934        .filter(|f| f.starts_with(prefix))
935        .cloned()
936        .collect();
937    module_files.sort();
938
939    if module_files.is_empty() {
940        return format!("No files found in module path '{prefix}'");
941    }
942
943    let file_set: HashSet<&str> = module_files
944        .iter()
945        .map(std::string::String::as_str)
946        .collect();
947
948    let mut internal_edges = 0;
949    let mut external_imports: Vec<String> = Vec::new();
950    let mut external_dependents: Vec<String> = Vec::new();
951
952    for file in &module_files {
953        if let Some(deps) = data.forward.get(file) {
954            for dep in deps {
955                if file_set.contains(dep.as_str()) {
956                    internal_edges += 1;
957                } else {
958                    external_imports.push(format!("{file} -> {dep}"));
959                }
960            }
961        }
962        if let Some(revs) = data.reverse.get(file) {
963            for rev in revs {
964                if !file_set.contains(rev.as_str()) {
965                    external_dependents.push(format!("{rev} -> {file}"));
966                }
967            }
968        }
969    }
970
971    external_imports.sort();
972    external_imports.dedup();
973    external_dependents.sort();
974    external_dependents.dedup();
975
976    let files_total = module_files.len();
977    let file_limit = crate::core::budgets::ARCHITECTURE_MODULE_FILES_LIMIT.max(1);
978    let files_truncated = files_total > file_limit;
979
980    match fmt {
981        OutputFormat::Json => {
982            let root_path = Path::new(root);
983            let files: Vec<String> = module_files.iter().take(file_limit).cloned().collect();
984
985            let ext_limit = 50usize;
986            let ext_imports_total = external_imports.len();
987            let ext_dependents_total = external_dependents.len();
988            let imports_truncated = ext_imports_total > ext_limit;
989            let dependents_truncated = ext_dependents_total > ext_limit;
990            let imports: Vec<String> = external_imports.iter().take(ext_limit).cloned().collect();
991            let dependents: Vec<String> = external_dependents
992                .iter()
993                .take(ext_limit)
994                .cloned()
995                .collect();
996
997            let v = json!({
998                "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
999                "tool": "ctx_architecture",
1000                "action": "module",
1001                "project": project_meta(root),
1002                "graph": graph_summary(root_path),
1003                "graph_meta": crate::core::property_graph::load_meta(root),
1004                "module_prefix": prefix,
1005                "file_count": files_total,
1006                "internal_edges": internal_edges,
1007                "files": files,
1008                "files_truncated": files_truncated,
1009                "external_imports_total": ext_imports_total,
1010                "external_imports": imports,
1011                "external_imports_truncated": imports_truncated,
1012                "external_dependents_total": ext_dependents_total,
1013                "external_dependents": dependents,
1014                "external_dependents_truncated": dependents_truncated
1015            });
1016            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
1017        }
1018        OutputFormat::Text => {
1019            let mut result = format!(
1020                "Module '{prefix}' ({files_total} files, {internal_edges} internal edges)\n"
1021            );
1022
1023            result.push_str("\nFiles:\n");
1024            for f in module_files.iter().take(file_limit) {
1025                result.push_str(&format!("  {f}\n"));
1026            }
1027            if files_truncated {
1028                result.push_str(&format!("  ... +{} more\n", files_total - file_limit));
1029            }
1030
1031            if !external_imports.is_empty() {
1032                result.push_str(&format!(
1033                    "\nExternal imports ({}):\n",
1034                    external_imports.len()
1035                ));
1036                for imp in external_imports.iter().take(15) {
1037                    result.push_str(&format!("  {imp}\n"));
1038                }
1039                if external_imports.len() > 15 {
1040                    result.push_str(&format!("  ... +{} more\n", external_imports.len() - 15));
1041                }
1042            }
1043
1044            if !external_dependents.is_empty() {
1045                result.push_str(&format!(
1046                    "\nExternal dependents ({}):\n",
1047                    external_dependents.len()
1048                ));
1049                for dep in external_dependents.iter().take(15) {
1050                    result.push_str(&format!("  {dep}\n"));
1051                }
1052                if external_dependents.len() > 15 {
1053                    result.push_str(&format!("  ... +{} more\n", external_dependents.len() - 15));
1054                }
1055            }
1056
1057            let tokens = count_tokens(&result);
1058            format!("{result}[ctx_architecture module: {tokens} tok]")
1059        }
1060    }
1061}
1062
1063// ---------------------------------------------------------------------------
1064// Algorithms
1065// ---------------------------------------------------------------------------
1066
1067#[derive(Debug)]
1068struct Cluster {
1069    files: Vec<String>,
1070    internal_edges: usize,
1071}
1072
1073fn compute_clusters(data: &GraphData) -> Vec<Cluster> {
1074    let mut dir_groups: HashMap<String, Vec<String>> = HashMap::new();
1075    for file in &data.all_files {
1076        let dir = file.rsplitn(2, '/').last().unwrap_or("").to_string();
1077        dir_groups.entry(dir).or_default().push(file.clone());
1078    }
1079
1080    let mut clusters: Vec<Cluster> = Vec::new();
1081    for files in dir_groups.values() {
1082        if files.len() < 2 {
1083            continue;
1084        }
1085        let file_set: HashSet<&str> = files.iter().map(std::string::String::as_str).collect();
1086        let mut internal = 0;
1087        for file in files {
1088            if let Some(deps) = data.forward.get(file) {
1089                for dep in deps {
1090                    if file_set.contains(dep.as_str()) {
1091                        internal += 1;
1092                    }
1093                }
1094            }
1095        }
1096
1097        let mut sorted = files.clone();
1098        sorted.sort();
1099        clusters.push(Cluster {
1100            files: sorted,
1101            internal_edges: internal,
1102        });
1103    }
1104
1105    clusters.sort_by(|a, b| {
1106        b.files
1107            .len()
1108            .cmp(&a.files.len())
1109            .then_with(|| a.files[0].cmp(&b.files[0]))
1110    });
1111    clusters
1112}
1113
1114struct Layer {
1115    depth: usize,
1116    files: Vec<String>,
1117}
1118
1119fn compute_layers(data: &GraphData) -> Vec<Layer> {
1120    let leaf_files: HashSet<&String> = data
1121        .all_files
1122        .iter()
1123        .filter(|f| data.forward.get(*f).is_none_or(std::vec::Vec::is_empty))
1124        .collect();
1125
1126    let mut depth_map: HashMap<String, usize> = HashMap::new();
1127    let mut queue: VecDeque<(String, usize)> = VecDeque::new();
1128
1129    for leaf in &leaf_files {
1130        depth_map.insert((*leaf).clone(), 0);
1131        queue.push_back(((*leaf).clone(), 0));
1132    }
1133
1134    while let Some((file, depth)) = queue.pop_front() {
1135        if let Some(dependents) = data.reverse.get(&file) {
1136            for dep in dependents {
1137                let new_depth = depth + 1;
1138                let current = depth_map.get(dep).copied().unwrap_or(0);
1139                if new_depth > current {
1140                    depth_map.insert(dep.clone(), new_depth);
1141                    queue.push_back((dep.clone(), new_depth));
1142                }
1143            }
1144        }
1145    }
1146
1147    for file in &data.all_files {
1148        depth_map.entry(file.clone()).or_insert(0);
1149    }
1150
1151    let max_depth = depth_map.values().copied().max().unwrap_or(0);
1152    let mut layers: Vec<Layer> = Vec::new();
1153    for d in 0..=max_depth {
1154        let mut files: Vec<String> = depth_map
1155            .iter()
1156            .filter(|(_, &depth)| depth == d)
1157            .map(|(f, _)| f.clone())
1158            .collect();
1159        if !files.is_empty() {
1160            files.sort();
1161            layers.push(Layer { depth: d, files });
1162        }
1163    }
1164
1165    layers
1166}
1167
1168fn find_entrypoints(data: &GraphData) -> Vec<String> {
1169    let mut entrypoints: Vec<String> = data
1170        .all_files
1171        .iter()
1172        .filter(|f| !data.reverse.contains_key(*f))
1173        .cloned()
1174        .collect();
1175    entrypoints.sort();
1176    entrypoints
1177}
1178
1179fn find_cycles(data: &GraphData) -> Vec<Vec<String>> {
1180    let mut cycles: Vec<Vec<String>> = Vec::new();
1181    let mut visited: HashSet<String> = HashSet::new();
1182
1183    let mut starts: Vec<&String> = data.all_files.iter().collect();
1184    starts.sort();
1185    for start in starts {
1186        if visited.contains(start) {
1187            continue;
1188        }
1189
1190        let mut stack: Vec<String> = Vec::new();
1191        let mut on_stack: HashSet<String> = HashSet::new();
1192        dfs_cycles(
1193            start,
1194            &data.forward,
1195            &mut stack,
1196            &mut on_stack,
1197            &mut visited,
1198            &mut cycles,
1199        );
1200    }
1201
1202    cycles.sort_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b)));
1203    cycles.truncate(crate::core::budgets::ARCHITECTURE_CYCLES_LIMIT.max(1));
1204    cycles
1205}
1206
1207fn dfs_cycles(
1208    node: &str,
1209    graph: &HashMap<String, Vec<String>>,
1210    stack: &mut Vec<String>,
1211    on_stack: &mut HashSet<String>,
1212    visited: &mut HashSet<String>,
1213    cycles: &mut Vec<Vec<String>>,
1214) {
1215    if on_stack.contains(node) {
1216        let cycle_start = stack.iter().position(|n| n == node).unwrap_or(0);
1217        let mut cycle: Vec<String> = stack[cycle_start..].to_vec();
1218        cycle.push(node.to_string());
1219        cycles.push(cycle);
1220        return;
1221    }
1222
1223    if visited.contains(node) {
1224        return;
1225    }
1226
1227    on_stack.insert(node.to_string());
1228    stack.push(node.to_string());
1229
1230    if let Some(deps) = graph.get(node) {
1231        for dep in deps {
1232            dfs_cycles(dep, graph, stack, on_stack, visited, cycles);
1233        }
1234    }
1235
1236    stack.pop();
1237    on_stack.remove(node);
1238    visited.insert(node.to_string());
1239}
1240
1241fn common_prefix(files: &[String]) -> String {
1242    if files.is_empty() {
1243        return String::new();
1244    }
1245    if files.len() == 1 {
1246        return files[0]
1247            .rsplitn(2, '/')
1248            .last()
1249            .unwrap_or(&files[0])
1250            .to_string();
1251    }
1252
1253    let parts: Vec<Vec<&str>> = files.iter().map(|f| f.split('/').collect()).collect();
1254    let min_len = parts.iter().map(std::vec::Vec::len).min().unwrap_or(0);
1255
1256    let mut common = Vec::new();
1257    for i in 0..min_len {
1258        let segment = parts[0][i];
1259        if parts.iter().all(|p| p[i] == segment) {
1260            common.push(segment);
1261        } else {
1262            break;
1263        }
1264    }
1265
1266    if common.is_empty() {
1267        "(root)".to_string()
1268    } else {
1269        common.join("/")
1270    }
1271}
1272
1273fn project_meta(root: &str) -> Value {
1274    let root_hash = crate::core::project_hash::hash_project_root(root);
1275    let identity_hash = crate::core::project_hash::project_identity(root)
1276        .as_deref()
1277        .map(crate::core::hasher::hash_str);
1278
1279    let root_path = Path::new(root);
1280    json!({
1281        "project_root_hash": root_hash,
1282        "project_identity_hash": identity_hash,
1283        "git": {
1284            "head": git_out(root_path, &["rev-parse", "--short", "HEAD"]),
1285            "branch": git_out(root_path, &["rev-parse", "--abbrev-ref", "HEAD"]),
1286            "dirty": git_dirty(root_path)
1287        }
1288    })
1289}
1290
1291fn graph_summary(project_root: &Path) -> Value {
1292    let root_str = project_root.to_string_lossy();
1293    let graph_dir = crate::core::property_graph::graph_dir(&root_str);
1294    let db_path = graph_dir.join("graph.db");
1295    let db_path_display = db_path.display().to_string();
1296    if !db_path.exists() {
1297        return json!({
1298            "exists": false,
1299            "db_path": db_path_display,
1300            "nodes": null,
1301            "edges": null
1302        });
1303    }
1304    match CodeGraph::open(&root_str) {
1305        Ok(g) => json!({
1306            "exists": true,
1307            "db_path": g.db_path().display().to_string(),
1308            "nodes": g.node_count().ok(),
1309            "edges": g.edge_count().ok()
1310        }),
1311        Err(_) => json!({
1312            "exists": true,
1313            "db_path": db_path_display,
1314            "nodes": null,
1315            "edges": null
1316        }),
1317    }
1318}
1319
1320fn git_dirty(project_root: &Path) -> bool {
1321    let out = std::process::Command::new("git")
1322        .args(["status", "--porcelain"])
1323        .current_dir(project_root)
1324        .stdout(std::process::Stdio::piped())
1325        .stderr(std::process::Stdio::null())
1326        .output();
1327    match out {
1328        Ok(o) if o.status.success() => !o.stdout.is_empty(),
1329        _ => false,
1330    }
1331}
1332
1333fn git_out(project_root: &Path, args: &[&str]) -> Option<String> {
1334    let out = std::process::Command::new("git")
1335        .args(args)
1336        .current_dir(project_root)
1337        .stdout(std::process::Stdio::piped())
1338        .stderr(std::process::Stdio::null())
1339        .output()
1340        .ok()?;
1341    if !out.status.success() {
1342        return None;
1343    }
1344    let s = String::from_utf8(out.stdout).ok()?;
1345    let s = s.trim().to_string();
1346    if s.is_empty() {
1347        None
1348    } else {
1349        Some(s)
1350    }
1351}
1352
1353#[cfg(test)]
1354mod tests {
1355    use super::*;
1356
1357    #[test]
1358    fn common_prefix_single() {
1359        let files = vec!["src/core/cache.rs".to_string()];
1360        assert_eq!(common_prefix(&files), "src/core");
1361    }
1362
1363    #[test]
1364    fn common_prefix_multiple() {
1365        let files = vec![
1366            "src/core/cache.rs".to_string(),
1367            "src/core/config.rs".to_string(),
1368            "src/core/session.rs".to_string(),
1369        ];
1370        assert_eq!(common_prefix(&files), "src/core");
1371    }
1372
1373    #[test]
1374    fn common_prefix_different_dirs() {
1375        let files = vec![
1376            "src/tools/ctx_read.rs".to_string(),
1377            "src/core/cache.rs".to_string(),
1378        ];
1379        assert_eq!(common_prefix(&files), "src");
1380    }
1381
1382    #[test]
1383    fn entrypoints_no_dependents() {
1384        let mut forward: HashMap<String, Vec<String>> = HashMap::new();
1385        forward.insert("main.rs".to_string(), vec!["lib.rs".to_string()]);
1386
1387        let all_files: HashSet<String> = ["main.rs", "lib.rs"]
1388            .iter()
1389            .map(std::string::ToString::to_string)
1390            .collect();
1391
1392        let data = GraphData {
1393            forward,
1394            reverse: {
1395                let mut r = HashMap::new();
1396                r.insert("lib.rs".to_string(), vec!["main.rs".to_string()]);
1397                r
1398            },
1399            all_files,
1400        };
1401
1402        let eps = find_entrypoints(&data);
1403        assert_eq!(eps, vec!["main.rs"]);
1404    }
1405
1406    #[test]
1407    fn layers_simple_chain() {
1408        let mut forward: HashMap<String, Vec<String>> = HashMap::new();
1409        forward.insert("a.rs".to_string(), vec!["b.rs".to_string()]);
1410        forward.insert("b.rs".to_string(), vec!["c.rs".to_string()]);
1411
1412        let mut reverse: HashMap<String, Vec<String>> = HashMap::new();
1413        reverse.insert("b.rs".to_string(), vec!["a.rs".to_string()]);
1414        reverse.insert("c.rs".to_string(), vec!["b.rs".to_string()]);
1415
1416        let all_files: HashSet<String> = ["a.rs", "b.rs", "c.rs"]
1417            .iter()
1418            .map(std::string::ToString::to_string)
1419            .collect();
1420
1421        let data = GraphData {
1422            forward,
1423            reverse,
1424            all_files,
1425        };
1426
1427        let layers = compute_layers(&data);
1428        assert!(layers.len() >= 2);
1429
1430        let layer0 = layers.iter().find(|l| l.depth == 0).unwrap();
1431        assert!(layer0.files.contains(&"c.rs".to_string()));
1432
1433        let layer2 = layers.iter().find(|l| l.depth == 2).unwrap();
1434        assert!(layer2.files.contains(&"a.rs".to_string()));
1435    }
1436
1437    #[test]
1438    fn cycles_detection() {
1439        let mut forward: HashMap<String, Vec<String>> = HashMap::new();
1440        forward.insert("a.rs".to_string(), vec!["b.rs".to_string()]);
1441        forward.insert("b.rs".to_string(), vec!["a.rs".to_string()]);
1442
1443        let all_files: HashSet<String> = ["a.rs", "b.rs"]
1444            .iter()
1445            .map(std::string::ToString::to_string)
1446            .collect();
1447
1448        let data = GraphData {
1449            forward,
1450            reverse: HashMap::new(),
1451            all_files,
1452        };
1453
1454        let cycles = find_cycles(&data);
1455        assert!(!cycles.is_empty());
1456    }
1457
1458    #[test]
1459    fn handle_unknown() {
1460        let result = handle("invalid", None, "/tmp", None);
1461        assert!(result.contains("Unknown action"));
1462    }
1463}