1use std::io::Write as _;
7use std::path::{Path, PathBuf};
8use std::time::{Instant, SystemTime};
9
10use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
11use clap_complete::Shell;
12
13use crate::cli::output::{format_size, progress, progress_done, Styled};
14use crate::engine::query::{
15 CallDirection, CallGraphParams, CouplingParams, DeadCodeParams, DependencyParams,
16 HotspotParams, ImpactParams, MatchMode, ProphecyParams, QueryEngine, SimilarityParams,
17 StabilityResult, SymbolLookupParams, TestGapParams,
18};
19use crate::format::{AcbReader, AcbWriter};
20use crate::graph::CodeGraph;
21use crate::parse::parser::{ParseOptions, Parser as AcbParser};
22use crate::semantic::analyzer::{AnalyzeOptions, SemanticAnalyzer};
23use crate::types::FileHeader;
24
25const DEFAULT_STORAGE_BUDGET_BYTES: u64 = 2 * 1024 * 1024 * 1024;
27const DEFAULT_STORAGE_BUDGET_HORIZON_YEARS: u32 = 20;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31enum StorageBudgetMode {
32 AutoRollup,
33 Warn,
34 Off,
35}
36
37impl StorageBudgetMode {
38 fn from_env(name: &str) -> Self {
39 let raw = read_env_string(name).unwrap_or_else(|| "auto-rollup".to_string());
40 match raw.trim().to_ascii_lowercase().as_str() {
41 "warn" => Self::Warn,
42 "off" | "disabled" | "none" => Self::Off,
43 _ => Self::AutoRollup,
44 }
45 }
46
47 fn as_str(self) -> &'static str {
48 match self {
49 Self::AutoRollup => "auto-rollup",
50 Self::Warn => "warn",
51 Self::Off => "off",
52 }
53 }
54}
55
56#[derive(Parser)]
62#[command(
63 name = "acb",
64 about = "AgenticCodebase \u{2014} Semantic code compiler for AI agents",
65 long_about = "AgenticCodebase compiles multi-language codebases into navigable concept \
66 graphs that AI agents can query. Supports Python, Rust, TypeScript, and Go.\n\n\
67 Quick start:\n\
68 \x20 acb compile ./my-project # build a graph\n\
69 \x20 acb info my-project.acb # inspect the graph\n\
70 \x20 acb query my-project.acb symbol --name UserService\n\
71 \x20 acb query my-project.acb impact --unit-id 42\n\n\
72 For AI agent integration, use the companion MCP server: acb-mcp",
73 after_help = "Run 'acb <command> --help' for details on a specific command.\n\
74 Set ACB_LOG=debug for verbose tracing. Set NO_COLOR=1 to disable colors.",
75 version
76)]
77pub struct Cli {
78 #[command(subcommand)]
79 pub command: Option<Command>,
80
81 #[arg(long, short = 'f', default_value = "text", global = true)]
83 pub format: OutputFormat,
84
85 #[arg(long, short = 'v', global = true)]
87 pub verbose: bool,
88
89 #[arg(long, short = 'q', global = true)]
91 pub quiet: bool,
92}
93
94#[derive(Clone, ValueEnum)]
96pub enum OutputFormat {
97 Text,
99 Json,
101}
102
103#[derive(Subcommand)]
105pub enum Command {
106 #[command(alias = "build")]
117 Compile {
118 path: PathBuf,
120
121 #[arg(short, long)]
123 output: Option<PathBuf>,
124
125 #[arg(long, short = 'e')]
127 exclude: Vec<String>,
128
129 #[arg(long, default_value_t = true)]
131 include_tests: bool,
132
133 #[arg(long)]
135 coverage_report: Option<PathBuf>,
136 },
137
138 #[command(alias = "stat")]
147 Info {
148 file: PathBuf,
150 },
151
152 #[command(alias = "q")]
174 Query {
175 file: PathBuf,
177
178 query_type: String,
181
182 #[arg(long, short = 'n')]
184 name: Option<String>,
185
186 #[arg(long, short = 'u')]
188 unit_id: Option<u64>,
189
190 #[arg(long, short = 'd', default_value_t = 3)]
192 depth: u32,
193
194 #[arg(long, short = 'l', default_value_t = 20)]
196 limit: usize,
197 },
198
199 Get {
208 file: PathBuf,
210
211 unit_id: u64,
213 },
214
215 Completions {
225 shell: Shell,
227 },
228
229 Health {
231 file: PathBuf,
233
234 #[arg(long, short = 'l', default_value_t = 10)]
236 limit: usize,
237 },
238
239 Gate {
241 file: PathBuf,
243
244 #[arg(long, short = 'u')]
246 unit_id: u64,
247
248 #[arg(long, default_value_t = 0.60)]
250 max_risk: f32,
251
252 #[arg(long, short = 'd', default_value_t = 3)]
254 depth: u32,
255
256 #[arg(long, default_value_t = true)]
258 require_tests: bool,
259 },
260
261 Budget {
263 file: PathBuf,
265
266 #[arg(long, default_value_t = DEFAULT_STORAGE_BUDGET_BYTES)]
268 max_bytes: u64,
269
270 #[arg(long, default_value_t = DEFAULT_STORAGE_BUDGET_HORIZON_YEARS)]
272 horizon_years: u32,
273 },
274}
275
276pub fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
284 let command_name = match &cli.command {
285 None => "repl",
286 Some(Command::Compile { .. }) => "compile",
287 Some(Command::Info { .. }) => "info",
288 Some(Command::Query { .. }) => "query",
289 Some(Command::Get { .. }) => "get",
290 Some(Command::Completions { .. }) => "completions",
291 Some(Command::Health { .. }) => "health",
292 Some(Command::Gate { .. }) => "gate",
293 Some(Command::Budget { .. }) => "budget",
294 };
295 let started = Instant::now();
296 let result = match &cli.command {
297 None => crate::cli::repl::run(),
299
300 Some(Command::Compile {
301 path,
302 output,
303 exclude,
304 include_tests,
305 coverage_report,
306 }) => cmd_compile(
307 path,
308 output.as_deref(),
309 exclude,
310 *include_tests,
311 coverage_report.as_deref(),
312 &cli,
313 ),
314 Some(Command::Info { file }) => cmd_info(file, &cli),
315 Some(Command::Query {
316 file,
317 query_type,
318 name,
319 unit_id,
320 depth,
321 limit,
322 }) => cmd_query(
323 file,
324 query_type,
325 name.as_deref(),
326 *unit_id,
327 *depth,
328 *limit,
329 &cli,
330 ),
331 Some(Command::Get { file, unit_id }) => cmd_get(file, *unit_id, &cli),
332 Some(Command::Completions { shell }) => {
333 let mut cmd = Cli::command();
334 clap_complete::generate(*shell, &mut cmd, "acb", &mut std::io::stdout());
335 Ok(())
336 }
337 Some(Command::Health { file, limit }) => cmd_health(file, *limit, &cli),
338 Some(Command::Gate {
339 file,
340 unit_id,
341 max_risk,
342 depth,
343 require_tests,
344 }) => cmd_gate(file, *unit_id, *max_risk, *depth, *require_tests, &cli),
345 Some(Command::Budget {
346 file,
347 max_bytes,
348 horizon_years,
349 }) => cmd_budget(file, *max_bytes, *horizon_years, &cli),
350 };
351
352 emit_cli_health_ledger(command_name, started.elapsed(), result.is_ok());
353 result
354}
355
356fn emit_cli_health_ledger(command: &str, duration: std::time::Duration, ok: bool) {
361 let dir = resolve_health_ledger_dir();
362 if std::fs::create_dir_all(&dir).is_err() {
363 return;
364 }
365 let path = dir.join("agentic-codebase-cli.json");
366 let tmp = dir.join("agentic-codebase-cli.json.tmp");
367 let profile = read_env_string("ACB_AUTONOMIC_PROFILE").unwrap_or_else(|| "desktop".to_string());
368 let payload = serde_json::json!({
369 "project": "AgenticCodebase",
370 "surface": "cli",
371 "timestamp": chrono::Utc::now().to_rfc3339(),
372 "status": if ok { "ok" } else { "error" },
373 "autonomic": {
374 "profile": profile.to_ascii_lowercase(),
375 "command": command,
376 "duration_ms": duration.as_millis(),
377 }
378 });
379 let Ok(bytes) = serde_json::to_vec_pretty(&payload) else {
380 return;
381 };
382 if std::fs::write(&tmp, bytes).is_err() {
383 return;
384 }
385 let _ = std::fs::rename(&tmp, &path);
386}
387
388fn resolve_health_ledger_dir() -> PathBuf {
389 if let Some(custom) = read_env_string("ACB_HEALTH_LEDGER_DIR") {
390 if !custom.is_empty() {
391 return PathBuf::from(custom);
392 }
393 }
394 if let Some(custom) = read_env_string("AGENTRA_HEALTH_LEDGER_DIR") {
395 if !custom.is_empty() {
396 return PathBuf::from(custom);
397 }
398 }
399 let home = std::env::var("HOME")
400 .ok()
401 .map(PathBuf::from)
402 .unwrap_or_else(|| PathBuf::from("."));
403 home.join(".agentra").join("health-ledger")
404}
405
406fn styled(cli: &Cli) -> Styled {
408 match cli.format {
409 OutputFormat::Json => Styled::plain(),
410 OutputFormat::Text => Styled::auto(),
411 }
412}
413
414fn validate_acb_path(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
416 let s = Styled::auto();
417 if !path.exists() {
418 return Err(format!(
419 "{} File not found: {}\n {} Check the path and try again",
420 s.fail(),
421 path.display(),
422 s.info()
423 )
424 .into());
425 }
426 if !path.is_file() {
427 return Err(format!(
428 "{} Not a file: {}\n {} Provide a path to an .acb file, not a directory",
429 s.fail(),
430 path.display(),
431 s.info()
432 )
433 .into());
434 }
435 if path.extension().and_then(|e| e.to_str()) != Some("acb") {
436 return Err(format!(
437 "{} Expected .acb file, got: {}\n {} Compile a repository first: acb compile <dir>",
438 s.fail(),
439 path.display(),
440 s.info()
441 )
442 .into());
443 }
444 Ok(())
445}
446
447fn cmd_compile(
452 path: &Path,
453 output: Option<&std::path::Path>,
454 exclude: &[String],
455 include_tests: bool,
456 coverage_report: Option<&Path>,
457 cli: &Cli,
458) -> Result<(), Box<dyn std::error::Error>> {
459 let s = styled(cli);
460
461 if !path.exists() {
462 return Err(format!(
463 "{} Path does not exist: {}\n {} Create the directory or check the path",
464 s.fail(),
465 path.display(),
466 s.info()
467 )
468 .into());
469 }
470 if !path.is_dir() {
471 return Err(format!(
472 "{} Path is not a directory: {}\n {} Provide the root directory of a source repository",
473 s.fail(),
474 path.display(),
475 s.info()
476 )
477 .into());
478 }
479
480 let out_path = match output {
481 Some(p) => p.to_path_buf(),
482 None => {
483 let dir_name = path
484 .file_name()
485 .map(|n| n.to_string_lossy().to_string())
486 .unwrap_or_else(|| "output".to_string());
487 PathBuf::from(format!("{}.acb", dir_name))
488 }
489 };
490
491 let mut opts = ParseOptions {
493 include_tests,
494 ..ParseOptions::default()
495 };
496 for pat in exclude {
497 opts.exclude.push(pat.clone());
498 }
499
500 if !cli.quiet {
501 if let OutputFormat::Text = cli.format {
502 eprintln!(
503 " {} Compiling {} {} {}",
504 s.info(),
505 s.bold(&path.display().to_string()),
506 s.arrow(),
507 s.cyan(&out_path.display().to_string()),
508 );
509 }
510 }
511
512 if cli.verbose {
514 eprintln!(" {} Parsing source files...", s.info());
515 }
516 let parser = AcbParser::new();
517 let parse_result = parser.parse_directory(path, &opts)?;
518
519 if !cli.quiet {
520 if let OutputFormat::Text = cli.format {
521 eprintln!(
522 " {} Parsed {} files ({} units found)",
523 s.ok(),
524 parse_result.stats.files_parsed,
525 parse_result.units.len(),
526 );
527 let cov = &parse_result.stats.coverage;
528 eprintln!(
529 " {} Ingestion seen:{} candidate:{} skipped:{} errored:{}",
530 s.info(),
531 cov.files_seen,
532 cov.files_candidate,
533 cov.total_skipped(),
534 parse_result.stats.files_errored
535 );
536 if !parse_result.errors.is_empty() {
537 eprintln!(
538 " {} {} parse errors (use --verbose to see details)",
539 s.warn(),
540 parse_result.errors.len()
541 );
542 }
543 }
544 }
545
546 if cli.verbose && !parse_result.errors.is_empty() {
547 for err in &parse_result.errors {
548 eprintln!(" {} {:?}", s.warn(), err);
549 }
550 }
551
552 if cli.verbose {
554 eprintln!(" {} Running semantic analysis...", s.info());
555 }
556 let unit_count = parse_result.units.len();
557 progress("Analyzing", 0, unit_count);
558 let analyzer = SemanticAnalyzer::new();
559 let analyze_opts = AnalyzeOptions::default();
560 let graph = analyzer.analyze(parse_result.units, &analyze_opts)?;
561 progress("Analyzing", unit_count, unit_count);
562 progress_done();
563
564 if cli.verbose {
565 eprintln!(
566 " {} Graph built: {} units, {} edges",
567 s.ok(),
568 graph.unit_count(),
569 graph.edge_count()
570 );
571 }
572
573 if cli.verbose {
575 eprintln!(" {} Writing binary format...", s.info());
576 }
577 let backup_path = maybe_backup_existing_output(&out_path)?;
578 if cli.verbose {
579 if let Some(backup) = &backup_path {
580 eprintln!(
581 " {} Backed up previous graph to {}",
582 s.info(),
583 s.dim(&backup.display().to_string())
584 );
585 }
586 }
587 let writer = AcbWriter::with_default_dimension();
588 writer.write_to_file(&graph, &out_path)?;
589
590 let file_size = std::fs::metadata(&out_path).map(|m| m.len()).unwrap_or(0);
592 let budget_report = match maybe_enforce_storage_budget_on_output(&out_path) {
593 Ok(report) => report,
594 Err(e) => {
595 tracing::warn!("ACB storage budget check skipped: {e}");
596 AcbStorageBudgetReport {
597 mode: "off",
598 max_bytes: DEFAULT_STORAGE_BUDGET_BYTES,
599 horizon_years: DEFAULT_STORAGE_BUDGET_HORIZON_YEARS,
600 target_fraction: 0.85,
601 current_size_bytes: file_size,
602 projected_size_bytes: None,
603 family_size_bytes: file_size,
604 over_budget: false,
605 backups_trimmed: 0,
606 bytes_freed: 0,
607 }
608 }
609 };
610 let cov = &parse_result.stats.coverage;
611 let coverage_json = serde_json::json!({
612 "files_seen": cov.files_seen,
613 "files_candidate": cov.files_candidate,
614 "files_parsed": parse_result.stats.files_parsed,
615 "files_skipped_total": cov.total_skipped(),
616 "files_errored_total": parse_result.stats.files_errored,
617 "skip_reasons": {
618 "unknown_language": cov.skipped_unknown_language,
619 "language_filter": cov.skipped_language_filter,
620 "exclude_pattern": cov.skipped_excluded_pattern,
621 "too_large": cov.skipped_too_large,
622 "test_file_filtered": cov.skipped_test_file
623 },
624 "errors": {
625 "read_errors": cov.read_errors,
626 "parse_errors": cov.parse_errors
627 },
628 "parse_time_ms": parse_result.stats.parse_time_ms,
629 "by_language": parse_result.stats.by_language,
630 });
631
632 if let Some(report_path) = coverage_report {
633 if let Some(parent) = report_path.parent() {
634 if !parent.as_os_str().is_empty() {
635 std::fs::create_dir_all(parent)?;
636 }
637 }
638 let payload = serde_json::json!({
639 "status": "ok",
640 "source_root": path.display().to_string(),
641 "output_graph": out_path.display().to_string(),
642 "generated_at": chrono::Utc::now().to_rfc3339(),
643 "coverage": coverage_json,
644 });
645 std::fs::write(report_path, serde_json::to_string_pretty(&payload)? + "\n")?;
646 }
647
648 let stdout = std::io::stdout();
649 let mut out = stdout.lock();
650
651 match cli.format {
652 OutputFormat::Text => {
653 if !cli.quiet {
654 let _ = writeln!(out);
655 let _ = writeln!(out, " {} Compiled successfully!", s.ok());
656 let _ = writeln!(
657 out,
658 " Units: {}",
659 s.bold(&graph.unit_count().to_string())
660 );
661 let _ = writeln!(
662 out,
663 " Edges: {}",
664 s.bold(&graph.edge_count().to_string())
665 );
666 let _ = writeln!(
667 out,
668 " Languages: {}",
669 s.bold(&graph.languages().len().to_string())
670 );
671 let _ = writeln!(out, " Size: {}", s.dim(&format_size(file_size)));
672 if budget_report.over_budget {
673 let projected = budget_report
674 .projected_size_bytes
675 .map(format_size)
676 .unwrap_or_else(|| "unavailable".to_string());
677 let _ = writeln!(
678 out,
679 " Budget: {} current={} projected={} limit={}",
680 s.warn(),
681 format_size(budget_report.current_size_bytes),
682 projected,
683 format_size(budget_report.max_bytes)
684 );
685 }
686 if budget_report.backups_trimmed > 0 {
687 let _ = writeln!(
688 out,
689 " Budget fix: trimmed {} backups ({} freed)",
690 budget_report.backups_trimmed,
691 format_size(budget_report.bytes_freed)
692 );
693 }
694 let _ = writeln!(
695 out,
696 " Coverage: seen={} candidate={} skipped={} errored={}",
697 cov.files_seen,
698 cov.files_candidate,
699 cov.total_skipped(),
700 parse_result.stats.files_errored
701 );
702 if let Some(report_path) = coverage_report {
703 let _ = writeln!(
704 out,
705 " Report: {}",
706 s.dim(&report_path.display().to_string())
707 );
708 }
709 let _ = writeln!(out);
710 let _ = writeln!(
711 out,
712 " Next: {} or {}",
713 s.cyan(&format!("acb info {}", out_path.display())),
714 s.cyan(&format!(
715 "acb query {} symbol --name <search>",
716 out_path.display()
717 )),
718 );
719 }
720 }
721 OutputFormat::Json => {
722 let obj = serde_json::json!({
723 "status": "ok",
724 "source": path.display().to_string(),
725 "output": out_path.display().to_string(),
726 "units": graph.unit_count(),
727 "edges": graph.edge_count(),
728 "languages": graph.languages().len(),
729 "file_size_bytes": file_size,
730 "storage_budget": {
731 "mode": budget_report.mode,
732 "max_bytes": budget_report.max_bytes,
733 "horizon_years": budget_report.horizon_years,
734 "target_fraction": budget_report.target_fraction,
735 "current_size_bytes": budget_report.current_size_bytes,
736 "projected_size_bytes": budget_report.projected_size_bytes,
737 "family_size_bytes": budget_report.family_size_bytes,
738 "over_budget": budget_report.over_budget,
739 "backups_trimmed": budget_report.backups_trimmed,
740 "bytes_freed": budget_report.bytes_freed
741 },
742 "coverage": coverage_json,
743 });
744 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
745 }
746 }
747
748 Ok(())
749}
750
751#[derive(Debug, Clone)]
752struct AcbStorageBudgetReport {
753 mode: &'static str,
754 max_bytes: u64,
755 horizon_years: u32,
756 target_fraction: f32,
757 current_size_bytes: u64,
758 projected_size_bytes: Option<u64>,
759 family_size_bytes: u64,
760 over_budget: bool,
761 backups_trimmed: usize,
762 bytes_freed: u64,
763}
764
765#[derive(Debug, Clone)]
766struct BackupEntry {
767 path: PathBuf,
768 size: u64,
769 modified: SystemTime,
770}
771
772fn maybe_enforce_storage_budget_on_output(
773 out_path: &Path,
774) -> Result<AcbStorageBudgetReport, Box<dyn std::error::Error>> {
775 let mode = StorageBudgetMode::from_env("ACB_STORAGE_BUDGET_MODE");
776 let max_bytes = read_env_u64("ACB_STORAGE_BUDGET_BYTES", DEFAULT_STORAGE_BUDGET_BYTES).max(1);
777 let horizon_years = read_env_u32(
778 "ACB_STORAGE_BUDGET_HORIZON_YEARS",
779 DEFAULT_STORAGE_BUDGET_HORIZON_YEARS,
780 )
781 .max(1);
782 let target_fraction =
783 read_env_f32("ACB_STORAGE_BUDGET_TARGET_FRACTION", 0.85).clamp(0.50, 0.99);
784
785 let current_meta = std::fs::metadata(out_path)?;
786 let current_size = current_meta.len();
787 let current_modified = current_meta.modified().unwrap_or(SystemTime::now());
788 let mut backups = list_backup_entries(out_path)?;
789 let mut family_size = current_size.saturating_add(backups.iter().map(|b| b.size).sum::<u64>());
790 let projected =
791 projected_size_from_samples(&backups, current_modified, current_size, horizon_years);
792 let over_budget = current_size > max_bytes || projected.map(|v| v > max_bytes).unwrap_or(false);
793
794 let mut trimmed = 0usize;
795 let mut bytes_freed = 0u64;
796
797 if mode == StorageBudgetMode::Warn && over_budget {
798 tracing::warn!(
799 "ACB storage budget warning: current={} projected={:?} limit={}",
800 current_size,
801 projected,
802 max_bytes
803 );
804 }
805
806 if mode == StorageBudgetMode::AutoRollup && (over_budget || family_size > max_bytes) {
807 let target_bytes = ((max_bytes as f64 * target_fraction as f64).round() as u64).max(1);
808 backups.sort_by_key(|b| b.modified);
809 for backup in backups {
810 if family_size <= target_bytes {
811 break;
812 }
813 if std::fs::remove_file(&backup.path).is_ok() {
814 family_size = family_size.saturating_sub(backup.size);
815 trimmed = trimmed.saturating_add(1);
816 bytes_freed = bytes_freed.saturating_add(backup.size);
817 }
818 }
819
820 if trimmed > 0 {
821 tracing::info!(
822 "ACB storage budget rollup: trimmed_backups={} freed_bytes={} family_size={}",
823 trimmed,
824 bytes_freed,
825 family_size
826 );
827 }
828 }
829
830 Ok(AcbStorageBudgetReport {
831 mode: mode.as_str(),
832 max_bytes,
833 horizon_years,
834 target_fraction,
835 current_size_bytes: current_size,
836 projected_size_bytes: projected,
837 family_size_bytes: family_size,
838 over_budget,
839 backups_trimmed: trimmed,
840 bytes_freed,
841 })
842}
843
844fn list_backup_entries(out_path: &Path) -> Result<Vec<BackupEntry>, Box<dyn std::error::Error>> {
845 let backups_dir = resolve_backup_dir(out_path);
846 if !backups_dir.exists() {
847 return Ok(Vec::new());
848 }
849
850 let original_name = out_path
851 .file_name()
852 .and_then(|n| n.to_str())
853 .unwrap_or("graph.acb");
854
855 let mut out = Vec::new();
856 for entry in std::fs::read_dir(&backups_dir)? {
857 let entry = entry?;
858 let name = entry.file_name();
859 let Some(name_str) = name.to_str() else {
860 continue;
861 };
862 if !(name_str.starts_with(original_name) && name_str.ends_with(".bak")) {
863 continue;
864 }
865 let meta = entry.metadata()?;
866 out.push(BackupEntry {
867 path: entry.path(),
868 size: meta.len(),
869 modified: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),
870 });
871 }
872 Ok(out)
873}
874
875fn projected_size_from_samples(
876 backups: &[BackupEntry],
877 current_modified: SystemTime,
878 current_size: u64,
879 horizon_years: u32,
880) -> Option<u64> {
881 let mut samples = backups
882 .iter()
883 .map(|b| (b.modified, b.size))
884 .collect::<Vec<_>>();
885 samples.push((current_modified, current_size));
886 if samples.len() < 2 {
887 return None;
888 }
889 samples.sort_by_key(|(ts, _)| *ts);
890 let (first_ts, first_size) = samples.first().copied()?;
891 let (last_ts, last_size) = samples.last().copied()?;
892 if last_ts <= first_ts {
893 return None;
894 }
895 let span_secs = last_ts
896 .duration_since(first_ts)
897 .ok()?
898 .as_secs_f64()
899 .max(1.0);
900 let delta = (last_size as f64 - first_size as f64).max(0.0);
901 if delta <= 0.0 {
902 return Some(current_size);
903 }
904 let per_sec = delta / span_secs;
905 let horizon_secs = (horizon_years.max(1) as f64) * 365.25 * 24.0 * 3600.0;
906 let projected = (current_size as f64 + per_sec * horizon_secs).round();
907 Some(projected.max(0.0).min(u64::MAX as f64) as u64)
908}
909
910fn maybe_backup_existing_output(
911 out_path: &Path,
912) -> Result<Option<PathBuf>, Box<dyn std::error::Error>> {
913 if !auto_backup_enabled() || !out_path.exists() || !out_path.is_file() {
914 return Ok(None);
915 }
916
917 let backups_dir = resolve_backup_dir(out_path);
918 std::fs::create_dir_all(&backups_dir)?;
919
920 let original_name = out_path
921 .file_name()
922 .and_then(|n| n.to_str())
923 .unwrap_or("graph.acb");
924 let ts = chrono::Utc::now().format("%Y%m%d%H%M%S");
925 let backup_path = backups_dir.join(format!("{original_name}.{ts}.bak"));
926 std::fs::copy(out_path, &backup_path)?;
927 prune_old_backups(&backups_dir, original_name, auto_backup_retention())?;
928
929 Ok(Some(backup_path))
930}
931
932fn auto_backup_enabled() -> bool {
933 match std::env::var("ACB_AUTO_BACKUP") {
934 Ok(v) => {
935 let value = v.trim().to_ascii_lowercase();
936 value != "0" && value != "false" && value != "off" && value != "no"
937 }
938 Err(_) => true,
939 }
940}
941
942fn auto_backup_retention() -> usize {
943 let default_retention = match read_env_string("ACB_AUTONOMIC_PROFILE")
944 .unwrap_or_else(|| "desktop".to_string())
945 .to_ascii_lowercase()
946 .as_str()
947 {
948 "cloud" => 40,
949 "aggressive" => 12,
950 _ => 20,
951 };
952 std::env::var("ACB_AUTO_BACKUP_RETENTION")
953 .ok()
954 .and_then(|v| v.parse::<usize>().ok())
955 .unwrap_or(default_retention)
956 .max(1)
957}
958
959fn resolve_backup_dir(out_path: &Path) -> PathBuf {
960 if let Ok(custom) = std::env::var("ACB_AUTO_BACKUP_DIR") {
961 let trimmed = custom.trim();
962 if !trimmed.is_empty() {
963 return PathBuf::from(trimmed);
964 }
965 }
966 out_path
967 .parent()
968 .unwrap_or_else(|| Path::new("."))
969 .join(".acb-backups")
970}
971
972fn read_env_string(name: &str) -> Option<String> {
973 std::env::var(name).ok().map(|v| v.trim().to_string())
974}
975
976fn read_env_u64(name: &str, default_value: u64) -> u64 {
977 std::env::var(name)
978 .ok()
979 .and_then(|v| v.parse::<u64>().ok())
980 .unwrap_or(default_value)
981}
982
983fn read_env_u32(name: &str, default_value: u32) -> u32 {
984 std::env::var(name)
985 .ok()
986 .and_then(|v| v.parse::<u32>().ok())
987 .unwrap_or(default_value)
988}
989
990fn read_env_f32(name: &str, default_value: f32) -> f32 {
991 std::env::var(name)
992 .ok()
993 .and_then(|v| v.parse::<f32>().ok())
994 .unwrap_or(default_value)
995}
996
997fn prune_old_backups(
998 backup_dir: &Path,
999 original_name: &str,
1000 retention: usize,
1001) -> Result<(), Box<dyn std::error::Error>> {
1002 let mut backups = std::fs::read_dir(backup_dir)?
1003 .filter_map(Result::ok)
1004 .filter(|entry| {
1005 entry
1006 .file_name()
1007 .to_str()
1008 .map(|name| name.starts_with(original_name) && name.ends_with(".bak"))
1009 .unwrap_or(false)
1010 })
1011 .collect::<Vec<_>>();
1012
1013 if backups.len() <= retention {
1014 return Ok(());
1015 }
1016
1017 backups.sort_by_key(|entry| {
1018 entry
1019 .metadata()
1020 .and_then(|m| m.modified())
1021 .ok()
1022 .unwrap_or(SystemTime::UNIX_EPOCH)
1023 });
1024
1025 let to_remove = backups.len().saturating_sub(retention);
1026 for entry in backups.into_iter().take(to_remove) {
1027 let _ = std::fs::remove_file(entry.path());
1028 }
1029 Ok(())
1030}
1031
1032fn cmd_budget(
1033 file: &Path,
1034 max_bytes: u64,
1035 horizon_years: u32,
1036 cli: &Cli,
1037) -> Result<(), Box<dyn std::error::Error>> {
1038 validate_acb_path(file)?;
1039 let s = styled(cli);
1040 let current_meta = std::fs::metadata(file)?;
1041 let current_size = current_meta.len();
1042 let current_modified = current_meta.modified().unwrap_or(SystemTime::now());
1043 let backups = list_backup_entries(file)?;
1044 let family_size = current_size.saturating_add(backups.iter().map(|b| b.size).sum::<u64>());
1045 let projected =
1046 projected_size_from_samples(&backups, current_modified, current_size, horizon_years);
1047 let over_budget = current_size > max_bytes || projected.map(|v| v > max_bytes).unwrap_or(false);
1048 let daily_budget_bytes = max_bytes as f64 / ((horizon_years.max(1) as f64) * 365.25);
1049
1050 let stdout = std::io::stdout();
1051 let mut out = stdout.lock();
1052
1053 match cli.format {
1054 OutputFormat::Text => {
1055 let status = if over_budget {
1056 s.red("over-budget")
1057 } else {
1058 s.green("within-budget")
1059 };
1060 let _ = writeln!(out, "\n {} {}\n", s.info(), s.bold("ACB Storage Budget"));
1061 let _ = writeln!(out, " File: {}", file.display());
1062 let _ = writeln!(out, " Current: {}", format_size(current_size));
1063 if let Some(v) = projected {
1064 let _ = writeln!(
1065 out,
1066 " Projected: {} ({}y)",
1067 format_size(v),
1068 horizon_years
1069 );
1070 } else {
1071 let _ = writeln!(
1072 out,
1073 " Projected: unavailable (need backup history samples)"
1074 );
1075 }
1076 let _ = writeln!(out, " Family: {}", format_size(family_size));
1077 let _ = writeln!(out, " Budget: {}", format_size(max_bytes));
1078 let _ = writeln!(out, " Status: {}", status);
1079 let _ = writeln!(
1080 out,
1081 " Guidance: {:.1} KB/day target growth",
1082 daily_budget_bytes / 1024.0
1083 );
1084 let _ = writeln!(
1085 out,
1086 " Suggested env: ACB_STORAGE_BUDGET_MODE=auto-rollup ACB_STORAGE_BUDGET_BYTES={} ACB_STORAGE_BUDGET_HORIZON_YEARS={}",
1087 max_bytes,
1088 horizon_years
1089 );
1090 let _ = writeln!(out);
1091 }
1092 OutputFormat::Json => {
1093 let obj = serde_json::json!({
1094 "file": file.display().to_string(),
1095 "current_size_bytes": current_size,
1096 "projected_size_bytes": projected,
1097 "family_size_bytes": family_size,
1098 "max_budget_bytes": max_bytes,
1099 "horizon_years": horizon_years,
1100 "over_budget": over_budget,
1101 "daily_budget_bytes": daily_budget_bytes,
1102 "daily_budget_kb": daily_budget_bytes / 1024.0,
1103 "guidance": {
1104 "recommended_policy_mode": if over_budget { "auto-rollup" } else { "warn" },
1105 "env": {
1106 "ACB_STORAGE_BUDGET_MODE": "auto-rollup|warn|off",
1107 "ACB_STORAGE_BUDGET_BYTES": max_bytes,
1108 "ACB_STORAGE_BUDGET_HORIZON_YEARS": horizon_years,
1109 }
1110 }
1111 });
1112 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1113 }
1114 }
1115 Ok(())
1116}
1117
1118fn cmd_info(file: &PathBuf, cli: &Cli) -> Result<(), Box<dyn std::error::Error>> {
1123 let s = styled(cli);
1124 validate_acb_path(file)?;
1125 let graph = AcbReader::read_from_file(file)?;
1126
1127 let data = std::fs::read(file)?;
1129 let header_bytes: [u8; 128] = data[..128]
1130 .try_into()
1131 .map_err(|_| "File too small for header")?;
1132 let header = FileHeader::from_bytes(&header_bytes)?;
1133 let file_size = data.len() as u64;
1134
1135 let stdout = std::io::stdout();
1136 let mut out = stdout.lock();
1137
1138 match cli.format {
1139 OutputFormat::Text => {
1140 let _ = writeln!(
1141 out,
1142 "\n {} {}",
1143 s.info(),
1144 s.bold(&file.display().to_string())
1145 );
1146 let _ = writeln!(out, " Version: v{}", header.version);
1147 let _ = writeln!(
1148 out,
1149 " Units: {}",
1150 s.bold(&graph.unit_count().to_string())
1151 );
1152 let _ = writeln!(
1153 out,
1154 " Edges: {}",
1155 s.bold(&graph.edge_count().to_string())
1156 );
1157 let _ = writeln!(
1158 out,
1159 " Languages: {}",
1160 s.bold(&graph.languages().len().to_string())
1161 );
1162 let _ = writeln!(out, " Dimension: {}", header.dimension);
1163 let _ = writeln!(out, " File size: {}", format_size(file_size));
1164 let _ = writeln!(out);
1165 for lang in graph.languages() {
1166 let count = graph.units().iter().filter(|u| u.language == *lang).count();
1167 let _ = writeln!(
1168 out,
1169 " {} {} {}",
1170 s.arrow(),
1171 s.cyan(&format!("{:12}", lang)),
1172 s.dim(&format!("{} units", count))
1173 );
1174 }
1175 let _ = writeln!(out);
1176 }
1177 OutputFormat::Json => {
1178 let mut lang_map = serde_json::Map::new();
1179 for lang in graph.languages() {
1180 let count = graph.units().iter().filter(|u| u.language == *lang).count();
1181 lang_map.insert(lang.to_string(), serde_json::json!(count));
1182 }
1183 let obj = serde_json::json!({
1184 "file": file.display().to_string(),
1185 "version": header.version,
1186 "units": graph.unit_count(),
1187 "edges": graph.edge_count(),
1188 "languages": graph.languages().len(),
1189 "dimension": header.dimension,
1190 "file_size_bytes": file_size,
1191 "language_breakdown": lang_map,
1192 });
1193 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1194 }
1195 }
1196
1197 Ok(())
1198}
1199
1200fn cmd_health(file: &Path, limit: usize, cli: &Cli) -> Result<(), Box<dyn std::error::Error>> {
1201 validate_acb_path(file)?;
1202 let graph = AcbReader::read_from_file(file)?;
1203 let engine = QueryEngine::new();
1204 let s = styled(cli);
1205
1206 let prophecy = engine.prophecy(
1207 &graph,
1208 ProphecyParams {
1209 top_k: limit,
1210 min_risk: 0.45,
1211 },
1212 )?;
1213 let test_gaps = engine.test_gap(
1214 &graph,
1215 TestGapParams {
1216 min_changes: 5,
1217 min_complexity: 10,
1218 unit_types: vec![],
1219 },
1220 )?;
1221 let hotspots = engine.hotspot_detection(
1222 &graph,
1223 HotspotParams {
1224 top_k: limit,
1225 min_score: 0.55,
1226 unit_types: vec![],
1227 },
1228 )?;
1229 let dead_code = engine.dead_code(
1230 &graph,
1231 DeadCodeParams {
1232 unit_types: vec![],
1233 include_tests_as_roots: true,
1234 },
1235 )?;
1236
1237 let high_risk = prophecy
1238 .predictions
1239 .iter()
1240 .filter(|p| p.risk_score >= 0.70)
1241 .count();
1242 let avg_risk = if prophecy.predictions.is_empty() {
1243 0.0
1244 } else {
1245 prophecy
1246 .predictions
1247 .iter()
1248 .map(|p| p.risk_score)
1249 .sum::<f32>()
1250 / prophecy.predictions.len() as f32
1251 };
1252 let status = if high_risk >= 3 || test_gaps.len() >= 8 {
1253 "fail"
1254 } else if high_risk > 0 || !test_gaps.is_empty() || !hotspots.is_empty() {
1255 "warn"
1256 } else {
1257 "pass"
1258 };
1259
1260 let stdout = std::io::stdout();
1261 let mut out = stdout.lock();
1262 match cli.format {
1263 OutputFormat::Text => {
1264 let status_label = match status {
1265 "pass" => s.green("PASS"),
1266 "warn" => s.yellow("WARN"),
1267 _ => s.red("FAIL"),
1268 };
1269 let _ = writeln!(
1270 out,
1271 "\n Graph health for {} [{}]\n",
1272 s.bold(&file.display().to_string()),
1273 status_label
1274 );
1275 let _ = writeln!(out, " Units: {}", graph.unit_count());
1276 let _ = writeln!(out, " Edges: {}", graph.edge_count());
1277 let _ = writeln!(out, " Avg risk: {:.2}", avg_risk);
1278 let _ = writeln!(out, " High risk: {}", high_risk);
1279 let _ = writeln!(out, " Test gaps: {}", test_gaps.len());
1280 let _ = writeln!(out, " Hotspots: {}", hotspots.len());
1281 let _ = writeln!(out, " Dead code: {}", dead_code.len());
1282 let _ = writeln!(out);
1283
1284 if !prophecy.predictions.is_empty() {
1285 let _ = writeln!(out, " Top risk predictions:");
1286 for p in prophecy.predictions.iter().take(5) {
1287 let name = graph
1288 .get_unit(p.unit_id)
1289 .map(|u| u.qualified_name.clone())
1290 .unwrap_or_else(|| format!("unit_{}", p.unit_id));
1291 let _ = writeln!(out, " {} {:.2} {}", s.arrow(), p.risk_score, name);
1292 }
1293 let _ = writeln!(out);
1294 }
1295
1296 if !test_gaps.is_empty() {
1297 let _ = writeln!(out, " Top test gaps:");
1298 for g in test_gaps.iter().take(5) {
1299 let name = graph
1300 .get_unit(g.unit_id)
1301 .map(|u| u.qualified_name.clone())
1302 .unwrap_or_else(|| format!("unit_{}", g.unit_id));
1303 let _ = writeln!(
1304 out,
1305 " {} {:.2} {} ({})",
1306 s.arrow(),
1307 g.priority,
1308 name,
1309 g.reason
1310 );
1311 }
1312 let _ = writeln!(out);
1313 }
1314
1315 let _ = writeln!(
1316 out,
1317 " Next: acb gate {} --unit-id <id> --max-risk 0.60",
1318 file.display()
1319 );
1320 let _ = writeln!(out);
1321 }
1322 OutputFormat::Json => {
1323 let predictions = prophecy
1324 .predictions
1325 .iter()
1326 .map(|p| {
1327 serde_json::json!({
1328 "unit_id": p.unit_id,
1329 "name": graph.get_unit(p.unit_id).map(|u| u.qualified_name.clone()).unwrap_or_default(),
1330 "risk_score": p.risk_score,
1331 "reason": p.reason,
1332 })
1333 })
1334 .collect::<Vec<_>>();
1335 let gaps = test_gaps
1336 .iter()
1337 .map(|g| {
1338 serde_json::json!({
1339 "unit_id": g.unit_id,
1340 "name": graph.get_unit(g.unit_id).map(|u| u.qualified_name.clone()).unwrap_or_default(),
1341 "priority": g.priority,
1342 "reason": g.reason,
1343 })
1344 })
1345 .collect::<Vec<_>>();
1346 let hotspot_rows = hotspots
1347 .iter()
1348 .map(|h| {
1349 serde_json::json!({
1350 "unit_id": h.unit_id,
1351 "name": graph.get_unit(h.unit_id).map(|u| u.qualified_name.clone()).unwrap_or_default(),
1352 "score": h.score,
1353 "factors": h.factors,
1354 })
1355 })
1356 .collect::<Vec<_>>();
1357 let dead_rows = dead_code
1358 .iter()
1359 .map(|u| {
1360 serde_json::json!({
1361 "unit_id": u.id,
1362 "name": u.qualified_name,
1363 "type": u.unit_type.label(),
1364 })
1365 })
1366 .collect::<Vec<_>>();
1367
1368 let obj = serde_json::json!({
1369 "status": status,
1370 "graph": file.display().to_string(),
1371 "summary": {
1372 "units": graph.unit_count(),
1373 "edges": graph.edge_count(),
1374 "avg_risk": avg_risk,
1375 "high_risk_count": high_risk,
1376 "test_gap_count": test_gaps.len(),
1377 "hotspot_count": hotspots.len(),
1378 "dead_code_count": dead_code.len(),
1379 },
1380 "risk_predictions": predictions,
1381 "test_gaps": gaps,
1382 "hotspots": hotspot_rows,
1383 "dead_code": dead_rows,
1384 });
1385 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1386 }
1387 }
1388
1389 Ok(())
1390}
1391
1392fn cmd_gate(
1393 file: &Path,
1394 unit_id: u64,
1395 max_risk: f32,
1396 depth: u32,
1397 require_tests: bool,
1398 cli: &Cli,
1399) -> Result<(), Box<dyn std::error::Error>> {
1400 validate_acb_path(file)?;
1401 let graph = AcbReader::read_from_file(file)?;
1402 let engine = QueryEngine::new();
1403 let s = styled(cli);
1404
1405 let result = engine.impact_analysis(
1406 &graph,
1407 ImpactParams {
1408 unit_id,
1409 max_depth: depth,
1410 edge_types: vec![],
1411 },
1412 )?;
1413 let untested_count = result.impacted.iter().filter(|u| !u.has_tests).count();
1414 let risk_pass = result.overall_risk <= max_risk;
1415 let test_pass = !require_tests || untested_count == 0;
1416 let passed = risk_pass && test_pass;
1417
1418 let stdout = std::io::stdout();
1419 let mut out = stdout.lock();
1420
1421 match cli.format {
1422 OutputFormat::Text => {
1423 let label = if passed {
1424 s.green("PASS")
1425 } else {
1426 s.red("FAIL")
1427 };
1428 let unit_name = graph
1429 .get_unit(unit_id)
1430 .map(|u| u.qualified_name.clone())
1431 .unwrap_or_else(|| format!("unit_{}", unit_id));
1432 let _ = writeln!(out, "\n Gate {} for {}\n", label, s.bold(&unit_name));
1433 let _ = writeln!(
1434 out,
1435 " Overall risk: {:.2} (max {:.2})",
1436 result.overall_risk, max_risk
1437 );
1438 let _ = writeln!(out, " Impacted: {}", result.impacted.len());
1439 let _ = writeln!(out, " Untested: {}", untested_count);
1440 let _ = writeln!(out, " Require tests: {}", require_tests);
1441 if !result.recommendations.is_empty() {
1442 let _ = writeln!(out);
1443 for rec in &result.recommendations {
1444 let _ = writeln!(out, " {} {}", s.info(), rec);
1445 }
1446 }
1447 let _ = writeln!(out);
1448 }
1449 OutputFormat::Json => {
1450 let obj = serde_json::json!({
1451 "gate": if passed { "pass" } else { "fail" },
1452 "file": file.display().to_string(),
1453 "unit_id": unit_id,
1454 "max_risk": max_risk,
1455 "overall_risk": result.overall_risk,
1456 "impacted_count": result.impacted.len(),
1457 "untested_count": untested_count,
1458 "require_tests": require_tests,
1459 "recommendations": result.recommendations,
1460 });
1461 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1462 }
1463 }
1464
1465 if !passed {
1466 return Err(format!(
1467 "{} gate failed: risk_pass={} test_pass={} (risk {:.2} / max {:.2}, untested {})",
1468 s.fail(),
1469 risk_pass,
1470 test_pass,
1471 result.overall_risk,
1472 max_risk,
1473 untested_count
1474 )
1475 .into());
1476 }
1477
1478 Ok(())
1479}
1480
1481fn cmd_query(
1486 file: &Path,
1487 query_type: &str,
1488 name: Option<&str>,
1489 unit_id: Option<u64>,
1490 depth: u32,
1491 limit: usize,
1492 cli: &Cli,
1493) -> Result<(), Box<dyn std::error::Error>> {
1494 validate_acb_path(file)?;
1495 let graph = AcbReader::read_from_file(file)?;
1496 let engine = QueryEngine::new();
1497 let s = styled(cli);
1498
1499 match query_type {
1500 "symbol" | "sym" | "s" => query_symbol(&graph, &engine, name, limit, cli, &s),
1501 "deps" | "dep" | "d" => query_deps(&graph, &engine, unit_id, depth, cli, &s),
1502 "rdeps" | "rdep" | "r" => query_rdeps(&graph, &engine, unit_id, depth, cli, &s),
1503 "impact" | "imp" | "i" => query_impact(&graph, &engine, unit_id, depth, cli, &s),
1504 "calls" | "call" | "c" => query_calls(&graph, &engine, unit_id, depth, cli, &s),
1505 "similar" | "sim" => query_similar(&graph, &engine, unit_id, limit, cli, &s),
1506 "prophecy" | "predict" | "p" => query_prophecy(&graph, &engine, limit, cli, &s),
1507 "stability" | "stab" => query_stability(&graph, &engine, unit_id, cli, &s),
1508 "coupling" | "couple" => query_coupling(&graph, &engine, unit_id, cli, &s),
1509 "test-gap" | "testgap" | "gaps" => query_test_gap(&graph, &engine, limit, cli, &s),
1510 "hotspot" | "hotspots" => query_hotspots(&graph, &engine, limit, cli, &s),
1511 "dead" | "dead-code" | "deadcode" => query_dead_code(&graph, &engine, limit, cli, &s),
1512 other => {
1513 let known = [
1514 "symbol",
1515 "deps",
1516 "rdeps",
1517 "impact",
1518 "calls",
1519 "similar",
1520 "prophecy",
1521 "stability",
1522 "coupling",
1523 "test-gap",
1524 "hotspots",
1525 "dead-code",
1526 ];
1527 let suggestion = known
1528 .iter()
1529 .filter(|k| k.starts_with(&other[..1.min(other.len())]))
1530 .copied()
1531 .collect::<Vec<_>>();
1532 let hint = if suggestion.is_empty() {
1533 format!("Available: {}", known.join(", "))
1534 } else {
1535 format!("Did you mean: {}?", suggestion.join(", "))
1536 };
1537 Err(format!(
1538 "{} Unknown query type: {}\n {} {}",
1539 s.fail(),
1540 other,
1541 s.info(),
1542 hint
1543 )
1544 .into())
1545 }
1546 }
1547}
1548
1549fn query_symbol(
1550 graph: &CodeGraph,
1551 engine: &QueryEngine,
1552 name: Option<&str>,
1553 limit: usize,
1554 cli: &Cli,
1555 s: &Styled,
1556) -> Result<(), Box<dyn std::error::Error>> {
1557 let search_name = name.ok_or_else(|| {
1558 format!(
1559 "{} --name is required for symbol queries\n {} Example: acb query file.acb symbol --name UserService",
1560 s.fail(),
1561 s.info()
1562 )
1563 })?;
1564 let params = SymbolLookupParams {
1565 name: search_name.to_string(),
1566 mode: MatchMode::Contains,
1567 limit,
1568 ..Default::default()
1569 };
1570 let results = engine.symbol_lookup(graph, params)?;
1571
1572 let stdout = std::io::stdout();
1573 let mut out = stdout.lock();
1574
1575 match cli.format {
1576 OutputFormat::Text => {
1577 let _ = writeln!(
1578 out,
1579 "\n Symbol lookup: {} ({} results)\n",
1580 s.bold(&format!("\"{}\"", search_name)),
1581 results.len()
1582 );
1583 if results.is_empty() {
1584 let _ = writeln!(
1585 out,
1586 " {} No matches found. Try a broader search term.",
1587 s.warn()
1588 );
1589 }
1590 for (i, unit) in results.iter().enumerate() {
1591 let _ = writeln!(
1592 out,
1593 " {:>3}. {} {} {}",
1594 s.dim(&format!("#{}", i + 1)),
1595 s.bold(&unit.qualified_name),
1596 s.dim(&format!("({})", unit.unit_type)),
1597 s.dim(&format!(
1598 "{}:{}",
1599 unit.file_path.display(),
1600 unit.span.start_line
1601 ))
1602 );
1603 }
1604 let _ = writeln!(out);
1605 }
1606 OutputFormat::Json => {
1607 let entries: Vec<serde_json::Value> = results
1608 .iter()
1609 .map(|u| {
1610 serde_json::json!({
1611 "id": u.id,
1612 "name": u.name,
1613 "qualified_name": u.qualified_name,
1614 "unit_type": u.unit_type.label(),
1615 "language": u.language.name(),
1616 "file": u.file_path.display().to_string(),
1617 "line": u.span.start_line,
1618 })
1619 })
1620 .collect();
1621 let obj = serde_json::json!({
1622 "query": "symbol",
1623 "name": search_name,
1624 "count": results.len(),
1625 "results": entries,
1626 });
1627 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1628 }
1629 }
1630 Ok(())
1631}
1632
1633fn query_deps(
1634 graph: &CodeGraph,
1635 engine: &QueryEngine,
1636 unit_id: Option<u64>,
1637 depth: u32,
1638 cli: &Cli,
1639 s: &Styled,
1640) -> Result<(), Box<dyn std::error::Error>> {
1641 let uid = unit_id.ok_or_else(|| {
1642 format!(
1643 "{} --unit-id is required for deps queries\n {} Find an ID first: acb query file.acb symbol --name <name>",
1644 s.fail(), s.info()
1645 )
1646 })?;
1647 let params = DependencyParams {
1648 unit_id: uid,
1649 max_depth: depth,
1650 edge_types: vec![],
1651 include_transitive: true,
1652 };
1653 let result = engine.dependency_graph(graph, params)?;
1654
1655 let stdout = std::io::stdout();
1656 let mut out = stdout.lock();
1657
1658 match cli.format {
1659 OutputFormat::Text => {
1660 let root_name = graph
1661 .get_unit(uid)
1662 .map(|u| u.qualified_name.as_str())
1663 .unwrap_or("?");
1664 let _ = writeln!(
1665 out,
1666 "\n Dependencies of {} ({} found)\n",
1667 s.bold(root_name),
1668 result.nodes.len()
1669 );
1670 for node in &result.nodes {
1671 let unit_name = graph
1672 .get_unit(node.unit_id)
1673 .map(|u| u.qualified_name.as_str())
1674 .unwrap_or("?");
1675 let indent = " ".repeat(node.depth as usize);
1676 let _ = writeln!(
1677 out,
1678 " {}{} {} {}",
1679 indent,
1680 s.arrow(),
1681 s.cyan(unit_name),
1682 s.dim(&format!("[id:{}]", node.unit_id))
1683 );
1684 }
1685 let _ = writeln!(out);
1686 }
1687 OutputFormat::Json => {
1688 let entries: Vec<serde_json::Value> = result
1689 .nodes
1690 .iter()
1691 .map(|n| {
1692 let unit_name = graph
1693 .get_unit(n.unit_id)
1694 .map(|u| u.qualified_name.clone())
1695 .unwrap_or_default();
1696 serde_json::json!({
1697 "unit_id": n.unit_id,
1698 "name": unit_name,
1699 "depth": n.depth,
1700 })
1701 })
1702 .collect();
1703 let obj = serde_json::json!({
1704 "query": "deps",
1705 "root_id": uid,
1706 "count": result.nodes.len(),
1707 "results": entries,
1708 });
1709 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1710 }
1711 }
1712 Ok(())
1713}
1714
1715fn query_rdeps(
1716 graph: &CodeGraph,
1717 engine: &QueryEngine,
1718 unit_id: Option<u64>,
1719 depth: u32,
1720 cli: &Cli,
1721 s: &Styled,
1722) -> Result<(), Box<dyn std::error::Error>> {
1723 let uid = unit_id.ok_or_else(|| {
1724 format!(
1725 "{} --unit-id is required for rdeps queries\n {} Find an ID first: acb query file.acb symbol --name <name>",
1726 s.fail(), s.info()
1727 )
1728 })?;
1729 let params = DependencyParams {
1730 unit_id: uid,
1731 max_depth: depth,
1732 edge_types: vec![],
1733 include_transitive: true,
1734 };
1735 let result = engine.reverse_dependency(graph, params)?;
1736
1737 let stdout = std::io::stdout();
1738 let mut out = stdout.lock();
1739
1740 match cli.format {
1741 OutputFormat::Text => {
1742 let root_name = graph
1743 .get_unit(uid)
1744 .map(|u| u.qualified_name.as_str())
1745 .unwrap_or("?");
1746 let _ = writeln!(
1747 out,
1748 "\n Reverse dependencies of {} ({} found)\n",
1749 s.bold(root_name),
1750 result.nodes.len()
1751 );
1752 for node in &result.nodes {
1753 let unit_name = graph
1754 .get_unit(node.unit_id)
1755 .map(|u| u.qualified_name.as_str())
1756 .unwrap_or("?");
1757 let indent = " ".repeat(node.depth as usize);
1758 let _ = writeln!(
1759 out,
1760 " {}{} {} {}",
1761 indent,
1762 s.arrow(),
1763 s.cyan(unit_name),
1764 s.dim(&format!("[id:{}]", node.unit_id))
1765 );
1766 }
1767 let _ = writeln!(out);
1768 }
1769 OutputFormat::Json => {
1770 let entries: Vec<serde_json::Value> = result
1771 .nodes
1772 .iter()
1773 .map(|n| {
1774 let unit_name = graph
1775 .get_unit(n.unit_id)
1776 .map(|u| u.qualified_name.clone())
1777 .unwrap_or_default();
1778 serde_json::json!({
1779 "unit_id": n.unit_id,
1780 "name": unit_name,
1781 "depth": n.depth,
1782 })
1783 })
1784 .collect();
1785 let obj = serde_json::json!({
1786 "query": "rdeps",
1787 "root_id": uid,
1788 "count": result.nodes.len(),
1789 "results": entries,
1790 });
1791 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1792 }
1793 }
1794 Ok(())
1795}
1796
1797fn query_impact(
1798 graph: &CodeGraph,
1799 engine: &QueryEngine,
1800 unit_id: Option<u64>,
1801 depth: u32,
1802 cli: &Cli,
1803 s: &Styled,
1804) -> Result<(), Box<dyn std::error::Error>> {
1805 let uid =
1806 unit_id.ok_or_else(|| format!("{} --unit-id is required for impact queries", s.fail()))?;
1807 let params = ImpactParams {
1808 unit_id: uid,
1809 max_depth: depth,
1810 edge_types: vec![],
1811 };
1812 let result = engine.impact_analysis(graph, params)?;
1813
1814 let stdout = std::io::stdout();
1815 let mut out = stdout.lock();
1816
1817 match cli.format {
1818 OutputFormat::Text => {
1819 let root_name = graph
1820 .get_unit(uid)
1821 .map(|u| u.qualified_name.as_str())
1822 .unwrap_or("?");
1823
1824 let risk_label = if result.overall_risk >= 0.7 {
1825 s.red("HIGH")
1826 } else if result.overall_risk >= 0.4 {
1827 s.yellow("MEDIUM")
1828 } else {
1829 s.green("LOW")
1830 };
1831
1832 let _ = writeln!(
1833 out,
1834 "\n Impact analysis for {} (risk: {})\n",
1835 s.bold(root_name),
1836 risk_label,
1837 );
1838 let _ = writeln!(
1839 out,
1840 " {} impacted units, overall risk {:.2}\n",
1841 result.impacted.len(),
1842 result.overall_risk
1843 );
1844 for imp in &result.impacted {
1845 let unit_name = graph
1846 .get_unit(imp.unit_id)
1847 .map(|u| u.qualified_name.as_str())
1848 .unwrap_or("?");
1849 let risk_sym = if imp.risk_score >= 0.7 {
1850 s.fail()
1851 } else if imp.risk_score >= 0.4 {
1852 s.warn()
1853 } else {
1854 s.ok()
1855 };
1856 let test_badge = if imp.has_tests {
1857 s.green("tested")
1858 } else {
1859 s.red("untested")
1860 };
1861 let _ = writeln!(
1862 out,
1863 " {} {} {} risk:{:.2} {}",
1864 risk_sym,
1865 s.cyan(unit_name),
1866 s.dim(&format!("(depth {})", imp.depth)),
1867 imp.risk_score,
1868 test_badge,
1869 );
1870 }
1871 if !result.recommendations.is_empty() {
1872 let _ = writeln!(out);
1873 for rec in &result.recommendations {
1874 let _ = writeln!(out, " {} {}", s.info(), rec);
1875 }
1876 }
1877 let _ = writeln!(out);
1878 }
1879 OutputFormat::Json => {
1880 let entries: Vec<serde_json::Value> = result
1881 .impacted
1882 .iter()
1883 .map(|imp| {
1884 serde_json::json!({
1885 "unit_id": imp.unit_id,
1886 "depth": imp.depth,
1887 "risk_score": imp.risk_score,
1888 "has_tests": imp.has_tests,
1889 })
1890 })
1891 .collect();
1892 let obj = serde_json::json!({
1893 "query": "impact",
1894 "root_id": uid,
1895 "count": result.impacted.len(),
1896 "overall_risk": result.overall_risk,
1897 "results": entries,
1898 "recommendations": result.recommendations,
1899 });
1900 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1901 }
1902 }
1903 Ok(())
1904}
1905
1906fn query_calls(
1907 graph: &CodeGraph,
1908 engine: &QueryEngine,
1909 unit_id: Option<u64>,
1910 depth: u32,
1911 cli: &Cli,
1912 s: &Styled,
1913) -> Result<(), Box<dyn std::error::Error>> {
1914 let uid =
1915 unit_id.ok_or_else(|| format!("{} --unit-id is required for calls queries", s.fail()))?;
1916 let params = CallGraphParams {
1917 unit_id: uid,
1918 direction: CallDirection::Both,
1919 max_depth: depth,
1920 };
1921 let result = engine.call_graph(graph, params)?;
1922
1923 let stdout = std::io::stdout();
1924 let mut out = stdout.lock();
1925
1926 match cli.format {
1927 OutputFormat::Text => {
1928 let root_name = graph
1929 .get_unit(uid)
1930 .map(|u| u.qualified_name.as_str())
1931 .unwrap_or("?");
1932 let _ = writeln!(
1933 out,
1934 "\n Call graph for {} ({} nodes)\n",
1935 s.bold(root_name),
1936 result.nodes.len()
1937 );
1938 for (nid, d) in &result.nodes {
1939 let unit_name = graph
1940 .get_unit(*nid)
1941 .map(|u| u.qualified_name.as_str())
1942 .unwrap_or("?");
1943 let indent = " ".repeat(*d as usize);
1944 let _ = writeln!(out, " {}{} {}", indent, s.arrow(), s.cyan(unit_name),);
1945 }
1946 let _ = writeln!(out);
1947 }
1948 OutputFormat::Json => {
1949 let entries: Vec<serde_json::Value> = result
1950 .nodes
1951 .iter()
1952 .map(|(nid, d)| {
1953 let unit_name = graph
1954 .get_unit(*nid)
1955 .map(|u| u.qualified_name.clone())
1956 .unwrap_or_default();
1957 serde_json::json!({
1958 "unit_id": nid,
1959 "name": unit_name,
1960 "depth": d,
1961 })
1962 })
1963 .collect();
1964 let obj = serde_json::json!({
1965 "query": "calls",
1966 "root_id": uid,
1967 "count": result.nodes.len(),
1968 "results": entries,
1969 });
1970 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1971 }
1972 }
1973 Ok(())
1974}
1975
1976fn query_similar(
1977 graph: &CodeGraph,
1978 engine: &QueryEngine,
1979 unit_id: Option<u64>,
1980 limit: usize,
1981 cli: &Cli,
1982 s: &Styled,
1983) -> Result<(), Box<dyn std::error::Error>> {
1984 let uid =
1985 unit_id.ok_or_else(|| format!("{} --unit-id is required for similar queries", s.fail()))?;
1986 let params = SimilarityParams {
1987 unit_id: uid,
1988 top_k: limit,
1989 min_similarity: 0.0,
1990 };
1991 let results = engine.similarity(graph, params)?;
1992
1993 let stdout = std::io::stdout();
1994 let mut out = stdout.lock();
1995
1996 match cli.format {
1997 OutputFormat::Text => {
1998 let root_name = graph
1999 .get_unit(uid)
2000 .map(|u| u.qualified_name.as_str())
2001 .unwrap_or("?");
2002 let _ = writeln!(
2003 out,
2004 "\n Similar to {} ({} matches)\n",
2005 s.bold(root_name),
2006 results.len()
2007 );
2008 for (i, m) in results.iter().enumerate() {
2009 let unit_name = graph
2010 .get_unit(m.unit_id)
2011 .map(|u| u.qualified_name.as_str())
2012 .unwrap_or("?");
2013 let score_str = format!("{:.2}%", m.score * 100.0);
2014 let _ = writeln!(
2015 out,
2016 " {:>3}. {} {} {}",
2017 s.dim(&format!("#{}", i + 1)),
2018 s.cyan(unit_name),
2019 s.dim(&format!("[id:{}]", m.unit_id)),
2020 s.yellow(&score_str),
2021 );
2022 }
2023 let _ = writeln!(out);
2024 }
2025 OutputFormat::Json => {
2026 let entries: Vec<serde_json::Value> = results
2027 .iter()
2028 .map(|m| {
2029 serde_json::json!({
2030 "unit_id": m.unit_id,
2031 "score": m.score,
2032 })
2033 })
2034 .collect();
2035 let obj = serde_json::json!({
2036 "query": "similar",
2037 "root_id": uid,
2038 "count": results.len(),
2039 "results": entries,
2040 });
2041 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2042 }
2043 }
2044 Ok(())
2045}
2046
2047fn query_prophecy(
2048 graph: &CodeGraph,
2049 engine: &QueryEngine,
2050 limit: usize,
2051 cli: &Cli,
2052 s: &Styled,
2053) -> Result<(), Box<dyn std::error::Error>> {
2054 let params = ProphecyParams {
2055 top_k: limit,
2056 min_risk: 0.0,
2057 };
2058 let result = engine.prophecy(graph, params)?;
2059
2060 let stdout = std::io::stdout();
2061 let mut out = stdout.lock();
2062
2063 match cli.format {
2064 OutputFormat::Text => {
2065 let _ = writeln!(
2066 out,
2067 "\n {} Code prophecy ({} predictions)\n",
2068 s.info(),
2069 result.predictions.len()
2070 );
2071 if result.predictions.is_empty() {
2072 let _ = writeln!(
2073 out,
2074 " {} No high-risk predictions. Codebase looks stable!",
2075 s.ok()
2076 );
2077 }
2078 for pred in &result.predictions {
2079 let unit_name = graph
2080 .get_unit(pred.unit_id)
2081 .map(|u| u.qualified_name.as_str())
2082 .unwrap_or("?");
2083 let risk_sym = if pred.risk_score >= 0.7 {
2084 s.fail()
2085 } else if pred.risk_score >= 0.4 {
2086 s.warn()
2087 } else {
2088 s.ok()
2089 };
2090 let _ = writeln!(
2091 out,
2092 " {} {} {}: {}",
2093 risk_sym,
2094 s.cyan(unit_name),
2095 s.dim(&format!("(risk {:.2})", pred.risk_score)),
2096 pred.reason,
2097 );
2098 }
2099 let _ = writeln!(out);
2100 }
2101 OutputFormat::Json => {
2102 let entries: Vec<serde_json::Value> = result
2103 .predictions
2104 .iter()
2105 .map(|p| {
2106 serde_json::json!({
2107 "unit_id": p.unit_id,
2108 "risk_score": p.risk_score,
2109 "reason": p.reason,
2110 })
2111 })
2112 .collect();
2113 let obj = serde_json::json!({
2114 "query": "prophecy",
2115 "count": result.predictions.len(),
2116 "results": entries,
2117 });
2118 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2119 }
2120 }
2121 Ok(())
2122}
2123
2124fn query_stability(
2125 graph: &CodeGraph,
2126 engine: &QueryEngine,
2127 unit_id: Option<u64>,
2128 cli: &Cli,
2129 s: &Styled,
2130) -> Result<(), Box<dyn std::error::Error>> {
2131 let uid = unit_id
2132 .ok_or_else(|| format!("{} --unit-id is required for stability queries", s.fail()))?;
2133 let result: StabilityResult = engine.stability_analysis(graph, uid)?;
2134
2135 let stdout = std::io::stdout();
2136 let mut out = stdout.lock();
2137
2138 match cli.format {
2139 OutputFormat::Text => {
2140 let root_name = graph
2141 .get_unit(uid)
2142 .map(|u| u.qualified_name.as_str())
2143 .unwrap_or("?");
2144
2145 let score_color = if result.overall_score >= 0.7 {
2146 s.green(&format!("{:.2}", result.overall_score))
2147 } else if result.overall_score >= 0.4 {
2148 s.yellow(&format!("{:.2}", result.overall_score))
2149 } else {
2150 s.red(&format!("{:.2}", result.overall_score))
2151 };
2152
2153 let _ = writeln!(
2154 out,
2155 "\n Stability of {}: {}\n",
2156 s.bold(root_name),
2157 score_color,
2158 );
2159 for factor in &result.factors {
2160 let _ = writeln!(
2161 out,
2162 " {} {} = {:.2}: {}",
2163 s.arrow(),
2164 s.bold(&factor.name),
2165 factor.value,
2166 s.dim(&factor.description),
2167 );
2168 }
2169 let _ = writeln!(out, "\n {} {}", s.info(), result.recommendation);
2170 let _ = writeln!(out);
2171 }
2172 OutputFormat::Json => {
2173 let factors: Vec<serde_json::Value> = result
2174 .factors
2175 .iter()
2176 .map(|f| {
2177 serde_json::json!({
2178 "name": f.name,
2179 "value": f.value,
2180 "description": f.description,
2181 })
2182 })
2183 .collect();
2184 let obj = serde_json::json!({
2185 "query": "stability",
2186 "unit_id": uid,
2187 "overall_score": result.overall_score,
2188 "factors": factors,
2189 "recommendation": result.recommendation,
2190 });
2191 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2192 }
2193 }
2194 Ok(())
2195}
2196
2197fn query_coupling(
2198 graph: &CodeGraph,
2199 engine: &QueryEngine,
2200 unit_id: Option<u64>,
2201 cli: &Cli,
2202 s: &Styled,
2203) -> Result<(), Box<dyn std::error::Error>> {
2204 let params = CouplingParams {
2205 unit_id,
2206 min_strength: 0.0,
2207 };
2208 let results = engine.coupling_detection(graph, params)?;
2209
2210 let stdout = std::io::stdout();
2211 let mut out = stdout.lock();
2212
2213 match cli.format {
2214 OutputFormat::Text => {
2215 let _ = writeln!(
2216 out,
2217 "\n Coupling analysis ({} pairs detected)\n",
2218 results.len()
2219 );
2220 if results.is_empty() {
2221 let _ = writeln!(out, " {} No tightly coupled pairs detected.", s.ok());
2222 }
2223 for c in &results {
2224 let name_a = graph
2225 .get_unit(c.unit_a)
2226 .map(|u| u.qualified_name.as_str())
2227 .unwrap_or("?");
2228 let name_b = graph
2229 .get_unit(c.unit_b)
2230 .map(|u| u.qualified_name.as_str())
2231 .unwrap_or("?");
2232 let strength_str = format!("{:.0}%", c.strength * 100.0);
2233 let _ = writeln!(
2234 out,
2235 " {} {} {} {} {}",
2236 s.warn(),
2237 s.cyan(name_a),
2238 s.dim("<->"),
2239 s.cyan(name_b),
2240 s.yellow(&strength_str),
2241 );
2242 }
2243 let _ = writeln!(out);
2244 }
2245 OutputFormat::Json => {
2246 let entries: Vec<serde_json::Value> = results
2247 .iter()
2248 .map(|c| {
2249 serde_json::json!({
2250 "unit_a": c.unit_a,
2251 "unit_b": c.unit_b,
2252 "strength": c.strength,
2253 "kind": format!("{:?}", c.kind),
2254 })
2255 })
2256 .collect();
2257 let obj = serde_json::json!({
2258 "query": "coupling",
2259 "count": results.len(),
2260 "results": entries,
2261 });
2262 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2263 }
2264 }
2265 Ok(())
2266}
2267
2268fn query_test_gap(
2269 graph: &CodeGraph,
2270 engine: &QueryEngine,
2271 limit: usize,
2272 cli: &Cli,
2273 s: &Styled,
2274) -> Result<(), Box<dyn std::error::Error>> {
2275 let mut gaps = engine.test_gap(
2276 graph,
2277 TestGapParams {
2278 min_changes: 5,
2279 min_complexity: 10,
2280 unit_types: vec![],
2281 },
2282 )?;
2283 if limit > 0 {
2284 gaps.truncate(limit);
2285 }
2286
2287 let stdout = std::io::stdout();
2288 let mut out = stdout.lock();
2289 match cli.format {
2290 OutputFormat::Text => {
2291 let _ = writeln!(out, "\n Test gaps ({} results)\n", gaps.len());
2292 for g in &gaps {
2293 let name = graph
2294 .get_unit(g.unit_id)
2295 .map(|u| u.qualified_name.as_str())
2296 .unwrap_or("?");
2297 let _ = writeln!(
2298 out,
2299 " {} {} priority:{:.2} {}",
2300 s.arrow(),
2301 s.cyan(name),
2302 g.priority,
2303 s.dim(&g.reason)
2304 );
2305 }
2306 let _ = writeln!(out);
2307 }
2308 OutputFormat::Json => {
2309 let rows = gaps
2310 .iter()
2311 .map(|g| {
2312 serde_json::json!({
2313 "unit_id": g.unit_id,
2314 "name": graph.get_unit(g.unit_id).map(|u| u.qualified_name.clone()).unwrap_or_default(),
2315 "priority": g.priority,
2316 "reason": g.reason,
2317 })
2318 })
2319 .collect::<Vec<_>>();
2320 let obj = serde_json::json!({
2321 "query": "test-gap",
2322 "count": rows.len(),
2323 "results": rows,
2324 });
2325 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2326 }
2327 }
2328 Ok(())
2329}
2330
2331fn query_hotspots(
2332 graph: &CodeGraph,
2333 engine: &QueryEngine,
2334 limit: usize,
2335 cli: &Cli,
2336 s: &Styled,
2337) -> Result<(), Box<dyn std::error::Error>> {
2338 let hotspots = engine.hotspot_detection(
2339 graph,
2340 HotspotParams {
2341 top_k: limit,
2342 min_score: 0.55,
2343 unit_types: vec![],
2344 },
2345 )?;
2346
2347 let stdout = std::io::stdout();
2348 let mut out = stdout.lock();
2349 match cli.format {
2350 OutputFormat::Text => {
2351 let _ = writeln!(out, "\n Hotspots ({} results)\n", hotspots.len());
2352 for h in &hotspots {
2353 let name = graph
2354 .get_unit(h.unit_id)
2355 .map(|u| u.qualified_name.as_str())
2356 .unwrap_or("?");
2357 let _ = writeln!(out, " {} {} score:{:.2}", s.arrow(), s.cyan(name), h.score);
2358 }
2359 let _ = writeln!(out);
2360 }
2361 OutputFormat::Json => {
2362 let rows = hotspots
2363 .iter()
2364 .map(|h| {
2365 serde_json::json!({
2366 "unit_id": h.unit_id,
2367 "name": graph.get_unit(h.unit_id).map(|u| u.qualified_name.clone()).unwrap_or_default(),
2368 "score": h.score,
2369 "factors": h.factors,
2370 })
2371 })
2372 .collect::<Vec<_>>();
2373 let obj = serde_json::json!({
2374 "query": "hotspots",
2375 "count": rows.len(),
2376 "results": rows,
2377 });
2378 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2379 }
2380 }
2381 Ok(())
2382}
2383
2384fn query_dead_code(
2385 graph: &CodeGraph,
2386 engine: &QueryEngine,
2387 limit: usize,
2388 cli: &Cli,
2389 s: &Styled,
2390) -> Result<(), Box<dyn std::error::Error>> {
2391 let mut dead = engine.dead_code(
2392 graph,
2393 DeadCodeParams {
2394 unit_types: vec![],
2395 include_tests_as_roots: true,
2396 },
2397 )?;
2398 if limit > 0 {
2399 dead.truncate(limit);
2400 }
2401
2402 let stdout = std::io::stdout();
2403 let mut out = stdout.lock();
2404 match cli.format {
2405 OutputFormat::Text => {
2406 let _ = writeln!(out, "\n Dead code ({} results)\n", dead.len());
2407 for unit in &dead {
2408 let _ = writeln!(
2409 out,
2410 " {} {} {}",
2411 s.arrow(),
2412 s.cyan(&unit.qualified_name),
2413 s.dim(&format!("({})", unit.unit_type.label()))
2414 );
2415 }
2416 let _ = writeln!(out);
2417 }
2418 OutputFormat::Json => {
2419 let rows = dead
2420 .iter()
2421 .map(|u| {
2422 serde_json::json!({
2423 "unit_id": u.id,
2424 "name": u.qualified_name,
2425 "unit_type": u.unit_type.label(),
2426 "file": u.file_path.display().to_string(),
2427 "line": u.span.start_line,
2428 })
2429 })
2430 .collect::<Vec<_>>();
2431 let obj = serde_json::json!({
2432 "query": "dead-code",
2433 "count": rows.len(),
2434 "results": rows,
2435 });
2436 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2437 }
2438 }
2439 Ok(())
2440}
2441
2442fn cmd_get(file: &Path, unit_id: u64, cli: &Cli) -> Result<(), Box<dyn std::error::Error>> {
2447 let s = styled(cli);
2448 validate_acb_path(file)?;
2449 let graph = AcbReader::read_from_file(file)?;
2450
2451 let unit = graph.get_unit(unit_id).ok_or_else(|| {
2452 format!(
2453 "{} Unit {} not found\n {} Use 'acb query ... symbol' to find valid unit IDs",
2454 s.fail(),
2455 unit_id,
2456 s.info()
2457 )
2458 })?;
2459
2460 let outgoing = graph.edges_from(unit_id);
2461 let incoming = graph.edges_to(unit_id);
2462
2463 let stdout = std::io::stdout();
2464 let mut out = stdout.lock();
2465
2466 match cli.format {
2467 OutputFormat::Text => {
2468 let _ = writeln!(
2469 out,
2470 "\n {} {}",
2471 s.info(),
2472 s.bold(&format!("Unit {}", unit.id))
2473 );
2474 let _ = writeln!(out, " Name: {}", s.cyan(&unit.name));
2475 let _ = writeln!(out, " Qualified name: {}", s.bold(&unit.qualified_name));
2476 let _ = writeln!(out, " Type: {}", unit.unit_type);
2477 let _ = writeln!(out, " Language: {}", unit.language);
2478 let _ = writeln!(
2479 out,
2480 " File: {}",
2481 s.cyan(&unit.file_path.display().to_string())
2482 );
2483 let _ = writeln!(out, " Span: {}", unit.span);
2484 let _ = writeln!(out, " Visibility: {}", unit.visibility);
2485 let _ = writeln!(out, " Complexity: {}", unit.complexity);
2486 if unit.is_async {
2487 let _ = writeln!(out, " Async: {}", s.green("yes"));
2488 }
2489 if unit.is_generator {
2490 let _ = writeln!(out, " Generator: {}", s.green("yes"));
2491 }
2492
2493 let stability_str = format!("{:.2}", unit.stability_score);
2494 let stability_color = if unit.stability_score >= 0.7 {
2495 s.green(&stability_str)
2496 } else if unit.stability_score >= 0.4 {
2497 s.yellow(&stability_str)
2498 } else {
2499 s.red(&stability_str)
2500 };
2501 let _ = writeln!(out, " Stability: {}", stability_color);
2502
2503 if let Some(sig) = &unit.signature {
2504 let _ = writeln!(out, " Signature: {}", s.dim(sig));
2505 }
2506 if let Some(doc) = &unit.doc_summary {
2507 let _ = writeln!(out, " Doc: {}", s.dim(doc));
2508 }
2509
2510 if !outgoing.is_empty() {
2511 let _ = writeln!(
2512 out,
2513 "\n {} Outgoing edges ({})",
2514 s.arrow(),
2515 outgoing.len()
2516 );
2517 for edge in &outgoing {
2518 let target_name = graph
2519 .get_unit(edge.target_id)
2520 .map(|u| u.qualified_name.as_str())
2521 .unwrap_or("?");
2522 let _ = writeln!(
2523 out,
2524 " {} {} {}",
2525 s.arrow(),
2526 s.cyan(target_name),
2527 s.dim(&format!("({})", edge.edge_type))
2528 );
2529 }
2530 }
2531 if !incoming.is_empty() {
2532 let _ = writeln!(
2533 out,
2534 "\n {} Incoming edges ({})",
2535 s.arrow(),
2536 incoming.len()
2537 );
2538 for edge in &incoming {
2539 let source_name = graph
2540 .get_unit(edge.source_id)
2541 .map(|u| u.qualified_name.as_str())
2542 .unwrap_or("?");
2543 let _ = writeln!(
2544 out,
2545 " {} {} {}",
2546 s.arrow(),
2547 s.cyan(source_name),
2548 s.dim(&format!("({})", edge.edge_type))
2549 );
2550 }
2551 }
2552 let _ = writeln!(out);
2553 }
2554 OutputFormat::Json => {
2555 let out_edges: Vec<serde_json::Value> = outgoing
2556 .iter()
2557 .map(|e| {
2558 serde_json::json!({
2559 "target_id": e.target_id,
2560 "edge_type": e.edge_type.label(),
2561 "weight": e.weight,
2562 })
2563 })
2564 .collect();
2565 let in_edges: Vec<serde_json::Value> = incoming
2566 .iter()
2567 .map(|e| {
2568 serde_json::json!({
2569 "source_id": e.source_id,
2570 "edge_type": e.edge_type.label(),
2571 "weight": e.weight,
2572 })
2573 })
2574 .collect();
2575 let obj = serde_json::json!({
2576 "id": unit.id,
2577 "name": unit.name,
2578 "qualified_name": unit.qualified_name,
2579 "unit_type": unit.unit_type.label(),
2580 "language": unit.language.name(),
2581 "file": unit.file_path.display().to_string(),
2582 "span": unit.span.to_string(),
2583 "visibility": unit.visibility.to_string(),
2584 "complexity": unit.complexity,
2585 "is_async": unit.is_async,
2586 "is_generator": unit.is_generator,
2587 "stability_score": unit.stability_score,
2588 "signature": unit.signature,
2589 "doc_summary": unit.doc_summary,
2590 "outgoing_edges": out_edges,
2591 "incoming_edges": in_edges,
2592 });
2593 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2594 }
2595 }
2596
2597 Ok(())
2598}