Skip to main content

cargo_arc/
cli.rs

1use anyhow::Result;
2use clap::Parser;
3use std::fs;
4use std::io::{self, Write};
5use std::path::PathBuf;
6use tracing_subscriber::EnvFilter;
7
8use crate::analyze::{
9    AnalysisBackend, FeatureConfig, ReExportMap, analyze_workspace, collect_crate_exports,
10    collect_crate_reexports, externals::analyze_externals, normalize_crate_name,
11};
12use crate::graph::ArcGraph;
13use crate::layout::{Cycle, ElementaryCycles, LayoutIR, build_layout};
14use crate::model::{CrateExportMap, ModulePathMap, WorkspaceCrates};
15use crate::render::{RenderConfig, render};
16use crate::volatility::{VolatilityAnalyzer, VolatilityConfig};
17use std::path::Path;
18
19/// Cargo subcommand wrapper for `cargo arc`
20#[derive(Parser)]
21#[command(name = "cargo", bin_name = "cargo")]
22pub enum Cargo {
23    /// Visualize workspace dependencies as SVG
24    #[command(name = "arc", version, author)]
25    Arc(Args),
26}
27
28#[allow(clippy::struct_excessive_bools)] // CLI flags map 1:1 to fields
29#[derive(Parser)]
30pub struct Args {
31    /// Output file (default: stdout)
32    #[arg(short, long)]
33    pub output: Option<PathBuf>,
34
35    /// Path to Cargo.toml (default: ./Cargo.toml)
36    #[arg(short, long, default_value = "Cargo.toml")]
37    pub manifest_path: PathBuf,
38
39    /// Comma-separated list of features to activate
40    #[arg(long, value_delimiter = ',')]
41    pub features: Vec<String>,
42
43    /// Activate all available features
44    #[arg(long)]
45    pub all_features: bool,
46
47    /// Do not activate the `default` feature
48    #[arg(long)]
49    pub no_default_features: bool,
50
51    /// Include test code in analysis (unit tests, integration tests)
52    #[arg(long)]
53    pub include_tests: bool,
54
55    /// Validate dependency graph (exit 1 if cycles found)
56    #[arg(long)]
57    pub check: bool,
58
59    /// Enable debug output to stderr (shows filtering decisions)
60    #[arg(long)]
61    pub debug: bool,
62
63    /// Print volatility report (text) instead of dependency SVG
64    #[arg(long)]
65    pub volatility: bool,
66
67    /// Disable git volatility analysis in SVG output
68    #[arg(long)]
69    pub no_volatility: bool,
70
71    /// Volatility analysis period in months (default: 6)
72    #[arg(long, default_value = "6")]
73    pub volatility_months: usize,
74
75    /// Low volatility threshold (default: 2)
76    #[arg(long, default_value = "2")]
77    pub volatility_low: usize,
78
79    /// High volatility threshold (default: 10)
80    #[arg(long, default_value = "10")]
81    pub volatility_high: usize,
82
83    /// Include external crate dependencies in visualization
84    #[arg(long)]
85    pub externals: bool,
86
87    /// Include transitive external dependencies (requires --externals)
88    #[arg(long)]
89    pub transitive_deps: bool,
90
91    /// Initial expand level for SVG (0=crates only, 1=direct modules, etc.)
92    #[arg(long)]
93    pub expand_level: Option<usize>,
94
95    /// Use rust-analyzer HIR backend instead of syn (slower but may catch more)
96    #[cfg(feature = "hir")]
97    #[arg(long)]
98    pub hir: bool,
99}
100
101#[allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
102pub fn run(args: Args) -> Result<()> {
103    if args.debug {
104        tracing_subscriber::fmt()
105            .with_env_filter(
106                EnvFilter::from_default_env().add_directive("cargo_arc=debug".parse().unwrap()),
107            )
108            .with_target(false)
109            .with_writer(std::io::stderr)
110            .init();
111    }
112
113    if args.check {
114        let feature_config = FeatureConfig {
115            features: args.features,
116            all_features: args.all_features,
117            no_default_features: args.no_default_features,
118            include_tests: args.include_tests,
119            debug: args.debug,
120        };
121
122        #[cfg(feature = "hir")]
123        let use_hir = args.hir;
124        #[cfg(not(feature = "hir"))]
125        let use_hir = false;
126
127        let graph = build_dependency_graph(
128            &args.manifest_path,
129            &feature_config,
130            use_hir,
131            args.externals,
132            args.transitive_deps,
133        )?;
134        tracing::debug!("phase: cycle detection start (--check)");
135        let cycles = graph.production_subgraph().elementary_cycles();
136        tracing::debug!("phase: cycle detection done ({} cycles)", cycles.len());
137        if cycles.is_empty() {
138            return Ok(());
139        }
140        eprint!("{}", format_cycle_errors(&graph, &cycles));
141        anyhow::bail!("dependency cycle(s) detected");
142    }
143
144    let vol_config = VolatilityConfig {
145        months: args.volatility_months,
146        low_threshold: args.volatility_low,
147        high_threshold: args.volatility_high,
148    };
149
150    if args.volatility {
151        return run_volatility_report(&args.manifest_path, vol_config, args.output.as_ref());
152    }
153
154    let feature_config = FeatureConfig {
155        features: args.features,
156        all_features: args.all_features,
157        no_default_features: args.no_default_features,
158        include_tests: args.include_tests,
159        debug: args.debug,
160    };
161
162    #[cfg(feature = "hir")]
163    let use_hir = args.hir;
164    #[cfg(not(feature = "hir"))]
165    let use_hir = false;
166
167    let graph = build_dependency_graph(
168        &args.manifest_path,
169        &feature_config,
170        use_hir,
171        args.externals,
172        args.transitive_deps,
173    )?;
174    tracing::debug!("phase: cycle detection start");
175    let cycles = graph.production_subgraph().elementary_cycles();
176    tracing::debug!("phase: cycle detection done ({} cycles)", cycles.len());
177    let mut layout = build_layout(&graph, &cycles);
178    tracing::debug!("phase: layout built ({} items)", layout.items.len());
179
180    if !args.no_volatility {
181        enrich_volatility(&mut layout, &args.manifest_path, vol_config);
182    }
183
184    let config = RenderConfig {
185        expand_level: args.expand_level,
186        ..RenderConfig::default()
187    };
188    let svg = render(&layout, &config);
189    tracing::debug!("phase: render done ({} bytes)", svg.len());
190    write_output(&svg, args.output.as_ref())
191}
192
193fn resolve_repo_path(manifest_path: &Path) -> &Path {
194    manifest_path
195        .parent()
196        .filter(|parent| !parent.as_os_str().is_empty())
197        .unwrap_or(Path::new("."))
198}
199
200fn write_output(content: &str, output: Option<&PathBuf>) -> Result<()> {
201    match output {
202        Some(path) => fs::write(path, content)?,
203        None => io::stdout().write_all(content.as_bytes())?,
204    }
205    Ok(())
206}
207
208fn run_volatility_report(
209    manifest_path: &Path,
210    vol_config: VolatilityConfig,
211    output: Option<&PathBuf>,
212) -> Result<()> {
213    let repo_path = resolve_repo_path(manifest_path);
214    let mut analyzer = VolatilityAnalyzer::new(vol_config);
215    analyzer.analyze(repo_path)?;
216    let report = analyzer.format_report();
217    write_output(&report, output)
218}
219
220fn build_dependency_graph(
221    manifest_path: &Path,
222    feature_config: &FeatureConfig,
223    use_hir: bool,
224    externals: bool,
225    transitive_deps: bool,
226) -> Result<ArcGraph> {
227    let crates = analyze_workspace(manifest_path, feature_config)?;
228    tracing::debug!("phase: workspace analyzed ({} crates)", crates.len());
229    let workspace_crates: WorkspaceCrates = crates.iter().map(|krate| krate.name.clone()).collect();
230    let backend = AnalysisBackend::new(manifest_path, feature_config, use_hir)?;
231
232    let all_module_paths: ModulePathMap = crates
233        .iter()
234        .map(|krate| {
235            let name = normalize_crate_name(&krate.name);
236            let paths = backend.collect_module_paths(krate);
237            (name, paths)
238        })
239        .collect();
240    tracing::debug!("phase: module paths collected");
241
242    let crate_exports: CrateExportMap = crates
243        .iter()
244        .map(|krate| {
245            let name = normalize_crate_name(&krate.name);
246            let exports = collect_crate_exports(&krate.path);
247            (name, exports)
248        })
249        .collect();
250    tracing::debug!("phase: crate exports collected");
251
252    let reexport_map: ReExportMap = crates
253        .iter()
254        .map(|krate| {
255            let name = normalize_crate_name(&krate.name);
256            let exports = collect_crate_reexports(
257                krate,
258                &all_module_paths,
259                &workspace_crates,
260                &crate_exports,
261            );
262            (name, exports)
263        })
264        .collect();
265    tracing::debug!("phase: reexport map collected");
266
267    // Run externals analysis before module analysis so crate_name_map
268    // is available for use-parser resolution of external crate imports.
269    let ext_result = if externals {
270        use cargo_metadata::MetadataCommand;
271        let metadata = MetadataCommand::new().manifest_path(manifest_path).exec()?;
272        Some(analyze_externals(&metadata, transitive_deps))
273    } else {
274        None
275    };
276
277    let empty_name_map = std::collections::HashMap::new();
278    let modules: Vec<_> = crates
279        .iter()
280        .filter_map(|krate| {
281            let name = normalize_crate_name(&krate.name);
282            tracing::debug!("analyzing crate: {name}");
283            let ext_names = ext_result
284                .as_ref()
285                .and_then(|r| r.crate_name_map.get(&name))
286                .unwrap_or(&empty_name_map);
287            match backend.analyze_modules(
288                krate,
289                &workspace_crates,
290                &all_module_paths,
291                &crate_exports,
292                &reexport_map,
293                ext_names,
294            ) {
295                Ok(tree) => Some(tree),
296                Err(err) => {
297                    tracing::warn!("Skipping crate {}: {err}", krate.name);
298                    None
299                }
300            }
301        })
302        .collect();
303    tracing::debug!("phase: all crates analyzed");
304
305    let graph = ArcGraph::build(&crates, &modules, ext_result.as_ref());
306    tracing::debug!(
307        "phase: graph built ({} nodes, {} edges)",
308        graph.node_count(),
309        graph.edge_count()
310    );
311    Ok(graph)
312}
313
314/// Format detected cycles as compiler-style error messages.
315///
316/// Returns an empty string when `cycles` is empty. Otherwise produces one
317/// `error[cycle]:` line per cycle (using `<->` for direct / `->` chains for
318/// transitive) followed by a summary line.
319fn format_cycle_errors(graph: &ArcGraph, cycles: &[Cycle]) -> String {
320    use std::fmt::Write;
321
322    if cycles.is_empty() {
323        return String::new();
324    }
325
326    let mut output = String::new();
327    for cycle in cycles {
328        let names: Vec<&str> = cycle.path.iter().map(|&idx| graph[idx].name()).collect();
329        if names.len() == 2 {
330            let _ = writeln!(output, "error[cycle]: {} <-> {}", names[0], names[1]);
331        } else {
332            let _ = writeln!(
333                output,
334                "error[cycle]: {} -> {}",
335                names.join(" -> "),
336                names[0]
337            );
338        }
339    }
340    let _ = write!(
341        output,
342        "\nerror: found {} cycle(s) in dependency graph\n",
343        cycles.len()
344    );
345    output
346}
347
348fn enrich_volatility(layout: &mut LayoutIR, manifest_path: &Path, vol_config: VolatilityConfig) {
349    let repo_path = resolve_repo_path(manifest_path);
350    let mut analyzer = VolatilityAnalyzer::new(vol_config);
351    match analyzer.analyze(repo_path) {
352        Ok(()) => {
353            for item in &mut layout.items {
354                if let Some(ref path) = item.source_path {
355                    let vol = analyzer.get_volatility(path);
356                    let count = analyzer.get_change_count(path);
357                    item.volatility = Some((vol, count));
358                }
359            }
360        }
361        Err(err) => {
362            tracing::warn!("Volatility analysis skipped: {err}");
363        }
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    /// Helper to parse Args via Cargo wrapper
372    fn parse_args(args: &[&str]) -> Args {
373        let Cargo::Arc(args) = Cargo::parse_from(args);
374        args
375    }
376
377    use crate::graph::Node;
378    use crate::layout::Cycle;
379    use petgraph::graph::NodeIndex;
380
381    /// Build a test graph with named module nodes.
382    fn test_graph(names: &[&str]) -> (ArcGraph, Vec<NodeIndex>) {
383        let mut graph = ArcGraph::new();
384        let crate_idx = graph.add_node(Node::Crate {
385            name: "test".into(),
386            path: "/test".into(),
387        });
388        let indices: Vec<_> = names
389            .iter()
390            .map(|name| {
391                graph.add_node(Node::Module {
392                    name: (*name).into(),
393                    crate_idx,
394                })
395            })
396            .collect();
397        (graph, indices)
398    }
399
400    #[test]
401    fn test_parse_check_flag() {
402        let args = parse_args(&["cargo", "arc", "--check"]);
403        assert!(args.check);
404    }
405
406    #[test]
407    fn test_parse_check_flag_default() {
408        let args = parse_args(&["cargo", "arc"]);
409        assert!(!args.check);
410    }
411
412    #[test]
413    fn test_format_cycle_errors_transitive() {
414        let (graph, idx) = test_graph(&["A", "B", "C"]);
415        let cycles = vec![Cycle {
416            path: vec![idx[0], idx[1], idx[2]],
417        }];
418        let output = format_cycle_errors(&graph, &cycles);
419        assert!(output.contains("error[cycle]: A -> B -> C -> A"));
420    }
421
422    #[test]
423    fn test_format_cycle_errors_direct() {
424        let (graph, idx) = test_graph(&["A", "B"]);
425        let cycles = vec![Cycle {
426            path: vec![idx[0], idx[1]],
427        }];
428        let output = format_cycle_errors(&graph, &cycles);
429        assert!(output.contains("error[cycle]: A <-> B"));
430    }
431
432    #[test]
433    fn test_format_cycle_errors_empty() {
434        let (graph, _) = test_graph(&["A", "B"]);
435        let output = format_cycle_errors(&graph, &[]);
436        assert!(output.is_empty());
437    }
438
439    #[test]
440    fn test_format_cycle_errors_summary() {
441        let (graph, idx) = test_graph(&["A", "B", "C", "D"]);
442        let cycles = vec![
443            Cycle {
444                path: vec![idx[0], idx[1]],
445            },
446            Cycle {
447                path: vec![idx[2], idx[3]],
448            },
449        ];
450        let output = format_cycle_errors(&graph, &cycles);
451        assert!(output.contains("error: found 2 cycle(s) in dependency graph"));
452    }
453
454    #[test]
455    fn test_cli_default_args() {
456        let args = parse_args(&["cargo", "arc"]);
457        assert!(args.output.is_none());
458        assert_eq!(args.manifest_path, PathBuf::from("Cargo.toml"));
459    }
460
461    #[test]
462    fn test_cli_features_parsing() {
463        let args = parse_args(&["cargo", "arc", "--features", "web,server"]);
464        assert_eq!(args.features, vec!["web", "server"]);
465    }
466
467    #[test]
468    fn test_cli_all_features() {
469        let args = parse_args(&["cargo", "arc", "--all-features"]);
470        assert!(args.all_features);
471    }
472
473    #[test]
474    fn test_cli_include_tests_flag() {
475        let args = parse_args(&["cargo", "arc", "--include-tests"]);
476        assert!(args.include_tests);
477    }
478
479    #[test]
480    fn test_cli_no_default_features_flag() {
481        let args = parse_args(&["cargo", "arc", "--no-default-features"]);
482        assert!(args.no_default_features);
483    }
484
485    #[test]
486    fn test_cli_volatility_flag() {
487        let args = parse_args(&["cargo", "arc", "--volatility"]);
488        assert!(args.volatility);
489    }
490
491    #[test]
492    fn test_cli_no_volatility_flag() {
493        let args = parse_args(&["cargo", "arc", "--no-volatility"]);
494        assert!(args.no_volatility);
495    }
496
497    #[test]
498    fn test_cli_volatility_months() {
499        let args = parse_args(&["cargo", "arc", "--volatility-months", "3"]);
500        assert_eq!(args.volatility_months, 3);
501    }
502
503    #[test]
504    fn test_cli_volatility_thresholds() {
505        let args = parse_args(&[
506            "cargo",
507            "arc",
508            "--volatility-low",
509            "5",
510            "--volatility-high",
511            "20",
512        ]);
513        assert_eq!(args.volatility_low, 5);
514        assert_eq!(args.volatility_high, 20);
515    }
516
517    #[test]
518    fn test_parse_externals_flag() {
519        let args = parse_args(&["cargo", "arc", "--externals"]);
520        assert!(args.externals);
521    }
522
523    #[test]
524    fn test_parse_externals_flag_default() {
525        let args = parse_args(&["cargo", "arc"]);
526        assert!(!args.externals);
527    }
528
529    #[test]
530    fn test_parse_transitive_deps_flag() {
531        let args = parse_args(&["cargo", "arc", "--externals", "--transitive-deps"]);
532        assert!(args.externals);
533        assert!(args.transitive_deps);
534    }
535
536    #[test]
537    fn test_parse_transitive_deps_flag_default() {
538        let args = parse_args(&["cargo", "arc"]);
539        assert!(!args.transitive_deps);
540    }
541
542    #[test]
543    fn test_parse_expand_level() {
544        let args = parse_args(&["cargo", "arc", "--expand-level", "0"]);
545        assert_eq!(args.expand_level, Some(0));
546    }
547
548    #[test]
549    fn test_parse_expand_level_two() {
550        let args = parse_args(&["cargo", "arc", "--expand-level", "2"]);
551        assert_eq!(args.expand_level, Some(2));
552    }
553
554    #[test]
555    fn test_parse_expand_level_default() {
556        let args = parse_args(&["cargo", "arc"]);
557        assert!(args.expand_level.is_none());
558    }
559
560    #[test]
561    fn test_cli_volatility_config_defaults() {
562        let args = parse_args(&["cargo", "arc"]);
563        assert!(!args.no_volatility);
564        assert_eq!(args.volatility_months, 6);
565        assert_eq!(args.volatility_low, 2);
566        assert_eq!(args.volatility_high, 10);
567    }
568
569    #[test]
570    #[ignore] // Smoke test - requires rust-analyzer (~30s)
571    fn test_run_with_output_file() {
572        let temp = tempfile::NamedTempFile::new().unwrap();
573        let args = Args {
574            output: Some(temp.path().to_path_buf()),
575            manifest_path: PathBuf::from("Cargo.toml"),
576            features: vec![],
577            all_features: false,
578            no_default_features: false,
579            include_tests: false,
580            check: false,
581            debug: false,
582            volatility: false,
583            no_volatility: false,
584            volatility_months: 6,
585            volatility_low: 2,
586            volatility_high: 10,
587            externals: false,
588            transitive_deps: false,
589            expand_level: None,
590            #[cfg(feature = "hir")]
591            hir: false,
592        };
593        let result = run(args);
594        assert!(result.is_ok());
595        let content = std::fs::read_to_string(temp.path()).unwrap();
596        assert!(content.contains("<svg"));
597    }
598}