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, DependencyParams, ImpactParams, MatchMode,
16 ProphecyParams, QueryEngine, SimilarityParams, StabilityResult, SymbolLookupParams,
17};
18use crate::format::{AcbReader, AcbWriter};
19use crate::graph::CodeGraph;
20use crate::parse::parser::{ParseOptions, Parser as AcbParser};
21use crate::semantic::analyzer::{AnalyzeOptions, SemanticAnalyzer};
22use crate::types::FileHeader;
23
24#[derive(Parser)]
30#[command(
31 name = "acb",
32 about = "AgenticCodebase \u{2014} Semantic code compiler for AI agents",
33 long_about = "AgenticCodebase compiles multi-language codebases into navigable concept \
34 graphs that AI agents can query. Supports Python, Rust, TypeScript, and Go.\n\n\
35 Quick start:\n\
36 \x20 acb compile ./my-project # build a graph\n\
37 \x20 acb info my-project.acb # inspect the graph\n\
38 \x20 acb query my-project.acb symbol --name UserService\n\
39 \x20 acb query my-project.acb impact --unit-id 42\n\n\
40 For AI agent integration, use the companion MCP server: acb-mcp",
41 after_help = "Run 'acb <command> --help' for details on a specific command.\n\
42 Set ACB_LOG=debug for verbose tracing. Set NO_COLOR=1 to disable colors.",
43 version
44)]
45pub struct Cli {
46 #[command(subcommand)]
47 pub command: Option<Command>,
48
49 #[arg(long, short = 'f', default_value = "text", global = true)]
51 pub format: OutputFormat,
52
53 #[arg(long, short = 'v', global = true)]
55 pub verbose: bool,
56
57 #[arg(long, short = 'q', global = true)]
59 pub quiet: bool,
60}
61
62#[derive(Clone, ValueEnum)]
64pub enum OutputFormat {
65 Text,
67 Json,
69}
70
71#[derive(Subcommand)]
73pub enum Command {
74 #[command(alias = "build")]
85 Compile {
86 path: PathBuf,
88
89 #[arg(short, long)]
91 output: Option<PathBuf>,
92
93 #[arg(long, short = 'e')]
95 exclude: Vec<String>,
96
97 #[arg(long, default_value_t = true)]
99 include_tests: bool,
100 },
101
102 #[command(alias = "stat")]
111 Info {
112 file: PathBuf,
114 },
115
116 #[command(alias = "q")]
135 Query {
136 file: PathBuf,
138
139 query_type: String,
142
143 #[arg(long, short = 'n')]
145 name: Option<String>,
146
147 #[arg(long, short = 'u')]
149 unit_id: Option<u64>,
150
151 #[arg(long, short = 'd', default_value_t = 3)]
153 depth: u32,
154
155 #[arg(long, short = 'l', default_value_t = 20)]
157 limit: usize,
158 },
159
160 Get {
169 file: PathBuf,
171
172 unit_id: u64,
174 },
175
176 Completions {
186 shell: Shell,
188 },
189}
190
191pub fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
199 let command_name = match &cli.command {
200 None => "repl",
201 Some(Command::Compile { .. }) => "compile",
202 Some(Command::Info { .. }) => "info",
203 Some(Command::Query { .. }) => "query",
204 Some(Command::Get { .. }) => "get",
205 Some(Command::Completions { .. }) => "completions",
206 };
207 let started = Instant::now();
208 let result = match &cli.command {
209 None => crate::cli::repl::run(),
211
212 Some(Command::Compile {
213 path,
214 output,
215 exclude,
216 include_tests,
217 }) => cmd_compile(path, output.as_deref(), exclude, *include_tests, &cli),
218 Some(Command::Info { file }) => cmd_info(file, &cli),
219 Some(Command::Query {
220 file,
221 query_type,
222 name,
223 unit_id,
224 depth,
225 limit,
226 }) => cmd_query(
227 file,
228 query_type,
229 name.as_deref(),
230 *unit_id,
231 *depth,
232 *limit,
233 &cli,
234 ),
235 Some(Command::Get { file, unit_id }) => cmd_get(file, *unit_id, &cli),
236 Some(Command::Completions { shell }) => {
237 let mut cmd = Cli::command();
238 clap_complete::generate(*shell, &mut cmd, "acb", &mut std::io::stdout());
239 Ok(())
240 }
241 };
242
243 emit_cli_health_ledger(command_name, started.elapsed(), result.is_ok());
244 result
245}
246
247fn emit_cli_health_ledger(command: &str, duration: std::time::Duration, ok: bool) {
252 let dir = resolve_health_ledger_dir();
253 if std::fs::create_dir_all(&dir).is_err() {
254 return;
255 }
256 let path = dir.join("agentic-codebase-cli.json");
257 let tmp = dir.join("agentic-codebase-cli.json.tmp");
258 let profile = read_env_string("ACB_AUTONOMIC_PROFILE").unwrap_or_else(|| "desktop".to_string());
259 let payload = serde_json::json!({
260 "project": "AgenticCodebase",
261 "surface": "cli",
262 "timestamp": chrono::Utc::now().to_rfc3339(),
263 "status": if ok { "ok" } else { "error" },
264 "autonomic": {
265 "profile": profile.to_ascii_lowercase(),
266 "command": command,
267 "duration_ms": duration.as_millis(),
268 }
269 });
270 let Ok(bytes) = serde_json::to_vec_pretty(&payload) else {
271 return;
272 };
273 if std::fs::write(&tmp, bytes).is_err() {
274 return;
275 }
276 let _ = std::fs::rename(&tmp, &path);
277}
278
279fn resolve_health_ledger_dir() -> PathBuf {
280 if let Some(custom) = read_env_string("ACB_HEALTH_LEDGER_DIR") {
281 if !custom.is_empty() {
282 return PathBuf::from(custom);
283 }
284 }
285 if let Some(custom) = read_env_string("AGENTRA_HEALTH_LEDGER_DIR") {
286 if !custom.is_empty() {
287 return PathBuf::from(custom);
288 }
289 }
290 let home = std::env::var("HOME")
291 .ok()
292 .map(PathBuf::from)
293 .unwrap_or_else(|| PathBuf::from("."));
294 home.join(".agentra").join("health-ledger")
295}
296
297fn styled(cli: &Cli) -> Styled {
299 match cli.format {
300 OutputFormat::Json => Styled::plain(),
301 OutputFormat::Text => Styled::auto(),
302 }
303}
304
305fn validate_acb_path(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
307 let s = Styled::auto();
308 if !path.exists() {
309 return Err(format!(
310 "{} File not found: {}\n {} Check the path and try again",
311 s.fail(),
312 path.display(),
313 s.info()
314 )
315 .into());
316 }
317 if !path.is_file() {
318 return Err(format!(
319 "{} Not a file: {}\n {} Provide a path to an .acb file, not a directory",
320 s.fail(),
321 path.display(),
322 s.info()
323 )
324 .into());
325 }
326 if path.extension().and_then(|e| e.to_str()) != Some("acb") {
327 return Err(format!(
328 "{} Expected .acb file, got: {}\n {} Compile a repository first: acb compile <dir>",
329 s.fail(),
330 path.display(),
331 s.info()
332 )
333 .into());
334 }
335 Ok(())
336}
337
338fn cmd_compile(
343 path: &Path,
344 output: Option<&std::path::Path>,
345 exclude: &[String],
346 include_tests: bool,
347 cli: &Cli,
348) -> Result<(), Box<dyn std::error::Error>> {
349 let s = styled(cli);
350
351 if !path.exists() {
352 return Err(format!(
353 "{} Path does not exist: {}\n {} Create the directory or check the path",
354 s.fail(),
355 path.display(),
356 s.info()
357 )
358 .into());
359 }
360 if !path.is_dir() {
361 return Err(format!(
362 "{} Path is not a directory: {}\n {} Provide the root directory of a source repository",
363 s.fail(),
364 path.display(),
365 s.info()
366 )
367 .into());
368 }
369
370 let out_path = match output {
371 Some(p) => p.to_path_buf(),
372 None => {
373 let dir_name = path
374 .file_name()
375 .map(|n| n.to_string_lossy().to_string())
376 .unwrap_or_else(|| "output".to_string());
377 PathBuf::from(format!("{}.acb", dir_name))
378 }
379 };
380
381 let mut opts = ParseOptions {
383 include_tests,
384 ..ParseOptions::default()
385 };
386 for pat in exclude {
387 opts.exclude.push(pat.clone());
388 }
389
390 if !cli.quiet {
391 if let OutputFormat::Text = cli.format {
392 eprintln!(
393 " {} Compiling {} {} {}",
394 s.info(),
395 s.bold(&path.display().to_string()),
396 s.arrow(),
397 s.cyan(&out_path.display().to_string()),
398 );
399 }
400 }
401
402 if cli.verbose {
404 eprintln!(" {} Parsing source files...", s.info());
405 }
406 let parser = AcbParser::new();
407 let parse_result = parser.parse_directory(path, &opts)?;
408
409 if !cli.quiet {
410 if let OutputFormat::Text = cli.format {
411 eprintln!(
412 " {} Parsed {} files ({} units found)",
413 s.ok(),
414 parse_result.stats.files_parsed,
415 parse_result.units.len(),
416 );
417 if !parse_result.errors.is_empty() {
418 eprintln!(
419 " {} {} parse errors (use --verbose to see details)",
420 s.warn(),
421 parse_result.errors.len()
422 );
423 }
424 }
425 }
426
427 if cli.verbose && !parse_result.errors.is_empty() {
428 for err in &parse_result.errors {
429 eprintln!(" {} {:?}", s.warn(), err);
430 }
431 }
432
433 if cli.verbose {
435 eprintln!(" {} Running semantic analysis...", s.info());
436 }
437 let unit_count = parse_result.units.len();
438 progress("Analyzing", 0, unit_count);
439 let analyzer = SemanticAnalyzer::new();
440 let analyze_opts = AnalyzeOptions::default();
441 let graph = analyzer.analyze(parse_result.units, &analyze_opts)?;
442 progress("Analyzing", unit_count, unit_count);
443 progress_done();
444
445 if cli.verbose {
446 eprintln!(
447 " {} Graph built: {} units, {} edges",
448 s.ok(),
449 graph.unit_count(),
450 graph.edge_count()
451 );
452 }
453
454 if cli.verbose {
456 eprintln!(" {} Writing binary format...", s.info());
457 }
458 let backup_path = maybe_backup_existing_output(&out_path)?;
459 if cli.verbose {
460 if let Some(backup) = &backup_path {
461 eprintln!(
462 " {} Backed up previous graph to {}",
463 s.info(),
464 s.dim(&backup.display().to_string())
465 );
466 }
467 }
468 let writer = AcbWriter::with_default_dimension();
469 writer.write_to_file(&graph, &out_path)?;
470
471 let file_size = std::fs::metadata(&out_path).map(|m| m.len()).unwrap_or(0);
473
474 let stdout = std::io::stdout();
475 let mut out = stdout.lock();
476
477 match cli.format {
478 OutputFormat::Text => {
479 if !cli.quiet {
480 let _ = writeln!(out);
481 let _ = writeln!(out, " {} Compiled successfully!", s.ok());
482 let _ = writeln!(
483 out,
484 " Units: {}",
485 s.bold(&graph.unit_count().to_string())
486 );
487 let _ = writeln!(
488 out,
489 " Edges: {}",
490 s.bold(&graph.edge_count().to_string())
491 );
492 let _ = writeln!(
493 out,
494 " Languages: {}",
495 s.bold(&graph.languages().len().to_string())
496 );
497 let _ = writeln!(out, " Size: {}", s.dim(&format_size(file_size)));
498 let _ = writeln!(out);
499 let _ = writeln!(
500 out,
501 " Next: {} or {}",
502 s.cyan(&format!("acb info {}", out_path.display())),
503 s.cyan(&format!(
504 "acb query {} symbol --name <search>",
505 out_path.display()
506 )),
507 );
508 }
509 }
510 OutputFormat::Json => {
511 let obj = serde_json::json!({
512 "status": "ok",
513 "source": path.display().to_string(),
514 "output": out_path.display().to_string(),
515 "units": graph.unit_count(),
516 "edges": graph.edge_count(),
517 "languages": graph.languages().len(),
518 "file_size_bytes": file_size,
519 });
520 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
521 }
522 }
523
524 Ok(())
525}
526
527fn maybe_backup_existing_output(
528 out_path: &Path,
529) -> Result<Option<PathBuf>, Box<dyn std::error::Error>> {
530 if !auto_backup_enabled() || !out_path.exists() || !out_path.is_file() {
531 return Ok(None);
532 }
533
534 let backups_dir = resolve_backup_dir(out_path);
535 std::fs::create_dir_all(&backups_dir)?;
536
537 let original_name = out_path
538 .file_name()
539 .and_then(|n| n.to_str())
540 .unwrap_or("graph.acb");
541 let ts = chrono::Utc::now().format("%Y%m%d%H%M%S");
542 let backup_path = backups_dir.join(format!("{original_name}.{ts}.bak"));
543 std::fs::copy(out_path, &backup_path)?;
544 prune_old_backups(&backups_dir, original_name, auto_backup_retention())?;
545
546 Ok(Some(backup_path))
547}
548
549fn auto_backup_enabled() -> bool {
550 match std::env::var("ACB_AUTO_BACKUP") {
551 Ok(v) => {
552 let value = v.trim().to_ascii_lowercase();
553 value != "0" && value != "false" && value != "off" && value != "no"
554 }
555 Err(_) => true,
556 }
557}
558
559fn auto_backup_retention() -> usize {
560 let default_retention = match read_env_string("ACB_AUTONOMIC_PROFILE")
561 .unwrap_or_else(|| "desktop".to_string())
562 .to_ascii_lowercase()
563 .as_str()
564 {
565 "cloud" => 40,
566 "aggressive" => 12,
567 _ => 20,
568 };
569 std::env::var("ACB_AUTO_BACKUP_RETENTION")
570 .ok()
571 .and_then(|v| v.parse::<usize>().ok())
572 .unwrap_or(default_retention)
573 .max(1)
574}
575
576fn resolve_backup_dir(out_path: &Path) -> PathBuf {
577 if let Ok(custom) = std::env::var("ACB_AUTO_BACKUP_DIR") {
578 let trimmed = custom.trim();
579 if !trimmed.is_empty() {
580 return PathBuf::from(trimmed);
581 }
582 }
583 out_path
584 .parent()
585 .unwrap_or_else(|| Path::new("."))
586 .join(".acb-backups")
587}
588
589fn read_env_string(name: &str) -> Option<String> {
590 std::env::var(name).ok().map(|v| v.trim().to_string())
591}
592
593fn prune_old_backups(
594 backup_dir: &Path,
595 original_name: &str,
596 retention: usize,
597) -> Result<(), Box<dyn std::error::Error>> {
598 let mut backups = std::fs::read_dir(backup_dir)?
599 .filter_map(Result::ok)
600 .filter(|entry| {
601 entry
602 .file_name()
603 .to_str()
604 .map(|name| name.starts_with(original_name) && name.ends_with(".bak"))
605 .unwrap_or(false)
606 })
607 .collect::<Vec<_>>();
608
609 if backups.len() <= retention {
610 return Ok(());
611 }
612
613 backups.sort_by_key(|entry| {
614 entry
615 .metadata()
616 .and_then(|m| m.modified())
617 .ok()
618 .unwrap_or(SystemTime::UNIX_EPOCH)
619 });
620
621 let to_remove = backups.len().saturating_sub(retention);
622 for entry in backups.into_iter().take(to_remove) {
623 let _ = std::fs::remove_file(entry.path());
624 }
625 Ok(())
626}
627
628fn cmd_info(file: &PathBuf, cli: &Cli) -> Result<(), Box<dyn std::error::Error>> {
633 let s = styled(cli);
634 validate_acb_path(file)?;
635 let graph = AcbReader::read_from_file(file)?;
636
637 let data = std::fs::read(file)?;
639 let header_bytes: [u8; 128] = data[..128]
640 .try_into()
641 .map_err(|_| "File too small for header")?;
642 let header = FileHeader::from_bytes(&header_bytes)?;
643 let file_size = data.len() as u64;
644
645 let stdout = std::io::stdout();
646 let mut out = stdout.lock();
647
648 match cli.format {
649 OutputFormat::Text => {
650 let _ = writeln!(
651 out,
652 "\n {} {}",
653 s.info(),
654 s.bold(&file.display().to_string())
655 );
656 let _ = writeln!(out, " Version: v{}", header.version);
657 let _ = writeln!(
658 out,
659 " Units: {}",
660 s.bold(&graph.unit_count().to_string())
661 );
662 let _ = writeln!(
663 out,
664 " Edges: {}",
665 s.bold(&graph.edge_count().to_string())
666 );
667 let _ = writeln!(
668 out,
669 " Languages: {}",
670 s.bold(&graph.languages().len().to_string())
671 );
672 let _ = writeln!(out, " Dimension: {}", header.dimension);
673 let _ = writeln!(out, " File size: {}", format_size(file_size));
674 let _ = writeln!(out);
675 for lang in graph.languages() {
676 let count = graph.units().iter().filter(|u| u.language == *lang).count();
677 let _ = writeln!(
678 out,
679 " {} {} {}",
680 s.arrow(),
681 s.cyan(&format!("{:12}", lang)),
682 s.dim(&format!("{} units", count))
683 );
684 }
685 let _ = writeln!(out);
686 }
687 OutputFormat::Json => {
688 let mut lang_map = serde_json::Map::new();
689 for lang in graph.languages() {
690 let count = graph.units().iter().filter(|u| u.language == *lang).count();
691 lang_map.insert(lang.to_string(), serde_json::json!(count));
692 }
693 let obj = serde_json::json!({
694 "file": file.display().to_string(),
695 "version": header.version,
696 "units": graph.unit_count(),
697 "edges": graph.edge_count(),
698 "languages": graph.languages().len(),
699 "dimension": header.dimension,
700 "file_size_bytes": file_size,
701 "language_breakdown": lang_map,
702 });
703 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
704 }
705 }
706
707 Ok(())
708}
709
710fn cmd_query(
715 file: &Path,
716 query_type: &str,
717 name: Option<&str>,
718 unit_id: Option<u64>,
719 depth: u32,
720 limit: usize,
721 cli: &Cli,
722) -> Result<(), Box<dyn std::error::Error>> {
723 validate_acb_path(file)?;
724 let graph = AcbReader::read_from_file(file)?;
725 let engine = QueryEngine::new();
726 let s = styled(cli);
727
728 match query_type {
729 "symbol" | "sym" | "s" => query_symbol(&graph, &engine, name, limit, cli, &s),
730 "deps" | "dep" | "d" => query_deps(&graph, &engine, unit_id, depth, cli, &s),
731 "rdeps" | "rdep" | "r" => query_rdeps(&graph, &engine, unit_id, depth, cli, &s),
732 "impact" | "imp" | "i" => query_impact(&graph, &engine, unit_id, depth, cli, &s),
733 "calls" | "call" | "c" => query_calls(&graph, &engine, unit_id, depth, cli, &s),
734 "similar" | "sim" => query_similar(&graph, &engine, unit_id, limit, cli, &s),
735 "prophecy" | "predict" | "p" => query_prophecy(&graph, &engine, limit, cli, &s),
736 "stability" | "stab" => query_stability(&graph, &engine, unit_id, cli, &s),
737 "coupling" | "couple" => query_coupling(&graph, &engine, unit_id, cli, &s),
738 other => {
739 let known = [
740 "symbol",
741 "deps",
742 "rdeps",
743 "impact",
744 "calls",
745 "similar",
746 "prophecy",
747 "stability",
748 "coupling",
749 ];
750 let suggestion = known
751 .iter()
752 .filter(|k| k.starts_with(&other[..1.min(other.len())]))
753 .copied()
754 .collect::<Vec<_>>();
755 let hint = if suggestion.is_empty() {
756 format!("Available: {}", known.join(", "))
757 } else {
758 format!("Did you mean: {}?", suggestion.join(", "))
759 };
760 Err(format!(
761 "{} Unknown query type: {}\n {} {}",
762 s.fail(),
763 other,
764 s.info(),
765 hint
766 )
767 .into())
768 }
769 }
770}
771
772fn query_symbol(
773 graph: &CodeGraph,
774 engine: &QueryEngine,
775 name: Option<&str>,
776 limit: usize,
777 cli: &Cli,
778 s: &Styled,
779) -> Result<(), Box<dyn std::error::Error>> {
780 let search_name = name.ok_or_else(|| {
781 format!(
782 "{} --name is required for symbol queries\n {} Example: acb query file.acb symbol --name UserService",
783 s.fail(),
784 s.info()
785 )
786 })?;
787 let params = SymbolLookupParams {
788 name: search_name.to_string(),
789 mode: MatchMode::Contains,
790 limit,
791 ..Default::default()
792 };
793 let results = engine.symbol_lookup(graph, params)?;
794
795 let stdout = std::io::stdout();
796 let mut out = stdout.lock();
797
798 match cli.format {
799 OutputFormat::Text => {
800 let _ = writeln!(
801 out,
802 "\n Symbol lookup: {} ({} results)\n",
803 s.bold(&format!("\"{}\"", search_name)),
804 results.len()
805 );
806 if results.is_empty() {
807 let _ = writeln!(
808 out,
809 " {} No matches found. Try a broader search term.",
810 s.warn()
811 );
812 }
813 for (i, unit) in results.iter().enumerate() {
814 let _ = writeln!(
815 out,
816 " {:>3}. {} {} {}",
817 s.dim(&format!("#{}", i + 1)),
818 s.bold(&unit.qualified_name),
819 s.dim(&format!("({})", unit.unit_type)),
820 s.dim(&format!(
821 "{}:{}",
822 unit.file_path.display(),
823 unit.span.start_line
824 ))
825 );
826 }
827 let _ = writeln!(out);
828 }
829 OutputFormat::Json => {
830 let entries: Vec<serde_json::Value> = results
831 .iter()
832 .map(|u| {
833 serde_json::json!({
834 "id": u.id,
835 "name": u.name,
836 "qualified_name": u.qualified_name,
837 "unit_type": u.unit_type.label(),
838 "language": u.language.name(),
839 "file": u.file_path.display().to_string(),
840 "line": u.span.start_line,
841 })
842 })
843 .collect();
844 let obj = serde_json::json!({
845 "query": "symbol",
846 "name": search_name,
847 "count": results.len(),
848 "results": entries,
849 });
850 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
851 }
852 }
853 Ok(())
854}
855
856fn query_deps(
857 graph: &CodeGraph,
858 engine: &QueryEngine,
859 unit_id: Option<u64>,
860 depth: u32,
861 cli: &Cli,
862 s: &Styled,
863) -> Result<(), Box<dyn std::error::Error>> {
864 let uid = unit_id.ok_or_else(|| {
865 format!(
866 "{} --unit-id is required for deps queries\n {} Find an ID first: acb query file.acb symbol --name <name>",
867 s.fail(), s.info()
868 )
869 })?;
870 let params = DependencyParams {
871 unit_id: uid,
872 max_depth: depth,
873 edge_types: vec![],
874 include_transitive: true,
875 };
876 let result = engine.dependency_graph(graph, params)?;
877
878 let stdout = std::io::stdout();
879 let mut out = stdout.lock();
880
881 match cli.format {
882 OutputFormat::Text => {
883 let root_name = graph
884 .get_unit(uid)
885 .map(|u| u.qualified_name.as_str())
886 .unwrap_or("?");
887 let _ = writeln!(
888 out,
889 "\n Dependencies of {} ({} found)\n",
890 s.bold(root_name),
891 result.nodes.len()
892 );
893 for node in &result.nodes {
894 let unit_name = graph
895 .get_unit(node.unit_id)
896 .map(|u| u.qualified_name.as_str())
897 .unwrap_or("?");
898 let indent = " ".repeat(node.depth as usize);
899 let _ = writeln!(
900 out,
901 " {}{} {} {}",
902 indent,
903 s.arrow(),
904 s.cyan(unit_name),
905 s.dim(&format!("[id:{}]", node.unit_id))
906 );
907 }
908 let _ = writeln!(out);
909 }
910 OutputFormat::Json => {
911 let entries: Vec<serde_json::Value> = result
912 .nodes
913 .iter()
914 .map(|n| {
915 let unit_name = graph
916 .get_unit(n.unit_id)
917 .map(|u| u.qualified_name.clone())
918 .unwrap_or_default();
919 serde_json::json!({
920 "unit_id": n.unit_id,
921 "name": unit_name,
922 "depth": n.depth,
923 })
924 })
925 .collect();
926 let obj = serde_json::json!({
927 "query": "deps",
928 "root_id": uid,
929 "count": result.nodes.len(),
930 "results": entries,
931 });
932 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
933 }
934 }
935 Ok(())
936}
937
938fn query_rdeps(
939 graph: &CodeGraph,
940 engine: &QueryEngine,
941 unit_id: Option<u64>,
942 depth: u32,
943 cli: &Cli,
944 s: &Styled,
945) -> Result<(), Box<dyn std::error::Error>> {
946 let uid = unit_id.ok_or_else(|| {
947 format!(
948 "{} --unit-id is required for rdeps queries\n {} Find an ID first: acb query file.acb symbol --name <name>",
949 s.fail(), s.info()
950 )
951 })?;
952 let params = DependencyParams {
953 unit_id: uid,
954 max_depth: depth,
955 edge_types: vec![],
956 include_transitive: true,
957 };
958 let result = engine.reverse_dependency(graph, params)?;
959
960 let stdout = std::io::stdout();
961 let mut out = stdout.lock();
962
963 match cli.format {
964 OutputFormat::Text => {
965 let root_name = graph
966 .get_unit(uid)
967 .map(|u| u.qualified_name.as_str())
968 .unwrap_or("?");
969 let _ = writeln!(
970 out,
971 "\n Reverse dependencies of {} ({} found)\n",
972 s.bold(root_name),
973 result.nodes.len()
974 );
975 for node in &result.nodes {
976 let unit_name = graph
977 .get_unit(node.unit_id)
978 .map(|u| u.qualified_name.as_str())
979 .unwrap_or("?");
980 let indent = " ".repeat(node.depth as usize);
981 let _ = writeln!(
982 out,
983 " {}{} {} {}",
984 indent,
985 s.arrow(),
986 s.cyan(unit_name),
987 s.dim(&format!("[id:{}]", node.unit_id))
988 );
989 }
990 let _ = writeln!(out);
991 }
992 OutputFormat::Json => {
993 let entries: Vec<serde_json::Value> = result
994 .nodes
995 .iter()
996 .map(|n| {
997 let unit_name = graph
998 .get_unit(n.unit_id)
999 .map(|u| u.qualified_name.clone())
1000 .unwrap_or_default();
1001 serde_json::json!({
1002 "unit_id": n.unit_id,
1003 "name": unit_name,
1004 "depth": n.depth,
1005 })
1006 })
1007 .collect();
1008 let obj = serde_json::json!({
1009 "query": "rdeps",
1010 "root_id": uid,
1011 "count": result.nodes.len(),
1012 "results": entries,
1013 });
1014 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1015 }
1016 }
1017 Ok(())
1018}
1019
1020fn query_impact(
1021 graph: &CodeGraph,
1022 engine: &QueryEngine,
1023 unit_id: Option<u64>,
1024 depth: u32,
1025 cli: &Cli,
1026 s: &Styled,
1027) -> Result<(), Box<dyn std::error::Error>> {
1028 let uid =
1029 unit_id.ok_or_else(|| format!("{} --unit-id is required for impact queries", s.fail()))?;
1030 let params = ImpactParams {
1031 unit_id: uid,
1032 max_depth: depth,
1033 edge_types: vec![],
1034 };
1035 let result = engine.impact_analysis(graph, params)?;
1036
1037 let stdout = std::io::stdout();
1038 let mut out = stdout.lock();
1039
1040 match cli.format {
1041 OutputFormat::Text => {
1042 let root_name = graph
1043 .get_unit(uid)
1044 .map(|u| u.qualified_name.as_str())
1045 .unwrap_or("?");
1046
1047 let risk_label = if result.overall_risk >= 0.7 {
1048 s.red("HIGH")
1049 } else if result.overall_risk >= 0.4 {
1050 s.yellow("MEDIUM")
1051 } else {
1052 s.green("LOW")
1053 };
1054
1055 let _ = writeln!(
1056 out,
1057 "\n Impact analysis for {} (risk: {})\n",
1058 s.bold(root_name),
1059 risk_label,
1060 );
1061 let _ = writeln!(
1062 out,
1063 " {} impacted units, overall risk {:.2}\n",
1064 result.impacted.len(),
1065 result.overall_risk
1066 );
1067 for imp in &result.impacted {
1068 let unit_name = graph
1069 .get_unit(imp.unit_id)
1070 .map(|u| u.qualified_name.as_str())
1071 .unwrap_or("?");
1072 let risk_sym = if imp.risk_score >= 0.7 {
1073 s.fail()
1074 } else if imp.risk_score >= 0.4 {
1075 s.warn()
1076 } else {
1077 s.ok()
1078 };
1079 let test_badge = if imp.has_tests {
1080 s.green("tested")
1081 } else {
1082 s.red("untested")
1083 };
1084 let _ = writeln!(
1085 out,
1086 " {} {} {} risk:{:.2} {}",
1087 risk_sym,
1088 s.cyan(unit_name),
1089 s.dim(&format!("(depth {})", imp.depth)),
1090 imp.risk_score,
1091 test_badge,
1092 );
1093 }
1094 if !result.recommendations.is_empty() {
1095 let _ = writeln!(out);
1096 for rec in &result.recommendations {
1097 let _ = writeln!(out, " {} {}", s.info(), rec);
1098 }
1099 }
1100 let _ = writeln!(out);
1101 }
1102 OutputFormat::Json => {
1103 let entries: Vec<serde_json::Value> = result
1104 .impacted
1105 .iter()
1106 .map(|imp| {
1107 serde_json::json!({
1108 "unit_id": imp.unit_id,
1109 "depth": imp.depth,
1110 "risk_score": imp.risk_score,
1111 "has_tests": imp.has_tests,
1112 })
1113 })
1114 .collect();
1115 let obj = serde_json::json!({
1116 "query": "impact",
1117 "root_id": uid,
1118 "count": result.impacted.len(),
1119 "overall_risk": result.overall_risk,
1120 "results": entries,
1121 "recommendations": result.recommendations,
1122 });
1123 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1124 }
1125 }
1126 Ok(())
1127}
1128
1129fn query_calls(
1130 graph: &CodeGraph,
1131 engine: &QueryEngine,
1132 unit_id: Option<u64>,
1133 depth: u32,
1134 cli: &Cli,
1135 s: &Styled,
1136) -> Result<(), Box<dyn std::error::Error>> {
1137 let uid =
1138 unit_id.ok_or_else(|| format!("{} --unit-id is required for calls queries", s.fail()))?;
1139 let params = CallGraphParams {
1140 unit_id: uid,
1141 direction: CallDirection::Both,
1142 max_depth: depth,
1143 };
1144 let result = engine.call_graph(graph, params)?;
1145
1146 let stdout = std::io::stdout();
1147 let mut out = stdout.lock();
1148
1149 match cli.format {
1150 OutputFormat::Text => {
1151 let root_name = graph
1152 .get_unit(uid)
1153 .map(|u| u.qualified_name.as_str())
1154 .unwrap_or("?");
1155 let _ = writeln!(
1156 out,
1157 "\n Call graph for {} ({} nodes)\n",
1158 s.bold(root_name),
1159 result.nodes.len()
1160 );
1161 for (nid, d) in &result.nodes {
1162 let unit_name = graph
1163 .get_unit(*nid)
1164 .map(|u| u.qualified_name.as_str())
1165 .unwrap_or("?");
1166 let indent = " ".repeat(*d as usize);
1167 let _ = writeln!(out, " {}{} {}", indent, s.arrow(), s.cyan(unit_name),);
1168 }
1169 let _ = writeln!(out);
1170 }
1171 OutputFormat::Json => {
1172 let entries: Vec<serde_json::Value> = result
1173 .nodes
1174 .iter()
1175 .map(|(nid, d)| {
1176 let unit_name = graph
1177 .get_unit(*nid)
1178 .map(|u| u.qualified_name.clone())
1179 .unwrap_or_default();
1180 serde_json::json!({
1181 "unit_id": nid,
1182 "name": unit_name,
1183 "depth": d,
1184 })
1185 })
1186 .collect();
1187 let obj = serde_json::json!({
1188 "query": "calls",
1189 "root_id": uid,
1190 "count": result.nodes.len(),
1191 "results": entries,
1192 });
1193 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1194 }
1195 }
1196 Ok(())
1197}
1198
1199fn query_similar(
1200 graph: &CodeGraph,
1201 engine: &QueryEngine,
1202 unit_id: Option<u64>,
1203 limit: usize,
1204 cli: &Cli,
1205 s: &Styled,
1206) -> Result<(), Box<dyn std::error::Error>> {
1207 let uid =
1208 unit_id.ok_or_else(|| format!("{} --unit-id is required for similar queries", s.fail()))?;
1209 let params = SimilarityParams {
1210 unit_id: uid,
1211 top_k: limit,
1212 min_similarity: 0.0,
1213 };
1214 let results = engine.similarity(graph, params)?;
1215
1216 let stdout = std::io::stdout();
1217 let mut out = stdout.lock();
1218
1219 match cli.format {
1220 OutputFormat::Text => {
1221 let root_name = graph
1222 .get_unit(uid)
1223 .map(|u| u.qualified_name.as_str())
1224 .unwrap_or("?");
1225 let _ = writeln!(
1226 out,
1227 "\n Similar to {} ({} matches)\n",
1228 s.bold(root_name),
1229 results.len()
1230 );
1231 for (i, m) in results.iter().enumerate() {
1232 let unit_name = graph
1233 .get_unit(m.unit_id)
1234 .map(|u| u.qualified_name.as_str())
1235 .unwrap_or("?");
1236 let score_str = format!("{:.2}%", m.score * 100.0);
1237 let _ = writeln!(
1238 out,
1239 " {:>3}. {} {} {}",
1240 s.dim(&format!("#{}", i + 1)),
1241 s.cyan(unit_name),
1242 s.dim(&format!("[id:{}]", m.unit_id)),
1243 s.yellow(&score_str),
1244 );
1245 }
1246 let _ = writeln!(out);
1247 }
1248 OutputFormat::Json => {
1249 let entries: Vec<serde_json::Value> = results
1250 .iter()
1251 .map(|m| {
1252 serde_json::json!({
1253 "unit_id": m.unit_id,
1254 "score": m.score,
1255 })
1256 })
1257 .collect();
1258 let obj = serde_json::json!({
1259 "query": "similar",
1260 "root_id": uid,
1261 "count": results.len(),
1262 "results": entries,
1263 });
1264 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1265 }
1266 }
1267 Ok(())
1268}
1269
1270fn query_prophecy(
1271 graph: &CodeGraph,
1272 engine: &QueryEngine,
1273 limit: usize,
1274 cli: &Cli,
1275 s: &Styled,
1276) -> Result<(), Box<dyn std::error::Error>> {
1277 let params = ProphecyParams {
1278 top_k: limit,
1279 min_risk: 0.0,
1280 };
1281 let result = engine.prophecy(graph, params)?;
1282
1283 let stdout = std::io::stdout();
1284 let mut out = stdout.lock();
1285
1286 match cli.format {
1287 OutputFormat::Text => {
1288 let _ = writeln!(
1289 out,
1290 "\n {} Code prophecy ({} predictions)\n",
1291 s.info(),
1292 result.predictions.len()
1293 );
1294 if result.predictions.is_empty() {
1295 let _ = writeln!(
1296 out,
1297 " {} No high-risk predictions. Codebase looks stable!",
1298 s.ok()
1299 );
1300 }
1301 for pred in &result.predictions {
1302 let unit_name = graph
1303 .get_unit(pred.unit_id)
1304 .map(|u| u.qualified_name.as_str())
1305 .unwrap_or("?");
1306 let risk_sym = if pred.risk_score >= 0.7 {
1307 s.fail()
1308 } else if pred.risk_score >= 0.4 {
1309 s.warn()
1310 } else {
1311 s.ok()
1312 };
1313 let _ = writeln!(
1314 out,
1315 " {} {} {}: {}",
1316 risk_sym,
1317 s.cyan(unit_name),
1318 s.dim(&format!("(risk {:.2})", pred.risk_score)),
1319 pred.reason,
1320 );
1321 }
1322 let _ = writeln!(out);
1323 }
1324 OutputFormat::Json => {
1325 let entries: Vec<serde_json::Value> = result
1326 .predictions
1327 .iter()
1328 .map(|p| {
1329 serde_json::json!({
1330 "unit_id": p.unit_id,
1331 "risk_score": p.risk_score,
1332 "reason": p.reason,
1333 })
1334 })
1335 .collect();
1336 let obj = serde_json::json!({
1337 "query": "prophecy",
1338 "count": result.predictions.len(),
1339 "results": entries,
1340 });
1341 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1342 }
1343 }
1344 Ok(())
1345}
1346
1347fn query_stability(
1348 graph: &CodeGraph,
1349 engine: &QueryEngine,
1350 unit_id: Option<u64>,
1351 cli: &Cli,
1352 s: &Styled,
1353) -> Result<(), Box<dyn std::error::Error>> {
1354 let uid = unit_id
1355 .ok_or_else(|| format!("{} --unit-id is required for stability queries", s.fail()))?;
1356 let result: StabilityResult = engine.stability_analysis(graph, uid)?;
1357
1358 let stdout = std::io::stdout();
1359 let mut out = stdout.lock();
1360
1361 match cli.format {
1362 OutputFormat::Text => {
1363 let root_name = graph
1364 .get_unit(uid)
1365 .map(|u| u.qualified_name.as_str())
1366 .unwrap_or("?");
1367
1368 let score_color = if result.overall_score >= 0.7 {
1369 s.green(&format!("{:.2}", result.overall_score))
1370 } else if result.overall_score >= 0.4 {
1371 s.yellow(&format!("{:.2}", result.overall_score))
1372 } else {
1373 s.red(&format!("{:.2}", result.overall_score))
1374 };
1375
1376 let _ = writeln!(
1377 out,
1378 "\n Stability of {}: {}\n",
1379 s.bold(root_name),
1380 score_color,
1381 );
1382 for factor in &result.factors {
1383 let _ = writeln!(
1384 out,
1385 " {} {} = {:.2}: {}",
1386 s.arrow(),
1387 s.bold(&factor.name),
1388 factor.value,
1389 s.dim(&factor.description),
1390 );
1391 }
1392 let _ = writeln!(out, "\n {} {}", s.info(), result.recommendation);
1393 let _ = writeln!(out);
1394 }
1395 OutputFormat::Json => {
1396 let factors: Vec<serde_json::Value> = result
1397 .factors
1398 .iter()
1399 .map(|f| {
1400 serde_json::json!({
1401 "name": f.name,
1402 "value": f.value,
1403 "description": f.description,
1404 })
1405 })
1406 .collect();
1407 let obj = serde_json::json!({
1408 "query": "stability",
1409 "unit_id": uid,
1410 "overall_score": result.overall_score,
1411 "factors": factors,
1412 "recommendation": result.recommendation,
1413 });
1414 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1415 }
1416 }
1417 Ok(())
1418}
1419
1420fn query_coupling(
1421 graph: &CodeGraph,
1422 engine: &QueryEngine,
1423 unit_id: Option<u64>,
1424 cli: &Cli,
1425 s: &Styled,
1426) -> Result<(), Box<dyn std::error::Error>> {
1427 let params = CouplingParams {
1428 unit_id,
1429 min_strength: 0.0,
1430 };
1431 let results = engine.coupling_detection(graph, params)?;
1432
1433 let stdout = std::io::stdout();
1434 let mut out = stdout.lock();
1435
1436 match cli.format {
1437 OutputFormat::Text => {
1438 let _ = writeln!(
1439 out,
1440 "\n Coupling analysis ({} pairs detected)\n",
1441 results.len()
1442 );
1443 if results.is_empty() {
1444 let _ = writeln!(out, " {} No tightly coupled pairs detected.", s.ok());
1445 }
1446 for c in &results {
1447 let name_a = graph
1448 .get_unit(c.unit_a)
1449 .map(|u| u.qualified_name.as_str())
1450 .unwrap_or("?");
1451 let name_b = graph
1452 .get_unit(c.unit_b)
1453 .map(|u| u.qualified_name.as_str())
1454 .unwrap_or("?");
1455 let strength_str = format!("{:.0}%", c.strength * 100.0);
1456 let _ = writeln!(
1457 out,
1458 " {} {} {} {} {}",
1459 s.warn(),
1460 s.cyan(name_a),
1461 s.dim("<->"),
1462 s.cyan(name_b),
1463 s.yellow(&strength_str),
1464 );
1465 }
1466 let _ = writeln!(out);
1467 }
1468 OutputFormat::Json => {
1469 let entries: Vec<serde_json::Value> = results
1470 .iter()
1471 .map(|c| {
1472 serde_json::json!({
1473 "unit_a": c.unit_a,
1474 "unit_b": c.unit_b,
1475 "strength": c.strength,
1476 "kind": format!("{:?}", c.kind),
1477 })
1478 })
1479 .collect();
1480 let obj = serde_json::json!({
1481 "query": "coupling",
1482 "count": results.len(),
1483 "results": entries,
1484 });
1485 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1486 }
1487 }
1488 Ok(())
1489}
1490
1491fn cmd_get(file: &Path, unit_id: u64, cli: &Cli) -> Result<(), Box<dyn std::error::Error>> {
1496 let s = styled(cli);
1497 validate_acb_path(file)?;
1498 let graph = AcbReader::read_from_file(file)?;
1499
1500 let unit = graph.get_unit(unit_id).ok_or_else(|| {
1501 format!(
1502 "{} Unit {} not found\n {} Use 'acb query ... symbol' to find valid unit IDs",
1503 s.fail(),
1504 unit_id,
1505 s.info()
1506 )
1507 })?;
1508
1509 let outgoing = graph.edges_from(unit_id);
1510 let incoming = graph.edges_to(unit_id);
1511
1512 let stdout = std::io::stdout();
1513 let mut out = stdout.lock();
1514
1515 match cli.format {
1516 OutputFormat::Text => {
1517 let _ = writeln!(
1518 out,
1519 "\n {} {}",
1520 s.info(),
1521 s.bold(&format!("Unit {}", unit.id))
1522 );
1523 let _ = writeln!(out, " Name: {}", s.cyan(&unit.name));
1524 let _ = writeln!(out, " Qualified name: {}", s.bold(&unit.qualified_name));
1525 let _ = writeln!(out, " Type: {}", unit.unit_type);
1526 let _ = writeln!(out, " Language: {}", unit.language);
1527 let _ = writeln!(
1528 out,
1529 " File: {}",
1530 s.cyan(&unit.file_path.display().to_string())
1531 );
1532 let _ = writeln!(out, " Span: {}", unit.span);
1533 let _ = writeln!(out, " Visibility: {}", unit.visibility);
1534 let _ = writeln!(out, " Complexity: {}", unit.complexity);
1535 if unit.is_async {
1536 let _ = writeln!(out, " Async: {}", s.green("yes"));
1537 }
1538 if unit.is_generator {
1539 let _ = writeln!(out, " Generator: {}", s.green("yes"));
1540 }
1541
1542 let stability_str = format!("{:.2}", unit.stability_score);
1543 let stability_color = if unit.stability_score >= 0.7 {
1544 s.green(&stability_str)
1545 } else if unit.stability_score >= 0.4 {
1546 s.yellow(&stability_str)
1547 } else {
1548 s.red(&stability_str)
1549 };
1550 let _ = writeln!(out, " Stability: {}", stability_color);
1551
1552 if let Some(sig) = &unit.signature {
1553 let _ = writeln!(out, " Signature: {}", s.dim(sig));
1554 }
1555 if let Some(doc) = &unit.doc_summary {
1556 let _ = writeln!(out, " Doc: {}", s.dim(doc));
1557 }
1558
1559 if !outgoing.is_empty() {
1560 let _ = writeln!(
1561 out,
1562 "\n {} Outgoing edges ({})",
1563 s.arrow(),
1564 outgoing.len()
1565 );
1566 for edge in &outgoing {
1567 let target_name = graph
1568 .get_unit(edge.target_id)
1569 .map(|u| u.qualified_name.as_str())
1570 .unwrap_or("?");
1571 let _ = writeln!(
1572 out,
1573 " {} {} {}",
1574 s.arrow(),
1575 s.cyan(target_name),
1576 s.dim(&format!("({})", edge.edge_type))
1577 );
1578 }
1579 }
1580 if !incoming.is_empty() {
1581 let _ = writeln!(
1582 out,
1583 "\n {} Incoming edges ({})",
1584 s.arrow(),
1585 incoming.len()
1586 );
1587 for edge in &incoming {
1588 let source_name = graph
1589 .get_unit(edge.source_id)
1590 .map(|u| u.qualified_name.as_str())
1591 .unwrap_or("?");
1592 let _ = writeln!(
1593 out,
1594 " {} {} {}",
1595 s.arrow(),
1596 s.cyan(source_name),
1597 s.dim(&format!("({})", edge.edge_type))
1598 );
1599 }
1600 }
1601 let _ = writeln!(out);
1602 }
1603 OutputFormat::Json => {
1604 let out_edges: Vec<serde_json::Value> = outgoing
1605 .iter()
1606 .map(|e| {
1607 serde_json::json!({
1608 "target_id": e.target_id,
1609 "edge_type": e.edge_type.label(),
1610 "weight": e.weight,
1611 })
1612 })
1613 .collect();
1614 let in_edges: Vec<serde_json::Value> = incoming
1615 .iter()
1616 .map(|e| {
1617 serde_json::json!({
1618 "source_id": e.source_id,
1619 "edge_type": e.edge_type.label(),
1620 "weight": e.weight,
1621 })
1622 })
1623 .collect();
1624 let obj = serde_json::json!({
1625 "id": unit.id,
1626 "name": unit.name,
1627 "qualified_name": unit.qualified_name,
1628 "unit_type": unit.unit_type.label(),
1629 "language": unit.language.name(),
1630 "file": unit.file_path.display().to_string(),
1631 "span": unit.span.to_string(),
1632 "visibility": unit.visibility.to_string(),
1633 "complexity": unit.complexity,
1634 "is_async": unit.is_async,
1635 "is_generator": unit.is_generator,
1636 "stability_score": unit.stability_score,
1637 "signature": unit.signature,
1638 "doc_summary": unit.doc_summary,
1639 "outgoing_edges": out_edges,
1640 "incoming_edges": in_edges,
1641 });
1642 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1643 }
1644 }
1645
1646 Ok(())
1647}