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#[derive(Parser)]
21#[command(name = "cargo", bin_name = "cargo")]
22pub enum Cargo {
23 #[command(name = "arc", version, author)]
25 Arc(Args),
26}
27
28#[allow(clippy::struct_excessive_bools)] #[derive(Parser)]
30pub struct Args {
31 #[arg(short, long)]
33 pub output: Option<PathBuf>,
34
35 #[arg(short, long, default_value = "Cargo.toml")]
37 pub manifest_path: PathBuf,
38
39 #[arg(long, value_delimiter = ',')]
41 pub features: Vec<String>,
42
43 #[arg(long)]
45 pub all_features: bool,
46
47 #[arg(long)]
49 pub no_default_features: bool,
50
51 #[arg(long)]
53 pub include_tests: bool,
54
55 #[arg(long)]
57 pub check: bool,
58
59 #[arg(long)]
61 pub debug: bool,
62
63 #[arg(long)]
65 pub volatility: bool,
66
67 #[arg(long)]
69 pub no_volatility: bool,
70
71 #[arg(long, default_value = "6")]
73 pub volatility_months: usize,
74
75 #[arg(long, default_value = "2")]
77 pub volatility_low: usize,
78
79 #[arg(long, default_value = "10")]
81 pub volatility_high: usize,
82
83 #[arg(long)]
85 pub externals: bool,
86
87 #[arg(long)]
89 pub transitive_deps: bool,
90
91 #[arg(long)]
93 pub expand_level: Option<usize>,
94
95 #[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 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
314fn 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 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 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] 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}