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;
12use serde::{Deserialize, Serialize};
13
14use crate::cli::output::{format_size, progress, progress_done, Styled};
15use crate::engine::query::{
16 CallDirection, CallGraphParams, CouplingParams, DeadCodeParams, DependencyParams,
17 HotspotParams, ImpactParams, MatchMode, ProphecyParams, QueryEngine, SimilarityParams,
18 StabilityResult, SymbolLookupParams, TestGapParams,
19};
20use crate::format::{AcbReader, AcbWriter};
21use crate::graph::CodeGraph;
22use crate::grounding::{Grounded, GroundingEngine, GroundingResult};
23use crate::parse::parser::{ParseOptions, Parser as AcbParser};
24use crate::semantic::analyzer::{AnalyzeOptions, SemanticAnalyzer};
25use crate::types::FileHeader;
26use crate::workspace::{ContextRole, WorkspaceManager};
27
28const DEFAULT_STORAGE_BUDGET_BYTES: u64 = 2 * 1024 * 1024 * 1024;
30const DEFAULT_STORAGE_BUDGET_HORIZON_YEARS: u32 = 20;
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34struct WorkspaceContextState {
35 path: String,
36 role: String,
37 language: Option<String>,
38}
39
40#[derive(Debug, Default, Serialize, Deserialize)]
41struct WorkspaceState {
42 workspaces: std::collections::HashMap<String, Vec<WorkspaceContextState>>,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46enum StorageBudgetMode {
47 AutoRollup,
48 Warn,
49 Off,
50}
51
52impl StorageBudgetMode {
53 fn from_env(name: &str) -> Self {
54 let raw = read_env_string(name).unwrap_or_else(|| "auto-rollup".to_string());
55 match raw.trim().to_ascii_lowercase().as_str() {
56 "warn" => Self::Warn,
57 "off" | "disabled" | "none" => Self::Off,
58 _ => Self::AutoRollup,
59 }
60 }
61
62 fn as_str(self) -> &'static str {
63 match self {
64 Self::AutoRollup => "auto-rollup",
65 Self::Warn => "warn",
66 Self::Off => "off",
67 }
68 }
69}
70
71#[derive(Parser)]
77#[command(
78 name = "acb",
79 about = "AgenticCodebase \u{2014} Semantic code compiler for AI agents",
80 long_about = "AgenticCodebase compiles multi-language codebases into navigable concept \
81 graphs that AI agents can query. Supports Python, Rust, TypeScript, and Go.\n\n\
82 Quick start:\n\
83 \x20 acb compile ./my-project # build a graph\n\
84 \x20 acb info my-project.acb # inspect the graph\n\
85 \x20 acb query my-project.acb symbol --name UserService\n\
86 \x20 acb query my-project.acb impact --unit-id 42\n\n\
87 For AI agent integration, use the companion MCP server: agentic-codebase-mcp",
88 after_help = "Run 'acb <command> --help' for details on a specific command.\n\
89 Set ACB_LOG=debug for verbose tracing. Set NO_COLOR=1 to disable colors.",
90 version
91)]
92pub struct Cli {
93 #[command(subcommand)]
94 pub command: Option<Command>,
95
96 #[arg(long, short = 'f', default_value = "text", global = true)]
98 pub format: OutputFormat,
99
100 #[arg(long, short = 'v', global = true)]
102 pub verbose: bool,
103
104 #[arg(long, short = 'q', global = true)]
106 pub quiet: bool,
107}
108
109#[derive(Clone, ValueEnum)]
111pub enum OutputFormat {
112 Text,
114 Json,
116}
117
118#[derive(Subcommand)]
120pub enum Command {
121 Init {
123 file: PathBuf,
125 },
126
127 #[command(alias = "build")]
138 Compile {
139 path: PathBuf,
141
142 #[arg(short, long)]
144 output: Option<PathBuf>,
145
146 #[arg(long, short = 'e')]
148 exclude: Vec<String>,
149
150 #[arg(long, default_value_t = true)]
152 include_tests: bool,
153
154 #[arg(long)]
156 coverage_report: Option<PathBuf>,
157 },
158
159 #[command(alias = "stat")]
168 Info {
169 file: PathBuf,
171 },
172
173 #[command(alias = "q")]
195 Query {
196 file: PathBuf,
198
199 query_type: String,
202
203 #[arg(long, short = 'n')]
205 name: Option<String>,
206
207 #[arg(long, short = 'u')]
209 unit_id: Option<u64>,
210
211 #[arg(long, short = 'd', default_value_t = 3)]
213 depth: u32,
214
215 #[arg(long, short = 'l', default_value_t = 20)]
217 limit: usize,
218 },
219
220 Get {
229 file: PathBuf,
231
232 unit_id: u64,
234 },
235
236 Completions {
246 shell: Shell,
248 },
249
250 Health {
252 file: PathBuf,
254
255 #[arg(long, short = 'l', default_value_t = 10)]
257 limit: usize,
258 },
259
260 Gate {
262 file: PathBuf,
264
265 #[arg(long, short = 'u')]
267 unit_id: u64,
268
269 #[arg(long, default_value_t = 0.60)]
271 max_risk: f32,
272
273 #[arg(long, short = 'd', default_value_t = 3)]
275 depth: u32,
276
277 #[arg(long, default_value_t = true)]
279 require_tests: bool,
280 },
281
282 Budget {
284 file: PathBuf,
286
287 #[arg(long, default_value_t = DEFAULT_STORAGE_BUDGET_BYTES)]
289 max_bytes: u64,
290
291 #[arg(long, default_value_t = DEFAULT_STORAGE_BUDGET_HORIZON_YEARS)]
293 horizon_years: u32,
294 },
295
296 Export {
298 file: PathBuf,
300
301 #[arg(short, long)]
303 output: Option<PathBuf>,
304 },
305
306 Ground {
308 file: PathBuf,
310 claim: String,
312 },
313
314 Evidence {
316 file: PathBuf,
318 query: String,
320 #[arg(long, short = 'l', default_value_t = 20)]
322 limit: usize,
323 },
324
325 Suggest {
327 file: PathBuf,
329 query: String,
331 #[arg(long, short = 'l', default_value_t = 10)]
333 limit: usize,
334 },
335
336 Workspace {
338 #[command(subcommand)]
339 command: WorkspaceCommand,
340 },
341}
342
343#[derive(Subcommand)]
344pub enum WorkspaceCommand {
345 Create { name: String },
347
348 Add {
350 workspace: String,
351 file: PathBuf,
352 #[arg(long, default_value = "source")]
353 role: String,
354 #[arg(long)]
355 language: Option<String>,
356 },
357
358 List { workspace: String },
360
361 Query { workspace: String, query: String },
363
364 Compare { workspace: String, symbol: String },
366
367 Xref { workspace: String, symbol: String },
369}
370
371pub fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
379 let command_name = match &cli.command {
380 None => "repl",
381 Some(Command::Init { .. }) => "init",
382 Some(Command::Compile { .. }) => "compile",
383 Some(Command::Info { .. }) => "info",
384 Some(Command::Query { .. }) => "query",
385 Some(Command::Get { .. }) => "get",
386 Some(Command::Completions { .. }) => "completions",
387 Some(Command::Health { .. }) => "health",
388 Some(Command::Gate { .. }) => "gate",
389 Some(Command::Budget { .. }) => "budget",
390 Some(Command::Export { .. }) => "export",
391 Some(Command::Ground { .. }) => "ground",
392 Some(Command::Evidence { .. }) => "evidence",
393 Some(Command::Suggest { .. }) => "suggest",
394 Some(Command::Workspace { .. }) => "workspace",
395 };
396 let started = Instant::now();
397 let result = match &cli.command {
398 None => crate::cli::repl::run(),
400
401 Some(Command::Init { file }) => cmd_init(file, &cli),
402 Some(Command::Compile {
403 path,
404 output,
405 exclude,
406 include_tests,
407 coverage_report,
408 }) => cmd_compile(
409 path,
410 output.as_deref(),
411 exclude,
412 *include_tests,
413 coverage_report.as_deref(),
414 &cli,
415 ),
416 Some(Command::Info { file }) => cmd_info(file, &cli),
417 Some(Command::Query {
418 file,
419 query_type,
420 name,
421 unit_id,
422 depth,
423 limit,
424 }) => cmd_query(
425 file,
426 query_type,
427 name.as_deref(),
428 *unit_id,
429 *depth,
430 *limit,
431 &cli,
432 ),
433 Some(Command::Get { file, unit_id }) => cmd_get(file, *unit_id, &cli),
434 Some(Command::Completions { shell }) => {
435 let mut cmd = Cli::command();
436 clap_complete::generate(*shell, &mut cmd, "acb", &mut std::io::stdout());
437 Ok(())
438 }
439 Some(Command::Health { file, limit }) => cmd_health(file, *limit, &cli),
440 Some(Command::Gate {
441 file,
442 unit_id,
443 max_risk,
444 depth,
445 require_tests,
446 }) => cmd_gate(file, *unit_id, *max_risk, *depth, *require_tests, &cli),
447 Some(Command::Budget {
448 file,
449 max_bytes,
450 horizon_years,
451 }) => cmd_budget(file, *max_bytes, *horizon_years, &cli),
452 Some(Command::Export { file, output }) => cmd_export_graph(file, output.as_deref(), &cli),
453 Some(Command::Ground { file, claim }) => cmd_ground(file, claim, &cli),
454 Some(Command::Evidence { file, query, limit }) => cmd_evidence(file, query, *limit, &cli),
455 Some(Command::Suggest { file, query, limit }) => cmd_suggest(file, query, *limit, &cli),
456 Some(Command::Workspace { command }) => cmd_workspace(command, &cli),
457 };
458
459 emit_cli_health_ledger(command_name, started.elapsed(), result.is_ok());
460 result
461}
462
463fn emit_cli_health_ledger(command: &str, duration: std::time::Duration, ok: bool) {
468 let dir = resolve_health_ledger_dir();
469 if std::fs::create_dir_all(&dir).is_err() {
470 return;
471 }
472 let path = dir.join("agentic-codebase-cli.json");
473 let tmp = dir.join("agentic-codebase-cli.json.tmp");
474 let profile = read_env_string("ACB_AUTONOMIC_PROFILE").unwrap_or_else(|| "desktop".to_string());
475 let payload = serde_json::json!({
476 "project": "AgenticCodebase",
477 "surface": "cli",
478 "timestamp": chrono::Utc::now().to_rfc3339(),
479 "status": if ok { "ok" } else { "error" },
480 "autonomic": {
481 "profile": profile.to_ascii_lowercase(),
482 "command": command,
483 "duration_ms": duration.as_millis(),
484 }
485 });
486 let Ok(bytes) = serde_json::to_vec_pretty(&payload) else {
487 return;
488 };
489 if std::fs::write(&tmp, bytes).is_err() {
490 return;
491 }
492 let _ = std::fs::rename(&tmp, &path);
493}
494
495fn resolve_health_ledger_dir() -> PathBuf {
496 if let Some(custom) = read_env_string("ACB_HEALTH_LEDGER_DIR") {
497 if !custom.is_empty() {
498 return PathBuf::from(custom);
499 }
500 }
501 if let Some(custom) = read_env_string("AGENTRA_HEALTH_LEDGER_DIR") {
502 if !custom.is_empty() {
503 return PathBuf::from(custom);
504 }
505 }
506 let home = std::env::var("HOME")
507 .ok()
508 .map(PathBuf::from)
509 .unwrap_or_else(|| PathBuf::from("."));
510 home.join(".agentra").join("health-ledger")
511}
512
513fn styled(cli: &Cli) -> Styled {
515 match cli.format {
516 OutputFormat::Json => Styled::plain(),
517 OutputFormat::Text => Styled::auto(),
518 }
519}
520
521fn validate_acb_path(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
523 let s = Styled::auto();
524 if !path.exists() {
525 return Err(format!(
526 "{} File not found: {}\n {} Check the path and try again",
527 s.fail(),
528 path.display(),
529 s.info()
530 )
531 .into());
532 }
533 if !path.is_file() {
534 return Err(format!(
535 "{} Not a file: {}\n {} Provide a path to an .acb file, not a directory",
536 s.fail(),
537 path.display(),
538 s.info()
539 )
540 .into());
541 }
542 if path.extension().and_then(|e| e.to_str()) != Some("acb") {
543 return Err(format!(
544 "{} Expected .acb file, got: {}\n {} Compile a repository first: acb compile <dir>",
545 s.fail(),
546 path.display(),
547 s.info()
548 )
549 .into());
550 }
551 Ok(())
552}
553
554fn workspace_state_path() -> PathBuf {
555 let home = std::env::var("HOME")
556 .ok()
557 .map(PathBuf::from)
558 .unwrap_or_else(|| PathBuf::from("."));
559 home.join(".agentic")
560 .join("codebase")
561 .join("workspaces.json")
562}
563
564fn load_workspace_state() -> Result<WorkspaceState, Box<dyn std::error::Error>> {
565 let path = workspace_state_path();
566 if !path.exists() {
567 return Ok(WorkspaceState::default());
568 }
569 let raw = std::fs::read_to_string(path)?;
570 let state = serde_json::from_str::<WorkspaceState>(&raw)?;
571 Ok(state)
572}
573
574fn save_workspace_state(state: &WorkspaceState) -> Result<(), Box<dyn std::error::Error>> {
575 let path = workspace_state_path();
576 if let Some(dir) = path.parent() {
577 std::fs::create_dir_all(dir)?;
578 }
579 let raw = serde_json::to_string_pretty(state)?;
580 std::fs::write(path, raw)?;
581 Ok(())
582}
583
584fn build_workspace_manager(
585 workspace: &str,
586) -> Result<(WorkspaceManager, String, WorkspaceState), Box<dyn std::error::Error>> {
587 let state = load_workspace_state()?;
588 let contexts = state
589 .workspaces
590 .get(workspace)
591 .ok_or_else(|| format!("workspace '{}' not found", workspace))?;
592
593 let mut manager = WorkspaceManager::new();
594 let ws_id = manager.create(workspace);
595
596 for ctx in contexts {
597 let role = ContextRole::parse_str(&ctx.role).unwrap_or(ContextRole::Source);
598 let graph = AcbReader::read_from_file(Path::new(&ctx.path))?;
599 manager.add_context(&ws_id, &ctx.path, role, ctx.language.clone(), graph)?;
600 }
601
602 Ok((manager, ws_id, state))
603}
604
605fn cmd_init(file: &Path, cli: &Cli) -> Result<(), Box<dyn std::error::Error>> {
606 if file.extension().and_then(|e| e.to_str()) != Some("acb") {
607 return Err("init target must use .acb extension".into());
608 }
609 let graph = CodeGraph::with_default_dimension();
610 let writer = AcbWriter::new(graph.dimension());
611 writer.write_to_file(&graph, file)?;
612
613 if matches!(cli.format, OutputFormat::Json) {
614 println!(
615 "{}",
616 serde_json::to_string_pretty(&serde_json::json!({
617 "file": file.display().to_string(),
618 "created": true,
619 "units": 0,
620 "edges": 0
621 }))?
622 );
623 } else if !cli.quiet {
624 println!("Initialized {}", file.display());
625 }
626 Ok(())
627}
628
629fn cmd_export_graph(
630 file: &Path,
631 output: Option<&Path>,
632 cli: &Cli,
633) -> Result<(), Box<dyn std::error::Error>> {
634 validate_acb_path(file)?;
635 let graph = AcbReader::read_from_file(file)?;
636 let payload = serde_json::json!({
637 "file": file.display().to_string(),
638 "units": graph.units().iter().map(|u| serde_json::json!({
639 "id": u.id,
640 "name": u.name,
641 "qualified_name": u.qualified_name,
642 "type": u.unit_type.label(),
643 "language": u.language.name(),
644 "file_path": u.file_path.display().to_string(),
645 "signature": u.signature,
646 })).collect::<Vec<_>>(),
647 "edges": graph.edges().iter().map(|e| serde_json::json!({
648 "source_id": e.source_id,
649 "target_id": e.target_id,
650 "type": e.edge_type.label(),
651 "weight": e.weight,
652 })).collect::<Vec<_>>(),
653 });
654
655 let raw = serde_json::to_string_pretty(&payload)?;
656 if let Some(path) = output {
657 std::fs::write(path, raw)?;
658 if !cli.quiet && matches!(cli.format, OutputFormat::Text) {
659 println!("Exported {} -> {}", file.display(), path.display());
660 }
661 } else {
662 println!("{}", raw);
663 }
664 Ok(())
665}
666
667fn cmd_ground(file: &Path, claim: &str, cli: &Cli) -> Result<(), Box<dyn std::error::Error>> {
668 validate_acb_path(file)?;
669 let graph = AcbReader::read_from_file(file)?;
670 let engine = GroundingEngine::new(&graph);
671 match engine.ground_claim(claim) {
672 GroundingResult::Verified {
673 evidence,
674 confidence,
675 } => {
676 if matches!(cli.format, OutputFormat::Json) {
677 println!(
678 "{}",
679 serde_json::to_string_pretty(&serde_json::json!({
680 "status": "verified",
681 "claim": claim,
682 "confidence": confidence,
683 "evidence_count": evidence.len(),
684 "evidence": evidence.iter().map(|e| serde_json::json!({
685 "node_id": e.node_id,
686 "name": e.name,
687 "type": e.node_type,
688 "file": e.file_path,
689 "line": e.line_number,
690 "snippet": e.snippet,
691 })).collect::<Vec<_>>()
692 }))?
693 );
694 } else {
695 println!("Status: verified (confidence {:.2})", confidence);
696 println!("Evidence: {}", evidence.len());
697 }
698 }
699 GroundingResult::Partial {
700 supported,
701 unsupported,
702 suggestions,
703 } => {
704 if matches!(cli.format, OutputFormat::Json) {
705 println!(
706 "{}",
707 serde_json::to_string_pretty(&serde_json::json!({
708 "status": "partial",
709 "claim": claim,
710 "supported": supported,
711 "unsupported": unsupported,
712 "suggestions": suggestions
713 }))?
714 );
715 } else {
716 println!("Status: partial");
717 println!("Supported: {:?}", supported);
718 println!("Unsupported: {:?}", unsupported);
719 if !suggestions.is_empty() {
720 println!("Suggestions: {:?}", suggestions);
721 }
722 }
723 }
724 GroundingResult::Ungrounded { suggestions, .. } => {
725 if matches!(cli.format, OutputFormat::Json) {
726 println!(
727 "{}",
728 serde_json::to_string_pretty(&serde_json::json!({
729 "status": "ungrounded",
730 "claim": claim,
731 "suggestions": suggestions
732 }))?
733 );
734 } else {
735 println!("Status: ungrounded");
736 if suggestions.is_empty() {
737 println!("Suggestions: none");
738 } else {
739 println!("Suggestions: {:?}", suggestions);
740 }
741 }
742 }
743 }
744 Ok(())
745}
746
747fn cmd_evidence(
748 file: &Path,
749 query: &str,
750 limit: usize,
751 cli: &Cli,
752) -> Result<(), Box<dyn std::error::Error>> {
753 validate_acb_path(file)?;
754 let graph = AcbReader::read_from_file(file)?;
755 let engine = GroundingEngine::new(&graph);
756 let mut evidence = engine.find_evidence(query);
757 evidence.truncate(limit);
758
759 if matches!(cli.format, OutputFormat::Json) {
760 println!(
761 "{}",
762 serde_json::to_string_pretty(&serde_json::json!({
763 "query": query,
764 "count": evidence.len(),
765 "evidence": evidence.iter().map(|e| serde_json::json!({
766 "node_id": e.node_id,
767 "name": e.name,
768 "type": e.node_type,
769 "file": e.file_path,
770 "line": e.line_number,
771 "snippet": e.snippet,
772 })).collect::<Vec<_>>()
773 }))?
774 );
775 } else if evidence.is_empty() {
776 println!("No evidence found.");
777 } else {
778 println!("Evidence for {:?}:", query);
779 for e in &evidence {
780 println!(
781 " - [{}] {} ({}) {}",
782 e.node_id, e.name, e.node_type, e.file_path
783 );
784 }
785 }
786 Ok(())
787}
788
789fn cmd_suggest(
790 file: &Path,
791 query: &str,
792 limit: usize,
793 cli: &Cli,
794) -> Result<(), Box<dyn std::error::Error>> {
795 validate_acb_path(file)?;
796 let graph = AcbReader::read_from_file(file)?;
797 let engine = GroundingEngine::new(&graph);
798 let suggestions = engine.suggest_similar(query, limit);
799
800 if matches!(cli.format, OutputFormat::Json) {
801 println!(
802 "{}",
803 serde_json::to_string_pretty(&serde_json::json!({
804 "query": query,
805 "suggestions": suggestions
806 }))?
807 );
808 } else if suggestions.is_empty() {
809 println!("No suggestions found.");
810 } else {
811 println!("Suggestions:");
812 for s in suggestions {
813 println!(" - {}", s);
814 }
815 }
816 Ok(())
817}
818
819fn cmd_workspace(command: &WorkspaceCommand, cli: &Cli) -> Result<(), Box<dyn std::error::Error>> {
820 match command {
821 WorkspaceCommand::Create { name } => {
822 let mut state = load_workspace_state()?;
823 state.workspaces.entry(name.clone()).or_default();
824 save_workspace_state(&state)?;
825 if matches!(cli.format, OutputFormat::Json) {
826 println!(
827 "{}",
828 serde_json::to_string_pretty(&serde_json::json!({
829 "workspace": name,
830 "created": true
831 }))?
832 );
833 } else if !cli.quiet {
834 println!("Created workspace '{}'", name);
835 }
836 Ok(())
837 }
838 WorkspaceCommand::Add {
839 workspace,
840 file,
841 role,
842 language,
843 } => {
844 validate_acb_path(file)?;
845 let mut state = load_workspace_state()?;
846 let contexts = state.workspaces.entry(workspace.clone()).or_default();
847 let path = file.display().to_string();
848 if !contexts.iter().any(|ctx| ctx.path == path) {
849 contexts.push(WorkspaceContextState {
850 path: path.clone(),
851 role: role.to_ascii_lowercase(),
852 language: language.clone(),
853 });
854 save_workspace_state(&state)?;
855 }
856
857 if matches!(cli.format, OutputFormat::Json) {
858 println!(
859 "{}",
860 serde_json::to_string_pretty(&serde_json::json!({
861 "workspace": workspace,
862 "path": path,
863 "added": true
864 }))?
865 );
866 } else if !cli.quiet {
867 println!("Added {} to workspace '{}'", file.display(), workspace);
868 }
869 Ok(())
870 }
871 WorkspaceCommand::List { workspace } => {
872 let state = load_workspace_state()?;
873 let contexts = state
874 .workspaces
875 .get(workspace)
876 .ok_or_else(|| format!("workspace '{}' not found", workspace))?;
877 if matches!(cli.format, OutputFormat::Json) {
878 println!(
879 "{}",
880 serde_json::to_string_pretty(&serde_json::json!({
881 "workspace": workspace,
882 "contexts": contexts
883 }))?
884 );
885 } else {
886 println!("Workspace '{}':", workspace);
887 for ctx in contexts {
888 println!(
889 " - {} (role={}, language={})",
890 ctx.path,
891 ctx.role,
892 ctx.language.clone().unwrap_or_else(|| "-".to_string())
893 );
894 }
895 }
896 Ok(())
897 }
898 WorkspaceCommand::Query { workspace, query } => {
899 let (manager, ws_id, _) = build_workspace_manager(workspace)?;
900 let results = manager.query_all(&ws_id, query)?;
901 if matches!(cli.format, OutputFormat::Json) {
902 println!(
903 "{}",
904 serde_json::to_string_pretty(&serde_json::json!({
905 "workspace": workspace,
906 "query": query,
907 "results": results.iter().map(|r| serde_json::json!({
908 "context_id": r.context_id,
909 "role": r.context_role.label(),
910 "matches": r.matches.iter().map(|m| serde_json::json!({
911 "unit_id": m.unit_id,
912 "name": m.name,
913 "qualified_name": m.qualified_name,
914 "unit_type": m.unit_type,
915 "file_path": m.file_path,
916 })).collect::<Vec<_>>()
917 })).collect::<Vec<_>>()
918 }))?
919 );
920 } else {
921 println!("Workspace query {:?}:", query);
922 for r in results {
923 println!(" Context {} ({})", r.context_id, r.context_role.label());
924 for m in r.matches {
925 println!(" - [{}] {}", m.unit_id, m.qualified_name);
926 }
927 }
928 }
929 Ok(())
930 }
931 WorkspaceCommand::Compare { workspace, symbol } => {
932 let (manager, ws_id, _) = build_workspace_manager(workspace)?;
933 let comparison = manager.compare(&ws_id, symbol)?;
934 if matches!(cli.format, OutputFormat::Json) {
935 println!(
936 "{}",
937 serde_json::to_string_pretty(&serde_json::json!({
938 "workspace": workspace,
939 "symbol": comparison.symbol,
940 "contexts": comparison.contexts.iter().map(|c| serde_json::json!({
941 "context_id": c.context_id,
942 "role": c.role.label(),
943 "found": c.found,
944 "unit_type": c.unit_type,
945 "signature": c.signature,
946 "file_path": c.file_path,
947 })).collect::<Vec<_>>(),
948 "semantic_match": comparison.semantic_match,
949 "structural_diff": comparison.structural_diff,
950 }))?
951 );
952 } else {
953 println!("Comparison for {:?}:", symbol);
954 for c in comparison.contexts {
955 println!(
956 " - {} ({}) found={}",
957 c.context_id,
958 c.role.label(),
959 c.found
960 );
961 }
962 }
963 Ok(())
964 }
965 WorkspaceCommand::Xref { workspace, symbol } => {
966 let (manager, ws_id, _) = build_workspace_manager(workspace)?;
967 let xref = manager.cross_reference(&ws_id, symbol)?;
968 if matches!(cli.format, OutputFormat::Json) {
969 println!(
970 "{}",
971 serde_json::to_string_pretty(&serde_json::json!({
972 "workspace": workspace,
973 "symbol": xref.symbol,
974 "found_in": xref.found_in.iter().map(|(id, role)| serde_json::json!({
975 "context_id": id,
976 "role": role.label(),
977 })).collect::<Vec<_>>(),
978 "missing_from": xref.missing_from.iter().map(|(id, role)| serde_json::json!({
979 "context_id": id,
980 "role": role.label(),
981 })).collect::<Vec<_>>(),
982 }))?
983 );
984 } else {
985 println!("Found in: {:?}", xref.found_in);
986 println!("Missing from: {:?}", xref.missing_from);
987 }
988 Ok(())
989 }
990 }
991}
992
993fn cmd_compile(
998 path: &Path,
999 output: Option<&std::path::Path>,
1000 exclude: &[String],
1001 include_tests: bool,
1002 coverage_report: Option<&Path>,
1003 cli: &Cli,
1004) -> Result<(), Box<dyn std::error::Error>> {
1005 let s = styled(cli);
1006
1007 if !path.exists() {
1008 return Err(format!(
1009 "{} Path does not exist: {}\n {} Create the directory or check the path",
1010 s.fail(),
1011 path.display(),
1012 s.info()
1013 )
1014 .into());
1015 }
1016 if !path.is_dir() {
1017 return Err(format!(
1018 "{} Path is not a directory: {}\n {} Provide the root directory of a source repository",
1019 s.fail(),
1020 path.display(),
1021 s.info()
1022 )
1023 .into());
1024 }
1025
1026 let out_path = match output {
1027 Some(p) => p.to_path_buf(),
1028 None => {
1029 let dir_name = path
1030 .file_name()
1031 .map(|n| n.to_string_lossy().to_string())
1032 .unwrap_or_else(|| "output".to_string());
1033 PathBuf::from(format!("{}.acb", dir_name))
1034 }
1035 };
1036
1037 let mut opts = ParseOptions {
1039 include_tests,
1040 ..ParseOptions::default()
1041 };
1042 for pat in exclude {
1043 opts.exclude.push(pat.clone());
1044 }
1045
1046 if !cli.quiet {
1047 if let OutputFormat::Text = cli.format {
1048 eprintln!(
1049 " {} Compiling {} {} {}",
1050 s.info(),
1051 s.bold(&path.display().to_string()),
1052 s.arrow(),
1053 s.cyan(&out_path.display().to_string()),
1054 );
1055 }
1056 }
1057
1058 if cli.verbose {
1060 eprintln!(" {} Parsing source files...", s.info());
1061 }
1062 let parser = AcbParser::new();
1063 let parse_result = parser.parse_directory(path, &opts)?;
1064
1065 if !cli.quiet {
1066 if let OutputFormat::Text = cli.format {
1067 eprintln!(
1068 " {} Parsed {} files ({} units found)",
1069 s.ok(),
1070 parse_result.stats.files_parsed,
1071 parse_result.units.len(),
1072 );
1073 let cov = &parse_result.stats.coverage;
1074 eprintln!(
1075 " {} Ingestion seen:{} candidate:{} skipped:{} errored:{}",
1076 s.info(),
1077 cov.files_seen,
1078 cov.files_candidate,
1079 cov.total_skipped(),
1080 parse_result.stats.files_errored
1081 );
1082 if !parse_result.errors.is_empty() {
1083 eprintln!(
1084 " {} {} parse errors (use --verbose to see details)",
1085 s.warn(),
1086 parse_result.errors.len()
1087 );
1088 }
1089 }
1090 }
1091
1092 if cli.verbose && !parse_result.errors.is_empty() {
1093 for err in &parse_result.errors {
1094 eprintln!(" {} {:?}", s.warn(), err);
1095 }
1096 }
1097
1098 if cli.verbose {
1100 eprintln!(" {} Running semantic analysis...", s.info());
1101 }
1102 let unit_count = parse_result.units.len();
1103 progress("Analyzing", 0, unit_count);
1104 let analyzer = SemanticAnalyzer::new();
1105 let analyze_opts = AnalyzeOptions::default();
1106 let graph = analyzer.analyze(parse_result.units, &analyze_opts)?;
1107 progress("Analyzing", unit_count, unit_count);
1108 progress_done();
1109
1110 if cli.verbose {
1111 eprintln!(
1112 " {} Graph built: {} units, {} edges",
1113 s.ok(),
1114 graph.unit_count(),
1115 graph.edge_count()
1116 );
1117 }
1118
1119 if cli.verbose {
1121 eprintln!(" {} Writing binary format...", s.info());
1122 }
1123 let backup_path = maybe_backup_existing_output(&out_path)?;
1124 if cli.verbose {
1125 if let Some(backup) = &backup_path {
1126 eprintln!(
1127 " {} Backed up previous graph to {}",
1128 s.info(),
1129 s.dim(&backup.display().to_string())
1130 );
1131 }
1132 }
1133 let writer = AcbWriter::with_default_dimension();
1134 writer.write_to_file(&graph, &out_path)?;
1135
1136 let file_size = std::fs::metadata(&out_path).map(|m| m.len()).unwrap_or(0);
1138 let budget_report = match maybe_enforce_storage_budget_on_output(&out_path) {
1139 Ok(report) => report,
1140 Err(e) => {
1141 tracing::warn!("ACB storage budget check skipped: {e}");
1142 AcbStorageBudgetReport {
1143 mode: "off",
1144 max_bytes: DEFAULT_STORAGE_BUDGET_BYTES,
1145 horizon_years: DEFAULT_STORAGE_BUDGET_HORIZON_YEARS,
1146 target_fraction: 0.85,
1147 current_size_bytes: file_size,
1148 projected_size_bytes: None,
1149 family_size_bytes: file_size,
1150 over_budget: false,
1151 backups_trimmed: 0,
1152 bytes_freed: 0,
1153 }
1154 }
1155 };
1156 let cov = &parse_result.stats.coverage;
1157 let coverage_json = serde_json::json!({
1158 "files_seen": cov.files_seen,
1159 "files_candidate": cov.files_candidate,
1160 "files_parsed": parse_result.stats.files_parsed,
1161 "files_skipped_total": cov.total_skipped(),
1162 "files_errored_total": parse_result.stats.files_errored,
1163 "skip_reasons": {
1164 "unknown_language": cov.skipped_unknown_language,
1165 "language_filter": cov.skipped_language_filter,
1166 "exclude_pattern": cov.skipped_excluded_pattern,
1167 "too_large": cov.skipped_too_large,
1168 "test_file_filtered": cov.skipped_test_file
1169 },
1170 "errors": {
1171 "read_errors": cov.read_errors,
1172 "parse_errors": cov.parse_errors
1173 },
1174 "parse_time_ms": parse_result.stats.parse_time_ms,
1175 "by_language": parse_result.stats.by_language,
1176 });
1177
1178 if let Some(report_path) = coverage_report {
1179 if let Some(parent) = report_path.parent() {
1180 if !parent.as_os_str().is_empty() {
1181 std::fs::create_dir_all(parent)?;
1182 }
1183 }
1184 let payload = serde_json::json!({
1185 "status": "ok",
1186 "source_root": path.display().to_string(),
1187 "output_graph": out_path.display().to_string(),
1188 "generated_at": chrono::Utc::now().to_rfc3339(),
1189 "coverage": coverage_json,
1190 });
1191 std::fs::write(report_path, serde_json::to_string_pretty(&payload)? + "\n")?;
1192 }
1193
1194 let stdout = std::io::stdout();
1195 let mut out = stdout.lock();
1196
1197 match cli.format {
1198 OutputFormat::Text => {
1199 if !cli.quiet {
1200 let _ = writeln!(out);
1201 let _ = writeln!(out, " {} Compiled successfully!", s.ok());
1202 let _ = writeln!(
1203 out,
1204 " Units: {}",
1205 s.bold(&graph.unit_count().to_string())
1206 );
1207 let _ = writeln!(
1208 out,
1209 " Edges: {}",
1210 s.bold(&graph.edge_count().to_string())
1211 );
1212 let _ = writeln!(
1213 out,
1214 " Languages: {}",
1215 s.bold(&graph.languages().len().to_string())
1216 );
1217 let _ = writeln!(out, " Size: {}", s.dim(&format_size(file_size)));
1218 if budget_report.over_budget {
1219 let projected = budget_report
1220 .projected_size_bytes
1221 .map(format_size)
1222 .unwrap_or_else(|| "unavailable".to_string());
1223 let _ = writeln!(
1224 out,
1225 " Budget: {} current={} projected={} limit={}",
1226 s.warn(),
1227 format_size(budget_report.current_size_bytes),
1228 projected,
1229 format_size(budget_report.max_bytes)
1230 );
1231 }
1232 if budget_report.backups_trimmed > 0 {
1233 let _ = writeln!(
1234 out,
1235 " Budget fix: trimmed {} backups ({} freed)",
1236 budget_report.backups_trimmed,
1237 format_size(budget_report.bytes_freed)
1238 );
1239 }
1240 let _ = writeln!(
1241 out,
1242 " Coverage: seen={} candidate={} skipped={} errored={}",
1243 cov.files_seen,
1244 cov.files_candidate,
1245 cov.total_skipped(),
1246 parse_result.stats.files_errored
1247 );
1248 if let Some(report_path) = coverage_report {
1249 let _ = writeln!(
1250 out,
1251 " Report: {}",
1252 s.dim(&report_path.display().to_string())
1253 );
1254 }
1255 let _ = writeln!(out);
1256 let _ = writeln!(
1257 out,
1258 " Next: {} or {}",
1259 s.cyan(&format!("acb info {}", out_path.display())),
1260 s.cyan(&format!(
1261 "acb query {} symbol --name <search>",
1262 out_path.display()
1263 )),
1264 );
1265 }
1266 }
1267 OutputFormat::Json => {
1268 let obj = serde_json::json!({
1269 "status": "ok",
1270 "source": path.display().to_string(),
1271 "output": out_path.display().to_string(),
1272 "units": graph.unit_count(),
1273 "edges": graph.edge_count(),
1274 "languages": graph.languages().len(),
1275 "file_size_bytes": file_size,
1276 "storage_budget": {
1277 "mode": budget_report.mode,
1278 "max_bytes": budget_report.max_bytes,
1279 "horizon_years": budget_report.horizon_years,
1280 "target_fraction": budget_report.target_fraction,
1281 "current_size_bytes": budget_report.current_size_bytes,
1282 "projected_size_bytes": budget_report.projected_size_bytes,
1283 "family_size_bytes": budget_report.family_size_bytes,
1284 "over_budget": budget_report.over_budget,
1285 "backups_trimmed": budget_report.backups_trimmed,
1286 "bytes_freed": budget_report.bytes_freed
1287 },
1288 "coverage": coverage_json,
1289 });
1290 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1291 }
1292 }
1293
1294 Ok(())
1295}
1296
1297#[derive(Debug, Clone)]
1298struct AcbStorageBudgetReport {
1299 mode: &'static str,
1300 max_bytes: u64,
1301 horizon_years: u32,
1302 target_fraction: f32,
1303 current_size_bytes: u64,
1304 projected_size_bytes: Option<u64>,
1305 family_size_bytes: u64,
1306 over_budget: bool,
1307 backups_trimmed: usize,
1308 bytes_freed: u64,
1309}
1310
1311#[derive(Debug, Clone)]
1312struct BackupEntry {
1313 path: PathBuf,
1314 size: u64,
1315 modified: SystemTime,
1316}
1317
1318fn maybe_enforce_storage_budget_on_output(
1319 out_path: &Path,
1320) -> Result<AcbStorageBudgetReport, Box<dyn std::error::Error>> {
1321 let mode = StorageBudgetMode::from_env("ACB_STORAGE_BUDGET_MODE");
1322 let max_bytes = read_env_u64("ACB_STORAGE_BUDGET_BYTES", DEFAULT_STORAGE_BUDGET_BYTES).max(1);
1323 let horizon_years = read_env_u32(
1324 "ACB_STORAGE_BUDGET_HORIZON_YEARS",
1325 DEFAULT_STORAGE_BUDGET_HORIZON_YEARS,
1326 )
1327 .max(1);
1328 let target_fraction =
1329 read_env_f32("ACB_STORAGE_BUDGET_TARGET_FRACTION", 0.85).clamp(0.50, 0.99);
1330
1331 let current_meta = std::fs::metadata(out_path)?;
1332 let current_size = current_meta.len();
1333 let current_modified = current_meta.modified().unwrap_or(SystemTime::now());
1334 let mut backups = list_backup_entries(out_path)?;
1335 let mut family_size = current_size.saturating_add(backups.iter().map(|b| b.size).sum::<u64>());
1336 let projected =
1337 projected_size_from_samples(&backups, current_modified, current_size, horizon_years);
1338 let over_budget = current_size > max_bytes || projected.map(|v| v > max_bytes).unwrap_or(false);
1339
1340 let mut trimmed = 0usize;
1341 let mut bytes_freed = 0u64;
1342
1343 if mode == StorageBudgetMode::Warn && over_budget {
1344 tracing::warn!(
1345 "ACB storage budget warning: current={} projected={:?} limit={}",
1346 current_size,
1347 projected,
1348 max_bytes
1349 );
1350 }
1351
1352 if mode == StorageBudgetMode::AutoRollup && (over_budget || family_size > max_bytes) {
1353 let target_bytes = ((max_bytes as f64 * target_fraction as f64).round() as u64).max(1);
1354 backups.sort_by_key(|b| b.modified);
1355 for backup in backups {
1356 if family_size <= target_bytes {
1357 break;
1358 }
1359 if std::fs::remove_file(&backup.path).is_ok() {
1360 family_size = family_size.saturating_sub(backup.size);
1361 trimmed = trimmed.saturating_add(1);
1362 bytes_freed = bytes_freed.saturating_add(backup.size);
1363 }
1364 }
1365
1366 if trimmed > 0 {
1367 tracing::info!(
1368 "ACB storage budget rollup: trimmed_backups={} freed_bytes={} family_size={}",
1369 trimmed,
1370 bytes_freed,
1371 family_size
1372 );
1373 }
1374 }
1375
1376 Ok(AcbStorageBudgetReport {
1377 mode: mode.as_str(),
1378 max_bytes,
1379 horizon_years,
1380 target_fraction,
1381 current_size_bytes: current_size,
1382 projected_size_bytes: projected,
1383 family_size_bytes: family_size,
1384 over_budget,
1385 backups_trimmed: trimmed,
1386 bytes_freed,
1387 })
1388}
1389
1390fn list_backup_entries(out_path: &Path) -> Result<Vec<BackupEntry>, Box<dyn std::error::Error>> {
1391 let backups_dir = resolve_backup_dir(out_path);
1392 if !backups_dir.exists() {
1393 return Ok(Vec::new());
1394 }
1395
1396 let original_name = out_path
1397 .file_name()
1398 .and_then(|n| n.to_str())
1399 .unwrap_or("graph.acb");
1400
1401 let mut out = Vec::new();
1402 for entry in std::fs::read_dir(&backups_dir)? {
1403 let entry = entry?;
1404 let name = entry.file_name();
1405 let Some(name_str) = name.to_str() else {
1406 continue;
1407 };
1408 if !(name_str.starts_with(original_name) && name_str.ends_with(".bak")) {
1409 continue;
1410 }
1411 let meta = entry.metadata()?;
1412 out.push(BackupEntry {
1413 path: entry.path(),
1414 size: meta.len(),
1415 modified: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),
1416 });
1417 }
1418 Ok(out)
1419}
1420
1421fn projected_size_from_samples(
1422 backups: &[BackupEntry],
1423 current_modified: SystemTime,
1424 current_size: u64,
1425 horizon_years: u32,
1426) -> Option<u64> {
1427 let mut samples = backups
1428 .iter()
1429 .map(|b| (b.modified, b.size))
1430 .collect::<Vec<_>>();
1431 samples.push((current_modified, current_size));
1432 if samples.len() < 2 {
1433 return None;
1434 }
1435 samples.sort_by_key(|(ts, _)| *ts);
1436 let (first_ts, first_size) = samples.first().copied()?;
1437 let (last_ts, last_size) = samples.last().copied()?;
1438 if last_ts <= first_ts {
1439 return None;
1440 }
1441 let span_secs = last_ts
1442 .duration_since(first_ts)
1443 .ok()?
1444 .as_secs_f64()
1445 .max(1.0);
1446 let delta = (last_size as f64 - first_size as f64).max(0.0);
1447 if delta <= 0.0 {
1448 return Some(current_size);
1449 }
1450 let per_sec = delta / span_secs;
1451 let horizon_secs = (horizon_years.max(1) as f64) * 365.25 * 24.0 * 3600.0;
1452 let projected = (current_size as f64 + per_sec * horizon_secs).round();
1453 Some(projected.max(0.0).min(u64::MAX as f64) as u64)
1454}
1455
1456fn maybe_backup_existing_output(
1457 out_path: &Path,
1458) -> Result<Option<PathBuf>, Box<dyn std::error::Error>> {
1459 if !auto_backup_enabled() || !out_path.exists() || !out_path.is_file() {
1460 return Ok(None);
1461 }
1462
1463 let backups_dir = resolve_backup_dir(out_path);
1464 std::fs::create_dir_all(&backups_dir)?;
1465
1466 let original_name = out_path
1467 .file_name()
1468 .and_then(|n| n.to_str())
1469 .unwrap_or("graph.acb");
1470 let ts = chrono::Utc::now().format("%Y%m%d%H%M%S");
1471 let backup_path = backups_dir.join(format!("{original_name}.{ts}.bak"));
1472 std::fs::copy(out_path, &backup_path)?;
1473 prune_old_backups(&backups_dir, original_name, auto_backup_retention())?;
1474
1475 Ok(Some(backup_path))
1476}
1477
1478fn auto_backup_enabled() -> bool {
1479 match std::env::var("ACB_AUTO_BACKUP") {
1480 Ok(v) => {
1481 let value = v.trim().to_ascii_lowercase();
1482 value != "0" && value != "false" && value != "off" && value != "no"
1483 }
1484 Err(_) => true,
1485 }
1486}
1487
1488fn auto_backup_retention() -> usize {
1489 let default_retention = match read_env_string("ACB_AUTONOMIC_PROFILE")
1490 .unwrap_or_else(|| "desktop".to_string())
1491 .to_ascii_lowercase()
1492 .as_str()
1493 {
1494 "cloud" => 40,
1495 "aggressive" => 12,
1496 _ => 20,
1497 };
1498 std::env::var("ACB_AUTO_BACKUP_RETENTION")
1499 .ok()
1500 .and_then(|v| v.parse::<usize>().ok())
1501 .unwrap_or(default_retention)
1502 .max(1)
1503}
1504
1505fn resolve_backup_dir(out_path: &Path) -> PathBuf {
1506 if let Ok(custom) = std::env::var("ACB_AUTO_BACKUP_DIR") {
1507 let trimmed = custom.trim();
1508 if !trimmed.is_empty() {
1509 return PathBuf::from(trimmed);
1510 }
1511 }
1512 out_path
1513 .parent()
1514 .unwrap_or_else(|| Path::new("."))
1515 .join(".acb-backups")
1516}
1517
1518fn read_env_string(name: &str) -> Option<String> {
1519 std::env::var(name).ok().map(|v| v.trim().to_string())
1520}
1521
1522fn read_env_u64(name: &str, default_value: u64) -> u64 {
1523 std::env::var(name)
1524 .ok()
1525 .and_then(|v| v.parse::<u64>().ok())
1526 .unwrap_or(default_value)
1527}
1528
1529fn read_env_u32(name: &str, default_value: u32) -> u32 {
1530 std::env::var(name)
1531 .ok()
1532 .and_then(|v| v.parse::<u32>().ok())
1533 .unwrap_or(default_value)
1534}
1535
1536fn read_env_f32(name: &str, default_value: f32) -> f32 {
1537 std::env::var(name)
1538 .ok()
1539 .and_then(|v| v.parse::<f32>().ok())
1540 .unwrap_or(default_value)
1541}
1542
1543fn prune_old_backups(
1544 backup_dir: &Path,
1545 original_name: &str,
1546 retention: usize,
1547) -> Result<(), Box<dyn std::error::Error>> {
1548 let mut backups = std::fs::read_dir(backup_dir)?
1549 .filter_map(Result::ok)
1550 .filter(|entry| {
1551 entry
1552 .file_name()
1553 .to_str()
1554 .map(|name| name.starts_with(original_name) && name.ends_with(".bak"))
1555 .unwrap_or(false)
1556 })
1557 .collect::<Vec<_>>();
1558
1559 if backups.len() <= retention {
1560 return Ok(());
1561 }
1562
1563 backups.sort_by_key(|entry| {
1564 entry
1565 .metadata()
1566 .and_then(|m| m.modified())
1567 .ok()
1568 .unwrap_or(SystemTime::UNIX_EPOCH)
1569 });
1570
1571 let to_remove = backups.len().saturating_sub(retention);
1572 for entry in backups.into_iter().take(to_remove) {
1573 let _ = std::fs::remove_file(entry.path());
1574 }
1575 Ok(())
1576}
1577
1578fn cmd_budget(
1579 file: &Path,
1580 max_bytes: u64,
1581 horizon_years: u32,
1582 cli: &Cli,
1583) -> Result<(), Box<dyn std::error::Error>> {
1584 validate_acb_path(file)?;
1585 let s = styled(cli);
1586 let current_meta = std::fs::metadata(file)?;
1587 let current_size = current_meta.len();
1588 let current_modified = current_meta.modified().unwrap_or(SystemTime::now());
1589 let backups = list_backup_entries(file)?;
1590 let family_size = current_size.saturating_add(backups.iter().map(|b| b.size).sum::<u64>());
1591 let projected =
1592 projected_size_from_samples(&backups, current_modified, current_size, horizon_years);
1593 let over_budget = current_size > max_bytes || projected.map(|v| v > max_bytes).unwrap_or(false);
1594 let daily_budget_bytes = max_bytes as f64 / ((horizon_years.max(1) as f64) * 365.25);
1595
1596 let stdout = std::io::stdout();
1597 let mut out = stdout.lock();
1598
1599 match cli.format {
1600 OutputFormat::Text => {
1601 let status = if over_budget {
1602 s.red("over-budget")
1603 } else {
1604 s.green("within-budget")
1605 };
1606 let _ = writeln!(out, "\n {} {}\n", s.info(), s.bold("ACB Storage Budget"));
1607 let _ = writeln!(out, " File: {}", file.display());
1608 let _ = writeln!(out, " Current: {}", format_size(current_size));
1609 if let Some(v) = projected {
1610 let _ = writeln!(
1611 out,
1612 " Projected: {} ({}y)",
1613 format_size(v),
1614 horizon_years
1615 );
1616 } else {
1617 let _ = writeln!(
1618 out,
1619 " Projected: unavailable (need backup history samples)"
1620 );
1621 }
1622 let _ = writeln!(out, " Family: {}", format_size(family_size));
1623 let _ = writeln!(out, " Budget: {}", format_size(max_bytes));
1624 let _ = writeln!(out, " Status: {}", status);
1625 let _ = writeln!(
1626 out,
1627 " Guidance: {:.1} KB/day target growth",
1628 daily_budget_bytes / 1024.0
1629 );
1630 let _ = writeln!(
1631 out,
1632 " Suggested env: ACB_STORAGE_BUDGET_MODE=auto-rollup ACB_STORAGE_BUDGET_BYTES={} ACB_STORAGE_BUDGET_HORIZON_YEARS={}",
1633 max_bytes,
1634 horizon_years
1635 );
1636 let _ = writeln!(out);
1637 }
1638 OutputFormat::Json => {
1639 let obj = serde_json::json!({
1640 "file": file.display().to_string(),
1641 "current_size_bytes": current_size,
1642 "projected_size_bytes": projected,
1643 "family_size_bytes": family_size,
1644 "max_budget_bytes": max_bytes,
1645 "horizon_years": horizon_years,
1646 "over_budget": over_budget,
1647 "daily_budget_bytes": daily_budget_bytes,
1648 "daily_budget_kb": daily_budget_bytes / 1024.0,
1649 "guidance": {
1650 "recommended_policy_mode": if over_budget { "auto-rollup" } else { "warn" },
1651 "env": {
1652 "ACB_STORAGE_BUDGET_MODE": "auto-rollup|warn|off",
1653 "ACB_STORAGE_BUDGET_BYTES": max_bytes,
1654 "ACB_STORAGE_BUDGET_HORIZON_YEARS": horizon_years,
1655 }
1656 }
1657 });
1658 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1659 }
1660 }
1661 Ok(())
1662}
1663
1664fn cmd_info(file: &PathBuf, cli: &Cli) -> Result<(), Box<dyn std::error::Error>> {
1669 let s = styled(cli);
1670 validate_acb_path(file)?;
1671 let graph = AcbReader::read_from_file(file)?;
1672
1673 let data = std::fs::read(file)?;
1675 let header_bytes: [u8; 128] = data[..128]
1676 .try_into()
1677 .map_err(|_| "File too small for header")?;
1678 let header = FileHeader::from_bytes(&header_bytes)?;
1679 let file_size = data.len() as u64;
1680
1681 let stdout = std::io::stdout();
1682 let mut out = stdout.lock();
1683
1684 match cli.format {
1685 OutputFormat::Text => {
1686 let _ = writeln!(
1687 out,
1688 "\n {} {}",
1689 s.info(),
1690 s.bold(&file.display().to_string())
1691 );
1692 let _ = writeln!(out, " Version: v{}", header.version);
1693 let _ = writeln!(
1694 out,
1695 " Units: {}",
1696 s.bold(&graph.unit_count().to_string())
1697 );
1698 let _ = writeln!(
1699 out,
1700 " Edges: {}",
1701 s.bold(&graph.edge_count().to_string())
1702 );
1703 let _ = writeln!(
1704 out,
1705 " Languages: {}",
1706 s.bold(&graph.languages().len().to_string())
1707 );
1708 let _ = writeln!(out, " Dimension: {}", header.dimension);
1709 let _ = writeln!(out, " File size: {}", format_size(file_size));
1710 let _ = writeln!(out);
1711 for lang in graph.languages() {
1712 let count = graph.units().iter().filter(|u| u.language == *lang).count();
1713 let _ = writeln!(
1714 out,
1715 " {} {} {}",
1716 s.arrow(),
1717 s.cyan(&format!("{:12}", lang)),
1718 s.dim(&format!("{} units", count))
1719 );
1720 }
1721 let _ = writeln!(out);
1722 }
1723 OutputFormat::Json => {
1724 let mut lang_map = serde_json::Map::new();
1725 for lang in graph.languages() {
1726 let count = graph.units().iter().filter(|u| u.language == *lang).count();
1727 lang_map.insert(lang.to_string(), serde_json::json!(count));
1728 }
1729 let obj = serde_json::json!({
1730 "file": file.display().to_string(),
1731 "version": header.version,
1732 "units": graph.unit_count(),
1733 "edges": graph.edge_count(),
1734 "languages": graph.languages().len(),
1735 "dimension": header.dimension,
1736 "file_size_bytes": file_size,
1737 "language_breakdown": lang_map,
1738 });
1739 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1740 }
1741 }
1742
1743 Ok(())
1744}
1745
1746fn cmd_health(file: &Path, limit: usize, cli: &Cli) -> Result<(), Box<dyn std::error::Error>> {
1747 validate_acb_path(file)?;
1748 let graph = AcbReader::read_from_file(file)?;
1749 let engine = QueryEngine::new();
1750 let s = styled(cli);
1751
1752 let prophecy = engine.prophecy(
1753 &graph,
1754 ProphecyParams {
1755 top_k: limit,
1756 min_risk: 0.45,
1757 },
1758 )?;
1759 let test_gaps = engine.test_gap(
1760 &graph,
1761 TestGapParams {
1762 min_changes: 5,
1763 min_complexity: 10,
1764 unit_types: vec![],
1765 },
1766 )?;
1767 let hotspots = engine.hotspot_detection(
1768 &graph,
1769 HotspotParams {
1770 top_k: limit,
1771 min_score: 0.55,
1772 unit_types: vec![],
1773 },
1774 )?;
1775 let dead_code = engine.dead_code(
1776 &graph,
1777 DeadCodeParams {
1778 unit_types: vec![],
1779 include_tests_as_roots: true,
1780 },
1781 )?;
1782
1783 let high_risk = prophecy
1784 .predictions
1785 .iter()
1786 .filter(|p| p.risk_score >= 0.70)
1787 .count();
1788 let avg_risk = if prophecy.predictions.is_empty() {
1789 0.0
1790 } else {
1791 prophecy
1792 .predictions
1793 .iter()
1794 .map(|p| p.risk_score)
1795 .sum::<f32>()
1796 / prophecy.predictions.len() as f32
1797 };
1798 let status = if high_risk >= 3 || test_gaps.len() >= 8 {
1799 "fail"
1800 } else if high_risk > 0 || !test_gaps.is_empty() || !hotspots.is_empty() {
1801 "warn"
1802 } else {
1803 "pass"
1804 };
1805
1806 let stdout = std::io::stdout();
1807 let mut out = stdout.lock();
1808 match cli.format {
1809 OutputFormat::Text => {
1810 let status_label = match status {
1811 "pass" => s.green("PASS"),
1812 "warn" => s.yellow("WARN"),
1813 _ => s.red("FAIL"),
1814 };
1815 let _ = writeln!(
1816 out,
1817 "\n Graph health for {} [{}]\n",
1818 s.bold(&file.display().to_string()),
1819 status_label
1820 );
1821 let _ = writeln!(out, " Units: {}", graph.unit_count());
1822 let _ = writeln!(out, " Edges: {}", graph.edge_count());
1823 let _ = writeln!(out, " Avg risk: {:.2}", avg_risk);
1824 let _ = writeln!(out, " High risk: {}", high_risk);
1825 let _ = writeln!(out, " Test gaps: {}", test_gaps.len());
1826 let _ = writeln!(out, " Hotspots: {}", hotspots.len());
1827 let _ = writeln!(out, " Dead code: {}", dead_code.len());
1828 let _ = writeln!(out);
1829
1830 if !prophecy.predictions.is_empty() {
1831 let _ = writeln!(out, " Top risk predictions:");
1832 for p in prophecy.predictions.iter().take(5) {
1833 let name = graph
1834 .get_unit(p.unit_id)
1835 .map(|u| u.qualified_name.clone())
1836 .unwrap_or_else(|| format!("unit_{}", p.unit_id));
1837 let _ = writeln!(out, " {} {:.2} {}", s.arrow(), p.risk_score, name);
1838 }
1839 let _ = writeln!(out);
1840 }
1841
1842 if !test_gaps.is_empty() {
1843 let _ = writeln!(out, " Top test gaps:");
1844 for g in test_gaps.iter().take(5) {
1845 let name = graph
1846 .get_unit(g.unit_id)
1847 .map(|u| u.qualified_name.clone())
1848 .unwrap_or_else(|| format!("unit_{}", g.unit_id));
1849 let _ = writeln!(
1850 out,
1851 " {} {:.2} {} ({})",
1852 s.arrow(),
1853 g.priority,
1854 name,
1855 g.reason
1856 );
1857 }
1858 let _ = writeln!(out);
1859 }
1860
1861 let _ = writeln!(
1862 out,
1863 " Next: acb gate {} --unit-id <id> --max-risk 0.60",
1864 file.display()
1865 );
1866 let _ = writeln!(out);
1867 }
1868 OutputFormat::Json => {
1869 let predictions = prophecy
1870 .predictions
1871 .iter()
1872 .map(|p| {
1873 serde_json::json!({
1874 "unit_id": p.unit_id,
1875 "name": graph.get_unit(p.unit_id).map(|u| u.qualified_name.clone()).unwrap_or_default(),
1876 "risk_score": p.risk_score,
1877 "reason": p.reason,
1878 })
1879 })
1880 .collect::<Vec<_>>();
1881 let gaps = test_gaps
1882 .iter()
1883 .map(|g| {
1884 serde_json::json!({
1885 "unit_id": g.unit_id,
1886 "name": graph.get_unit(g.unit_id).map(|u| u.qualified_name.clone()).unwrap_or_default(),
1887 "priority": g.priority,
1888 "reason": g.reason,
1889 })
1890 })
1891 .collect::<Vec<_>>();
1892 let hotspot_rows = hotspots
1893 .iter()
1894 .map(|h| {
1895 serde_json::json!({
1896 "unit_id": h.unit_id,
1897 "name": graph.get_unit(h.unit_id).map(|u| u.qualified_name.clone()).unwrap_or_default(),
1898 "score": h.score,
1899 "factors": h.factors,
1900 })
1901 })
1902 .collect::<Vec<_>>();
1903 let dead_rows = dead_code
1904 .iter()
1905 .map(|u| {
1906 serde_json::json!({
1907 "unit_id": u.id,
1908 "name": u.qualified_name,
1909 "type": u.unit_type.label(),
1910 })
1911 })
1912 .collect::<Vec<_>>();
1913
1914 let obj = serde_json::json!({
1915 "status": status,
1916 "graph": file.display().to_string(),
1917 "summary": {
1918 "units": graph.unit_count(),
1919 "edges": graph.edge_count(),
1920 "avg_risk": avg_risk,
1921 "high_risk_count": high_risk,
1922 "test_gap_count": test_gaps.len(),
1923 "hotspot_count": hotspots.len(),
1924 "dead_code_count": dead_code.len(),
1925 },
1926 "risk_predictions": predictions,
1927 "test_gaps": gaps,
1928 "hotspots": hotspot_rows,
1929 "dead_code": dead_rows,
1930 });
1931 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1932 }
1933 }
1934
1935 Ok(())
1936}
1937
1938fn cmd_gate(
1939 file: &Path,
1940 unit_id: u64,
1941 max_risk: f32,
1942 depth: u32,
1943 require_tests: bool,
1944 cli: &Cli,
1945) -> Result<(), Box<dyn std::error::Error>> {
1946 validate_acb_path(file)?;
1947 let graph = AcbReader::read_from_file(file)?;
1948 let engine = QueryEngine::new();
1949 let s = styled(cli);
1950
1951 let result = engine.impact_analysis(
1952 &graph,
1953 ImpactParams {
1954 unit_id,
1955 max_depth: depth,
1956 edge_types: vec![],
1957 },
1958 )?;
1959 let untested_count = result.impacted.iter().filter(|u| !u.has_tests).count();
1960 let risk_pass = result.overall_risk <= max_risk;
1961 let test_pass = !require_tests || untested_count == 0;
1962 let passed = risk_pass && test_pass;
1963
1964 let stdout = std::io::stdout();
1965 let mut out = stdout.lock();
1966
1967 match cli.format {
1968 OutputFormat::Text => {
1969 let label = if passed {
1970 s.green("PASS")
1971 } else {
1972 s.red("FAIL")
1973 };
1974 let unit_name = graph
1975 .get_unit(unit_id)
1976 .map(|u| u.qualified_name.clone())
1977 .unwrap_or_else(|| format!("unit_{}", unit_id));
1978 let _ = writeln!(out, "\n Gate {} for {}\n", label, s.bold(&unit_name));
1979 let _ = writeln!(
1980 out,
1981 " Overall risk: {:.2} (max {:.2})",
1982 result.overall_risk, max_risk
1983 );
1984 let _ = writeln!(out, " Impacted: {}", result.impacted.len());
1985 let _ = writeln!(out, " Untested: {}", untested_count);
1986 let _ = writeln!(out, " Require tests: {}", require_tests);
1987 if !result.recommendations.is_empty() {
1988 let _ = writeln!(out);
1989 for rec in &result.recommendations {
1990 let _ = writeln!(out, " {} {}", s.info(), rec);
1991 }
1992 }
1993 let _ = writeln!(out);
1994 }
1995 OutputFormat::Json => {
1996 let obj = serde_json::json!({
1997 "gate": if passed { "pass" } else { "fail" },
1998 "file": file.display().to_string(),
1999 "unit_id": unit_id,
2000 "max_risk": max_risk,
2001 "overall_risk": result.overall_risk,
2002 "impacted_count": result.impacted.len(),
2003 "untested_count": untested_count,
2004 "require_tests": require_tests,
2005 "recommendations": result.recommendations,
2006 });
2007 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2008 }
2009 }
2010
2011 if !passed {
2012 return Err(format!(
2013 "{} gate failed: risk_pass={} test_pass={} (risk {:.2} / max {:.2}, untested {})",
2014 s.fail(),
2015 risk_pass,
2016 test_pass,
2017 result.overall_risk,
2018 max_risk,
2019 untested_count
2020 )
2021 .into());
2022 }
2023
2024 Ok(())
2025}
2026
2027fn cmd_query(
2032 file: &Path,
2033 query_type: &str,
2034 name: Option<&str>,
2035 unit_id: Option<u64>,
2036 depth: u32,
2037 limit: usize,
2038 cli: &Cli,
2039) -> Result<(), Box<dyn std::error::Error>> {
2040 validate_acb_path(file)?;
2041 let graph = AcbReader::read_from_file(file)?;
2042 let engine = QueryEngine::new();
2043 let s = styled(cli);
2044
2045 match query_type {
2046 "symbol" | "sym" | "s" => query_symbol(&graph, &engine, name, limit, cli, &s),
2047 "deps" | "dep" | "d" => query_deps(&graph, &engine, unit_id, depth, cli, &s),
2048 "rdeps" | "rdep" | "r" => query_rdeps(&graph, &engine, unit_id, depth, cli, &s),
2049 "impact" | "imp" | "i" => query_impact(&graph, &engine, unit_id, depth, cli, &s),
2050 "calls" | "call" | "c" => query_calls(&graph, &engine, unit_id, depth, cli, &s),
2051 "similar" | "sim" => query_similar(&graph, &engine, unit_id, limit, cli, &s),
2052 "prophecy" | "predict" | "p" => query_prophecy(&graph, &engine, limit, cli, &s),
2053 "stability" | "stab" => query_stability(&graph, &engine, unit_id, cli, &s),
2054 "coupling" | "couple" => query_coupling(&graph, &engine, unit_id, cli, &s),
2055 "test-gap" | "testgap" | "gaps" => query_test_gap(&graph, &engine, limit, cli, &s),
2056 "hotspot" | "hotspots" => query_hotspots(&graph, &engine, limit, cli, &s),
2057 "dead" | "dead-code" | "deadcode" => query_dead_code(&graph, &engine, limit, cli, &s),
2058 other => {
2059 let known = [
2060 "symbol",
2061 "deps",
2062 "rdeps",
2063 "impact",
2064 "calls",
2065 "similar",
2066 "prophecy",
2067 "stability",
2068 "coupling",
2069 "test-gap",
2070 "hotspots",
2071 "dead-code",
2072 ];
2073 let suggestion = known
2074 .iter()
2075 .filter(|k| k.starts_with(&other[..1.min(other.len())]))
2076 .copied()
2077 .collect::<Vec<_>>();
2078 let hint = if suggestion.is_empty() {
2079 format!("Available: {}", known.join(", "))
2080 } else {
2081 format!("Did you mean: {}?", suggestion.join(", "))
2082 };
2083 Err(format!(
2084 "{} Unknown query type: {}\n {} {}",
2085 s.fail(),
2086 other,
2087 s.info(),
2088 hint
2089 )
2090 .into())
2091 }
2092 }
2093}
2094
2095fn query_symbol(
2096 graph: &CodeGraph,
2097 engine: &QueryEngine,
2098 name: Option<&str>,
2099 limit: usize,
2100 cli: &Cli,
2101 s: &Styled,
2102) -> Result<(), Box<dyn std::error::Error>> {
2103 let search_name = name.ok_or_else(|| {
2104 format!(
2105 "{} --name is required for symbol queries\n {} Example: acb query file.acb symbol --name UserService",
2106 s.fail(),
2107 s.info()
2108 )
2109 })?;
2110 let params = SymbolLookupParams {
2111 name: search_name.to_string(),
2112 mode: MatchMode::Contains,
2113 limit,
2114 ..Default::default()
2115 };
2116 let results = engine.symbol_lookup(graph, params)?;
2117
2118 let stdout = std::io::stdout();
2119 let mut out = stdout.lock();
2120
2121 match cli.format {
2122 OutputFormat::Text => {
2123 let _ = writeln!(
2124 out,
2125 "\n Symbol lookup: {} ({} results)\n",
2126 s.bold(&format!("\"{}\"", search_name)),
2127 results.len()
2128 );
2129 if results.is_empty() {
2130 let _ = writeln!(
2131 out,
2132 " {} No matches found. Try a broader search term.",
2133 s.warn()
2134 );
2135 }
2136 for (i, unit) in results.iter().enumerate() {
2137 let _ = writeln!(
2138 out,
2139 " {:>3}. {} {} {}",
2140 s.dim(&format!("#{}", i + 1)),
2141 s.bold(&unit.qualified_name),
2142 s.dim(&format!("({})", unit.unit_type)),
2143 s.dim(&format!(
2144 "{}:{}",
2145 unit.file_path.display(),
2146 unit.span.start_line
2147 ))
2148 );
2149 }
2150 let _ = writeln!(out);
2151 }
2152 OutputFormat::Json => {
2153 let entries: Vec<serde_json::Value> = results
2154 .iter()
2155 .map(|u| {
2156 serde_json::json!({
2157 "id": u.id,
2158 "name": u.name,
2159 "qualified_name": u.qualified_name,
2160 "unit_type": u.unit_type.label(),
2161 "language": u.language.name(),
2162 "file": u.file_path.display().to_string(),
2163 "line": u.span.start_line,
2164 })
2165 })
2166 .collect();
2167 let obj = serde_json::json!({
2168 "query": "symbol",
2169 "name": search_name,
2170 "count": results.len(),
2171 "results": entries,
2172 });
2173 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2174 }
2175 }
2176 Ok(())
2177}
2178
2179fn query_deps(
2180 graph: &CodeGraph,
2181 engine: &QueryEngine,
2182 unit_id: Option<u64>,
2183 depth: u32,
2184 cli: &Cli,
2185 s: &Styled,
2186) -> Result<(), Box<dyn std::error::Error>> {
2187 let uid = unit_id.ok_or_else(|| {
2188 format!(
2189 "{} --unit-id is required for deps queries\n {} Find an ID first: acb query file.acb symbol --name <name>",
2190 s.fail(), s.info()
2191 )
2192 })?;
2193 let params = DependencyParams {
2194 unit_id: uid,
2195 max_depth: depth,
2196 edge_types: vec![],
2197 include_transitive: true,
2198 };
2199 let result = engine.dependency_graph(graph, params)?;
2200
2201 let stdout = std::io::stdout();
2202 let mut out = stdout.lock();
2203
2204 match cli.format {
2205 OutputFormat::Text => {
2206 let root_name = graph
2207 .get_unit(uid)
2208 .map(|u| u.qualified_name.as_str())
2209 .unwrap_or("?");
2210 let _ = writeln!(
2211 out,
2212 "\n Dependencies of {} ({} found)\n",
2213 s.bold(root_name),
2214 result.nodes.len()
2215 );
2216 for node in &result.nodes {
2217 let unit_name = graph
2218 .get_unit(node.unit_id)
2219 .map(|u| u.qualified_name.as_str())
2220 .unwrap_or("?");
2221 let indent = " ".repeat(node.depth as usize);
2222 let _ = writeln!(
2223 out,
2224 " {}{} {} {}",
2225 indent,
2226 s.arrow(),
2227 s.cyan(unit_name),
2228 s.dim(&format!("[id:{}]", node.unit_id))
2229 );
2230 }
2231 let _ = writeln!(out);
2232 }
2233 OutputFormat::Json => {
2234 let entries: Vec<serde_json::Value> = result
2235 .nodes
2236 .iter()
2237 .map(|n| {
2238 let unit_name = graph
2239 .get_unit(n.unit_id)
2240 .map(|u| u.qualified_name.clone())
2241 .unwrap_or_default();
2242 serde_json::json!({
2243 "unit_id": n.unit_id,
2244 "name": unit_name,
2245 "depth": n.depth,
2246 })
2247 })
2248 .collect();
2249 let obj = serde_json::json!({
2250 "query": "deps",
2251 "root_id": uid,
2252 "count": result.nodes.len(),
2253 "results": entries,
2254 });
2255 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2256 }
2257 }
2258 Ok(())
2259}
2260
2261fn query_rdeps(
2262 graph: &CodeGraph,
2263 engine: &QueryEngine,
2264 unit_id: Option<u64>,
2265 depth: u32,
2266 cli: &Cli,
2267 s: &Styled,
2268) -> Result<(), Box<dyn std::error::Error>> {
2269 let uid = unit_id.ok_or_else(|| {
2270 format!(
2271 "{} --unit-id is required for rdeps queries\n {} Find an ID first: acb query file.acb symbol --name <name>",
2272 s.fail(), s.info()
2273 )
2274 })?;
2275 let params = DependencyParams {
2276 unit_id: uid,
2277 max_depth: depth,
2278 edge_types: vec![],
2279 include_transitive: true,
2280 };
2281 let result = engine.reverse_dependency(graph, params)?;
2282
2283 let stdout = std::io::stdout();
2284 let mut out = stdout.lock();
2285
2286 match cli.format {
2287 OutputFormat::Text => {
2288 let root_name = graph
2289 .get_unit(uid)
2290 .map(|u| u.qualified_name.as_str())
2291 .unwrap_or("?");
2292 let _ = writeln!(
2293 out,
2294 "\n Reverse dependencies of {} ({} found)\n",
2295 s.bold(root_name),
2296 result.nodes.len()
2297 );
2298 for node in &result.nodes {
2299 let unit_name = graph
2300 .get_unit(node.unit_id)
2301 .map(|u| u.qualified_name.as_str())
2302 .unwrap_or("?");
2303 let indent = " ".repeat(node.depth as usize);
2304 let _ = writeln!(
2305 out,
2306 " {}{} {} {}",
2307 indent,
2308 s.arrow(),
2309 s.cyan(unit_name),
2310 s.dim(&format!("[id:{}]", node.unit_id))
2311 );
2312 }
2313 let _ = writeln!(out);
2314 }
2315 OutputFormat::Json => {
2316 let entries: Vec<serde_json::Value> = result
2317 .nodes
2318 .iter()
2319 .map(|n| {
2320 let unit_name = graph
2321 .get_unit(n.unit_id)
2322 .map(|u| u.qualified_name.clone())
2323 .unwrap_or_default();
2324 serde_json::json!({
2325 "unit_id": n.unit_id,
2326 "name": unit_name,
2327 "depth": n.depth,
2328 })
2329 })
2330 .collect();
2331 let obj = serde_json::json!({
2332 "query": "rdeps",
2333 "root_id": uid,
2334 "count": result.nodes.len(),
2335 "results": entries,
2336 });
2337 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2338 }
2339 }
2340 Ok(())
2341}
2342
2343fn query_impact(
2344 graph: &CodeGraph,
2345 engine: &QueryEngine,
2346 unit_id: Option<u64>,
2347 depth: u32,
2348 cli: &Cli,
2349 s: &Styled,
2350) -> Result<(), Box<dyn std::error::Error>> {
2351 let uid =
2352 unit_id.ok_or_else(|| format!("{} --unit-id is required for impact queries", s.fail()))?;
2353 let params = ImpactParams {
2354 unit_id: uid,
2355 max_depth: depth,
2356 edge_types: vec![],
2357 };
2358 let result = engine.impact_analysis(graph, params)?;
2359
2360 let stdout = std::io::stdout();
2361 let mut out = stdout.lock();
2362
2363 match cli.format {
2364 OutputFormat::Text => {
2365 let root_name = graph
2366 .get_unit(uid)
2367 .map(|u| u.qualified_name.as_str())
2368 .unwrap_or("?");
2369
2370 let risk_label = if result.overall_risk >= 0.7 {
2371 s.red("HIGH")
2372 } else if result.overall_risk >= 0.4 {
2373 s.yellow("MEDIUM")
2374 } else {
2375 s.green("LOW")
2376 };
2377
2378 let _ = writeln!(
2379 out,
2380 "\n Impact analysis for {} (risk: {})\n",
2381 s.bold(root_name),
2382 risk_label,
2383 );
2384 let _ = writeln!(
2385 out,
2386 " {} impacted units, overall risk {:.2}\n",
2387 result.impacted.len(),
2388 result.overall_risk
2389 );
2390 for imp in &result.impacted {
2391 let unit_name = graph
2392 .get_unit(imp.unit_id)
2393 .map(|u| u.qualified_name.as_str())
2394 .unwrap_or("?");
2395 let risk_sym = if imp.risk_score >= 0.7 {
2396 s.fail()
2397 } else if imp.risk_score >= 0.4 {
2398 s.warn()
2399 } else {
2400 s.ok()
2401 };
2402 let test_badge = if imp.has_tests {
2403 s.green("tested")
2404 } else {
2405 s.red("untested")
2406 };
2407 let _ = writeln!(
2408 out,
2409 " {} {} {} risk:{:.2} {}",
2410 risk_sym,
2411 s.cyan(unit_name),
2412 s.dim(&format!("(depth {})", imp.depth)),
2413 imp.risk_score,
2414 test_badge,
2415 );
2416 }
2417 if !result.recommendations.is_empty() {
2418 let _ = writeln!(out);
2419 for rec in &result.recommendations {
2420 let _ = writeln!(out, " {} {}", s.info(), rec);
2421 }
2422 }
2423 let _ = writeln!(out);
2424 }
2425 OutputFormat::Json => {
2426 let entries: Vec<serde_json::Value> = result
2427 .impacted
2428 .iter()
2429 .map(|imp| {
2430 serde_json::json!({
2431 "unit_id": imp.unit_id,
2432 "depth": imp.depth,
2433 "risk_score": imp.risk_score,
2434 "has_tests": imp.has_tests,
2435 })
2436 })
2437 .collect();
2438 let obj = serde_json::json!({
2439 "query": "impact",
2440 "root_id": uid,
2441 "count": result.impacted.len(),
2442 "overall_risk": result.overall_risk,
2443 "results": entries,
2444 "recommendations": result.recommendations,
2445 });
2446 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2447 }
2448 }
2449 Ok(())
2450}
2451
2452fn query_calls(
2453 graph: &CodeGraph,
2454 engine: &QueryEngine,
2455 unit_id: Option<u64>,
2456 depth: u32,
2457 cli: &Cli,
2458 s: &Styled,
2459) -> Result<(), Box<dyn std::error::Error>> {
2460 let uid =
2461 unit_id.ok_or_else(|| format!("{} --unit-id is required for calls queries", s.fail()))?;
2462 let params = CallGraphParams {
2463 unit_id: uid,
2464 direction: CallDirection::Both,
2465 max_depth: depth,
2466 };
2467 let result = engine.call_graph(graph, params)?;
2468
2469 let stdout = std::io::stdout();
2470 let mut out = stdout.lock();
2471
2472 match cli.format {
2473 OutputFormat::Text => {
2474 let root_name = graph
2475 .get_unit(uid)
2476 .map(|u| u.qualified_name.as_str())
2477 .unwrap_or("?");
2478 let _ = writeln!(
2479 out,
2480 "\n Call graph for {} ({} nodes)\n",
2481 s.bold(root_name),
2482 result.nodes.len()
2483 );
2484 for (nid, d) in &result.nodes {
2485 let unit_name = graph
2486 .get_unit(*nid)
2487 .map(|u| u.qualified_name.as_str())
2488 .unwrap_or("?");
2489 let indent = " ".repeat(*d as usize);
2490 let _ = writeln!(out, " {}{} {}", indent, s.arrow(), s.cyan(unit_name),);
2491 }
2492 let _ = writeln!(out);
2493 }
2494 OutputFormat::Json => {
2495 let entries: Vec<serde_json::Value> = result
2496 .nodes
2497 .iter()
2498 .map(|(nid, d)| {
2499 let unit_name = graph
2500 .get_unit(*nid)
2501 .map(|u| u.qualified_name.clone())
2502 .unwrap_or_default();
2503 serde_json::json!({
2504 "unit_id": nid,
2505 "name": unit_name,
2506 "depth": d,
2507 })
2508 })
2509 .collect();
2510 let obj = serde_json::json!({
2511 "query": "calls",
2512 "root_id": uid,
2513 "count": result.nodes.len(),
2514 "results": entries,
2515 });
2516 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2517 }
2518 }
2519 Ok(())
2520}
2521
2522fn query_similar(
2523 graph: &CodeGraph,
2524 engine: &QueryEngine,
2525 unit_id: Option<u64>,
2526 limit: usize,
2527 cli: &Cli,
2528 s: &Styled,
2529) -> Result<(), Box<dyn std::error::Error>> {
2530 let uid =
2531 unit_id.ok_or_else(|| format!("{} --unit-id is required for similar queries", s.fail()))?;
2532 let params = SimilarityParams {
2533 unit_id: uid,
2534 top_k: limit,
2535 min_similarity: 0.0,
2536 };
2537 let results = engine.similarity(graph, params)?;
2538
2539 let stdout = std::io::stdout();
2540 let mut out = stdout.lock();
2541
2542 match cli.format {
2543 OutputFormat::Text => {
2544 let root_name = graph
2545 .get_unit(uid)
2546 .map(|u| u.qualified_name.as_str())
2547 .unwrap_or("?");
2548 let _ = writeln!(
2549 out,
2550 "\n Similar to {} ({} matches)\n",
2551 s.bold(root_name),
2552 results.len()
2553 );
2554 for (i, m) in results.iter().enumerate() {
2555 let unit_name = graph
2556 .get_unit(m.unit_id)
2557 .map(|u| u.qualified_name.as_str())
2558 .unwrap_or("?");
2559 let score_str = format!("{:.2}%", m.score * 100.0);
2560 let _ = writeln!(
2561 out,
2562 " {:>3}. {} {} {}",
2563 s.dim(&format!("#{}", i + 1)),
2564 s.cyan(unit_name),
2565 s.dim(&format!("[id:{}]", m.unit_id)),
2566 s.yellow(&score_str),
2567 );
2568 }
2569 let _ = writeln!(out);
2570 }
2571 OutputFormat::Json => {
2572 let entries: Vec<serde_json::Value> = results
2573 .iter()
2574 .map(|m| {
2575 serde_json::json!({
2576 "unit_id": m.unit_id,
2577 "score": m.score,
2578 })
2579 })
2580 .collect();
2581 let obj = serde_json::json!({
2582 "query": "similar",
2583 "root_id": uid,
2584 "count": results.len(),
2585 "results": entries,
2586 });
2587 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2588 }
2589 }
2590 Ok(())
2591}
2592
2593fn query_prophecy(
2594 graph: &CodeGraph,
2595 engine: &QueryEngine,
2596 limit: usize,
2597 cli: &Cli,
2598 s: &Styled,
2599) -> Result<(), Box<dyn std::error::Error>> {
2600 let params = ProphecyParams {
2601 top_k: limit,
2602 min_risk: 0.0,
2603 };
2604 let result = engine.prophecy(graph, params)?;
2605
2606 let stdout = std::io::stdout();
2607 let mut out = stdout.lock();
2608
2609 match cli.format {
2610 OutputFormat::Text => {
2611 let _ = writeln!(
2612 out,
2613 "\n {} Code prophecy ({} predictions)\n",
2614 s.info(),
2615 result.predictions.len()
2616 );
2617 if result.predictions.is_empty() {
2618 let _ = writeln!(
2619 out,
2620 " {} No high-risk predictions. Codebase looks stable!",
2621 s.ok()
2622 );
2623 }
2624 for pred in &result.predictions {
2625 let unit_name = graph
2626 .get_unit(pred.unit_id)
2627 .map(|u| u.qualified_name.as_str())
2628 .unwrap_or("?");
2629 let risk_sym = if pred.risk_score >= 0.7 {
2630 s.fail()
2631 } else if pred.risk_score >= 0.4 {
2632 s.warn()
2633 } else {
2634 s.ok()
2635 };
2636 let _ = writeln!(
2637 out,
2638 " {} {} {}: {}",
2639 risk_sym,
2640 s.cyan(unit_name),
2641 s.dim(&format!("(risk {:.2})", pred.risk_score)),
2642 pred.reason,
2643 );
2644 }
2645 let _ = writeln!(out);
2646 }
2647 OutputFormat::Json => {
2648 let entries: Vec<serde_json::Value> = result
2649 .predictions
2650 .iter()
2651 .map(|p| {
2652 serde_json::json!({
2653 "unit_id": p.unit_id,
2654 "risk_score": p.risk_score,
2655 "reason": p.reason,
2656 })
2657 })
2658 .collect();
2659 let obj = serde_json::json!({
2660 "query": "prophecy",
2661 "count": result.predictions.len(),
2662 "results": entries,
2663 });
2664 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2665 }
2666 }
2667 Ok(())
2668}
2669
2670fn query_stability(
2671 graph: &CodeGraph,
2672 engine: &QueryEngine,
2673 unit_id: Option<u64>,
2674 cli: &Cli,
2675 s: &Styled,
2676) -> Result<(), Box<dyn std::error::Error>> {
2677 let uid = unit_id
2678 .ok_or_else(|| format!("{} --unit-id is required for stability queries", s.fail()))?;
2679 let result: StabilityResult = engine.stability_analysis(graph, uid)?;
2680
2681 let stdout = std::io::stdout();
2682 let mut out = stdout.lock();
2683
2684 match cli.format {
2685 OutputFormat::Text => {
2686 let root_name = graph
2687 .get_unit(uid)
2688 .map(|u| u.qualified_name.as_str())
2689 .unwrap_or("?");
2690
2691 let score_color = if result.overall_score >= 0.7 {
2692 s.green(&format!("{:.2}", result.overall_score))
2693 } else if result.overall_score >= 0.4 {
2694 s.yellow(&format!("{:.2}", result.overall_score))
2695 } else {
2696 s.red(&format!("{:.2}", result.overall_score))
2697 };
2698
2699 let _ = writeln!(
2700 out,
2701 "\n Stability of {}: {}\n",
2702 s.bold(root_name),
2703 score_color,
2704 );
2705 for factor in &result.factors {
2706 let _ = writeln!(
2707 out,
2708 " {} {} = {:.2}: {}",
2709 s.arrow(),
2710 s.bold(&factor.name),
2711 factor.value,
2712 s.dim(&factor.description),
2713 );
2714 }
2715 let _ = writeln!(out, "\n {} {}", s.info(), result.recommendation);
2716 let _ = writeln!(out);
2717 }
2718 OutputFormat::Json => {
2719 let factors: Vec<serde_json::Value> = result
2720 .factors
2721 .iter()
2722 .map(|f| {
2723 serde_json::json!({
2724 "name": f.name,
2725 "value": f.value,
2726 "description": f.description,
2727 })
2728 })
2729 .collect();
2730 let obj = serde_json::json!({
2731 "query": "stability",
2732 "unit_id": uid,
2733 "overall_score": result.overall_score,
2734 "factors": factors,
2735 "recommendation": result.recommendation,
2736 });
2737 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2738 }
2739 }
2740 Ok(())
2741}
2742
2743fn query_coupling(
2744 graph: &CodeGraph,
2745 engine: &QueryEngine,
2746 unit_id: Option<u64>,
2747 cli: &Cli,
2748 s: &Styled,
2749) -> Result<(), Box<dyn std::error::Error>> {
2750 let params = CouplingParams {
2751 unit_id,
2752 min_strength: 0.0,
2753 };
2754 let results = engine.coupling_detection(graph, params)?;
2755
2756 let stdout = std::io::stdout();
2757 let mut out = stdout.lock();
2758
2759 match cli.format {
2760 OutputFormat::Text => {
2761 let _ = writeln!(
2762 out,
2763 "\n Coupling analysis ({} pairs detected)\n",
2764 results.len()
2765 );
2766 if results.is_empty() {
2767 let _ = writeln!(out, " {} No tightly coupled pairs detected.", s.ok());
2768 }
2769 for c in &results {
2770 let name_a = graph
2771 .get_unit(c.unit_a)
2772 .map(|u| u.qualified_name.as_str())
2773 .unwrap_or("?");
2774 let name_b = graph
2775 .get_unit(c.unit_b)
2776 .map(|u| u.qualified_name.as_str())
2777 .unwrap_or("?");
2778 let strength_str = format!("{:.0}%", c.strength * 100.0);
2779 let _ = writeln!(
2780 out,
2781 " {} {} {} {} {}",
2782 s.warn(),
2783 s.cyan(name_a),
2784 s.dim("<->"),
2785 s.cyan(name_b),
2786 s.yellow(&strength_str),
2787 );
2788 }
2789 let _ = writeln!(out);
2790 }
2791 OutputFormat::Json => {
2792 let entries: Vec<serde_json::Value> = results
2793 .iter()
2794 .map(|c| {
2795 serde_json::json!({
2796 "unit_a": c.unit_a,
2797 "unit_b": c.unit_b,
2798 "strength": c.strength,
2799 "kind": format!("{:?}", c.kind),
2800 })
2801 })
2802 .collect();
2803 let obj = serde_json::json!({
2804 "query": "coupling",
2805 "count": results.len(),
2806 "results": entries,
2807 });
2808 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2809 }
2810 }
2811 Ok(())
2812}
2813
2814fn query_test_gap(
2815 graph: &CodeGraph,
2816 engine: &QueryEngine,
2817 limit: usize,
2818 cli: &Cli,
2819 s: &Styled,
2820) -> Result<(), Box<dyn std::error::Error>> {
2821 let mut gaps = engine.test_gap(
2822 graph,
2823 TestGapParams {
2824 min_changes: 5,
2825 min_complexity: 10,
2826 unit_types: vec![],
2827 },
2828 )?;
2829 if limit > 0 {
2830 gaps.truncate(limit);
2831 }
2832
2833 let stdout = std::io::stdout();
2834 let mut out = stdout.lock();
2835 match cli.format {
2836 OutputFormat::Text => {
2837 let _ = writeln!(out, "\n Test gaps ({} results)\n", gaps.len());
2838 for g in &gaps {
2839 let name = graph
2840 .get_unit(g.unit_id)
2841 .map(|u| u.qualified_name.as_str())
2842 .unwrap_or("?");
2843 let _ = writeln!(
2844 out,
2845 " {} {} priority:{:.2} {}",
2846 s.arrow(),
2847 s.cyan(name),
2848 g.priority,
2849 s.dim(&g.reason)
2850 );
2851 }
2852 let _ = writeln!(out);
2853 }
2854 OutputFormat::Json => {
2855 let rows = gaps
2856 .iter()
2857 .map(|g| {
2858 serde_json::json!({
2859 "unit_id": g.unit_id,
2860 "name": graph.get_unit(g.unit_id).map(|u| u.qualified_name.clone()).unwrap_or_default(),
2861 "priority": g.priority,
2862 "reason": g.reason,
2863 })
2864 })
2865 .collect::<Vec<_>>();
2866 let obj = serde_json::json!({
2867 "query": "test-gap",
2868 "count": rows.len(),
2869 "results": rows,
2870 });
2871 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2872 }
2873 }
2874 Ok(())
2875}
2876
2877fn query_hotspots(
2878 graph: &CodeGraph,
2879 engine: &QueryEngine,
2880 limit: usize,
2881 cli: &Cli,
2882 s: &Styled,
2883) -> Result<(), Box<dyn std::error::Error>> {
2884 let hotspots = engine.hotspot_detection(
2885 graph,
2886 HotspotParams {
2887 top_k: limit,
2888 min_score: 0.55,
2889 unit_types: vec![],
2890 },
2891 )?;
2892
2893 let stdout = std::io::stdout();
2894 let mut out = stdout.lock();
2895 match cli.format {
2896 OutputFormat::Text => {
2897 let _ = writeln!(out, "\n Hotspots ({} results)\n", hotspots.len());
2898 for h in &hotspots {
2899 let name = graph
2900 .get_unit(h.unit_id)
2901 .map(|u| u.qualified_name.as_str())
2902 .unwrap_or("?");
2903 let _ = writeln!(out, " {} {} score:{:.2}", s.arrow(), s.cyan(name), h.score);
2904 }
2905 let _ = writeln!(out);
2906 }
2907 OutputFormat::Json => {
2908 let rows = hotspots
2909 .iter()
2910 .map(|h| {
2911 serde_json::json!({
2912 "unit_id": h.unit_id,
2913 "name": graph.get_unit(h.unit_id).map(|u| u.qualified_name.clone()).unwrap_or_default(),
2914 "score": h.score,
2915 "factors": h.factors,
2916 })
2917 })
2918 .collect::<Vec<_>>();
2919 let obj = serde_json::json!({
2920 "query": "hotspots",
2921 "count": rows.len(),
2922 "results": rows,
2923 });
2924 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2925 }
2926 }
2927 Ok(())
2928}
2929
2930fn query_dead_code(
2931 graph: &CodeGraph,
2932 engine: &QueryEngine,
2933 limit: usize,
2934 cli: &Cli,
2935 s: &Styled,
2936) -> Result<(), Box<dyn std::error::Error>> {
2937 let mut dead = engine.dead_code(
2938 graph,
2939 DeadCodeParams {
2940 unit_types: vec![],
2941 include_tests_as_roots: true,
2942 },
2943 )?;
2944 if limit > 0 {
2945 dead.truncate(limit);
2946 }
2947
2948 let stdout = std::io::stdout();
2949 let mut out = stdout.lock();
2950 match cli.format {
2951 OutputFormat::Text => {
2952 let _ = writeln!(out, "\n Dead code ({} results)\n", dead.len());
2953 for unit in &dead {
2954 let _ = writeln!(
2955 out,
2956 " {} {} {}",
2957 s.arrow(),
2958 s.cyan(&unit.qualified_name),
2959 s.dim(&format!("({})", unit.unit_type.label()))
2960 );
2961 }
2962 let _ = writeln!(out);
2963 }
2964 OutputFormat::Json => {
2965 let rows = dead
2966 .iter()
2967 .map(|u| {
2968 serde_json::json!({
2969 "unit_id": u.id,
2970 "name": u.qualified_name,
2971 "unit_type": u.unit_type.label(),
2972 "file": u.file_path.display().to_string(),
2973 "line": u.span.start_line,
2974 })
2975 })
2976 .collect::<Vec<_>>();
2977 let obj = serde_json::json!({
2978 "query": "dead-code",
2979 "count": rows.len(),
2980 "results": rows,
2981 });
2982 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2983 }
2984 }
2985 Ok(())
2986}
2987
2988fn cmd_get(file: &Path, unit_id: u64, cli: &Cli) -> Result<(), Box<dyn std::error::Error>> {
2993 let s = styled(cli);
2994 validate_acb_path(file)?;
2995 let graph = AcbReader::read_from_file(file)?;
2996
2997 let unit = graph.get_unit(unit_id).ok_or_else(|| {
2998 format!(
2999 "{} Unit {} not found\n {} Use 'acb query ... symbol' to find valid unit IDs",
3000 s.fail(),
3001 unit_id,
3002 s.info()
3003 )
3004 })?;
3005
3006 let outgoing = graph.edges_from(unit_id);
3007 let incoming = graph.edges_to(unit_id);
3008
3009 let stdout = std::io::stdout();
3010 let mut out = stdout.lock();
3011
3012 match cli.format {
3013 OutputFormat::Text => {
3014 let _ = writeln!(
3015 out,
3016 "\n {} {}",
3017 s.info(),
3018 s.bold(&format!("Unit {}", unit.id))
3019 );
3020 let _ = writeln!(out, " Name: {}", s.cyan(&unit.name));
3021 let _ = writeln!(out, " Qualified name: {}", s.bold(&unit.qualified_name));
3022 let _ = writeln!(out, " Type: {}", unit.unit_type);
3023 let _ = writeln!(out, " Language: {}", unit.language);
3024 let _ = writeln!(
3025 out,
3026 " File: {}",
3027 s.cyan(&unit.file_path.display().to_string())
3028 );
3029 let _ = writeln!(out, " Span: {}", unit.span);
3030 let _ = writeln!(out, " Visibility: {}", unit.visibility);
3031 let _ = writeln!(out, " Complexity: {}", unit.complexity);
3032 if unit.is_async {
3033 let _ = writeln!(out, " Async: {}", s.green("yes"));
3034 }
3035 if unit.is_generator {
3036 let _ = writeln!(out, " Generator: {}", s.green("yes"));
3037 }
3038
3039 let stability_str = format!("{:.2}", unit.stability_score);
3040 let stability_color = if unit.stability_score >= 0.7 {
3041 s.green(&stability_str)
3042 } else if unit.stability_score >= 0.4 {
3043 s.yellow(&stability_str)
3044 } else {
3045 s.red(&stability_str)
3046 };
3047 let _ = writeln!(out, " Stability: {}", stability_color);
3048
3049 if let Some(sig) = &unit.signature {
3050 let _ = writeln!(out, " Signature: {}", s.dim(sig));
3051 }
3052 if let Some(doc) = &unit.doc_summary {
3053 let _ = writeln!(out, " Doc: {}", s.dim(doc));
3054 }
3055
3056 if !outgoing.is_empty() {
3057 let _ = writeln!(
3058 out,
3059 "\n {} Outgoing edges ({})",
3060 s.arrow(),
3061 outgoing.len()
3062 );
3063 for edge in &outgoing {
3064 let target_name = graph
3065 .get_unit(edge.target_id)
3066 .map(|u| u.qualified_name.as_str())
3067 .unwrap_or("?");
3068 let _ = writeln!(
3069 out,
3070 " {} {} {}",
3071 s.arrow(),
3072 s.cyan(target_name),
3073 s.dim(&format!("({})", edge.edge_type))
3074 );
3075 }
3076 }
3077 if !incoming.is_empty() {
3078 let _ = writeln!(
3079 out,
3080 "\n {} Incoming edges ({})",
3081 s.arrow(),
3082 incoming.len()
3083 );
3084 for edge in &incoming {
3085 let source_name = graph
3086 .get_unit(edge.source_id)
3087 .map(|u| u.qualified_name.as_str())
3088 .unwrap_or("?");
3089 let _ = writeln!(
3090 out,
3091 " {} {} {}",
3092 s.arrow(),
3093 s.cyan(source_name),
3094 s.dim(&format!("({})", edge.edge_type))
3095 );
3096 }
3097 }
3098 let _ = writeln!(out);
3099 }
3100 OutputFormat::Json => {
3101 let out_edges: Vec<serde_json::Value> = outgoing
3102 .iter()
3103 .map(|e| {
3104 serde_json::json!({
3105 "target_id": e.target_id,
3106 "edge_type": e.edge_type.label(),
3107 "weight": e.weight,
3108 })
3109 })
3110 .collect();
3111 let in_edges: Vec<serde_json::Value> = incoming
3112 .iter()
3113 .map(|e| {
3114 serde_json::json!({
3115 "source_id": e.source_id,
3116 "edge_type": e.edge_type.label(),
3117 "weight": e.weight,
3118 })
3119 })
3120 .collect();
3121 let obj = serde_json::json!({
3122 "id": unit.id,
3123 "name": unit.name,
3124 "qualified_name": unit.qualified_name,
3125 "unit_type": unit.unit_type.label(),
3126 "language": unit.language.name(),
3127 "file": unit.file_path.display().to_string(),
3128 "span": unit.span.to_string(),
3129 "visibility": unit.visibility.to_string(),
3130 "complexity": unit.complexity,
3131 "is_async": unit.is_async,
3132 "is_generator": unit.is_generator,
3133 "stability_score": unit.stability_score,
3134 "signature": unit.signature,
3135 "doc_summary": unit.doc_summary,
3136 "outgoing_edges": out_edges,
3137 "incoming_edges": in_edges,
3138 });
3139 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
3140 }
3141 }
3142
3143 Ok(())
3144}