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 cov.skipped_unknown_language > 0 && !cov.unsupported_extensions.is_empty() {
1083 let mut exts: Vec<_> = cov.unsupported_extensions.iter().collect();
1084 exts.sort_by(|a, b| b.1.cmp(a.1));
1085 let top: Vec<_> = exts
1086 .iter()
1087 .take(8)
1088 .map(|(ext, count)| format!(".{}({})", ext, count))
1089 .collect();
1090 eprintln!(
1091 " {} Unsupported: {} files [{}]",
1092 s.info(),
1093 cov.skipped_unknown_language,
1094 top.join(", ")
1095 );
1096 }
1097 if !parse_result.errors.is_empty() {
1098 eprintln!(
1099 " {} {} parse errors (use --verbose to see details)",
1100 s.warn(),
1101 parse_result.errors.len()
1102 );
1103 }
1104 }
1105 }
1106
1107 if cli.verbose && !parse_result.errors.is_empty() {
1108 for err in &parse_result.errors {
1109 eprintln!(" {} {:?}", s.warn(), err);
1110 }
1111 }
1112
1113 if cli.verbose {
1115 eprintln!(" {} Running semantic analysis...", s.info());
1116 }
1117 let unit_count = parse_result.units.len();
1118 progress("Analyzing", 0, unit_count);
1119 let analyzer = SemanticAnalyzer::new();
1120 let analyze_opts = AnalyzeOptions::default();
1121 let graph = analyzer.analyze(parse_result.units, &analyze_opts)?;
1122 progress("Analyzing", unit_count, unit_count);
1123 progress_done();
1124
1125 if cli.verbose {
1126 eprintln!(
1127 " {} Graph built: {} units, {} edges",
1128 s.ok(),
1129 graph.unit_count(),
1130 graph.edge_count()
1131 );
1132 }
1133
1134 if cli.verbose {
1136 eprintln!(" {} Writing binary format...", s.info());
1137 }
1138 let backup_path = maybe_backup_existing_output(&out_path)?;
1139 if cli.verbose {
1140 if let Some(backup) = &backup_path {
1141 eprintln!(
1142 " {} Backed up previous graph to {}",
1143 s.info(),
1144 s.dim(&backup.display().to_string())
1145 );
1146 }
1147 }
1148 let writer = AcbWriter::with_default_dimension();
1149 writer.write_to_file(&graph, &out_path)?;
1150
1151 let file_size = std::fs::metadata(&out_path).map(|m| m.len()).unwrap_or(0);
1153 let budget_report = match maybe_enforce_storage_budget_on_output(&out_path) {
1154 Ok(report) => report,
1155 Err(e) => {
1156 tracing::warn!("ACB storage budget check skipped: {e}");
1157 AcbStorageBudgetReport {
1158 mode: "off",
1159 max_bytes: DEFAULT_STORAGE_BUDGET_BYTES,
1160 horizon_years: DEFAULT_STORAGE_BUDGET_HORIZON_YEARS,
1161 target_fraction: 0.85,
1162 current_size_bytes: file_size,
1163 projected_size_bytes: None,
1164 family_size_bytes: file_size,
1165 over_budget: false,
1166 backups_trimmed: 0,
1167 bytes_freed: 0,
1168 }
1169 }
1170 };
1171 let cov = &parse_result.stats.coverage;
1172 let coverage_json = serde_json::json!({
1173 "files_seen": cov.files_seen,
1174 "files_candidate": cov.files_candidate,
1175 "files_parsed": parse_result.stats.files_parsed,
1176 "files_skipped_total": cov.total_skipped(),
1177 "files_errored_total": parse_result.stats.files_errored,
1178 "skip_reasons": {
1179 "unknown_language": cov.skipped_unknown_language,
1180 "unsupported_extensions": cov.unsupported_extensions,
1181 "language_filter": cov.skipped_language_filter,
1182 "exclude_pattern": cov.skipped_excluded_pattern,
1183 "too_large": cov.skipped_too_large,
1184 "test_file_filtered": cov.skipped_test_file
1185 },
1186 "errors": {
1187 "read_errors": cov.read_errors,
1188 "parse_errors": cov.parse_errors
1189 },
1190 "parse_time_ms": parse_result.stats.parse_time_ms,
1191 "by_language": parse_result.stats.by_language,
1192 });
1193
1194 if let Some(report_path) = coverage_report {
1195 if let Some(parent) = report_path.parent() {
1196 if !parent.as_os_str().is_empty() {
1197 std::fs::create_dir_all(parent)?;
1198 }
1199 }
1200 let payload = serde_json::json!({
1201 "status": "ok",
1202 "source_root": path.display().to_string(),
1203 "output_graph": out_path.display().to_string(),
1204 "generated_at": chrono::Utc::now().to_rfc3339(),
1205 "coverage": coverage_json,
1206 });
1207 std::fs::write(report_path, serde_json::to_string_pretty(&payload)? + "\n")?;
1208 }
1209
1210 let stdout = std::io::stdout();
1211 let mut out = stdout.lock();
1212
1213 match cli.format {
1214 OutputFormat::Text => {
1215 if !cli.quiet {
1216 let _ = writeln!(out);
1217 let _ = writeln!(out, " {} Compiled successfully!", s.ok());
1218 let _ = writeln!(
1219 out,
1220 " Units: {}",
1221 s.bold(&graph.unit_count().to_string())
1222 );
1223 let _ = writeln!(
1224 out,
1225 " Edges: {}",
1226 s.bold(&graph.edge_count().to_string())
1227 );
1228 let _ = writeln!(
1229 out,
1230 " Languages: {}",
1231 s.bold(&graph.languages().len().to_string())
1232 );
1233 let _ = writeln!(out, " Size: {}", s.dim(&format_size(file_size)));
1234 if budget_report.over_budget {
1235 let projected = budget_report
1236 .projected_size_bytes
1237 .map(format_size)
1238 .unwrap_or_else(|| "unavailable".to_string());
1239 let _ = writeln!(
1240 out,
1241 " Budget: {} current={} projected={} limit={}",
1242 s.warn(),
1243 format_size(budget_report.current_size_bytes),
1244 projected,
1245 format_size(budget_report.max_bytes)
1246 );
1247 }
1248 if budget_report.backups_trimmed > 0 {
1249 let _ = writeln!(
1250 out,
1251 " Budget fix: trimmed {} backups ({} freed)",
1252 budget_report.backups_trimmed,
1253 format_size(budget_report.bytes_freed)
1254 );
1255 }
1256 let _ = writeln!(
1257 out,
1258 " Coverage: seen={} candidate={} skipped={} errored={}",
1259 cov.files_seen,
1260 cov.files_candidate,
1261 cov.total_skipped(),
1262 parse_result.stats.files_errored
1263 );
1264 if cov.skipped_unknown_language > 0 && !cov.unsupported_extensions.is_empty() {
1265 let mut exts: Vec<_> = cov.unsupported_extensions.iter().collect();
1266 exts.sort_by(|a, b| b.1.cmp(a.1));
1267 let top: Vec<_> = exts
1268 .iter()
1269 .take(8)
1270 .map(|(ext, count)| format!(".{}({})", ext, count))
1271 .collect();
1272 let _ = writeln!(
1273 out,
1274 " Unsupported: {} files [{}]",
1275 cov.skipped_unknown_language,
1276 top.join(", ")
1277 );
1278 }
1279 if let Some(report_path) = coverage_report {
1280 let _ = writeln!(
1281 out,
1282 " Report: {}",
1283 s.dim(&report_path.display().to_string())
1284 );
1285 }
1286 let _ = writeln!(out);
1287 let _ = writeln!(
1288 out,
1289 " Next: {} or {}",
1290 s.cyan(&format!("acb info {}", out_path.display())),
1291 s.cyan(&format!(
1292 "acb query {} symbol --name <search>",
1293 out_path.display()
1294 )),
1295 );
1296 }
1297 }
1298 OutputFormat::Json => {
1299 let obj = serde_json::json!({
1300 "status": "ok",
1301 "source": path.display().to_string(),
1302 "output": out_path.display().to_string(),
1303 "units": graph.unit_count(),
1304 "edges": graph.edge_count(),
1305 "languages": graph.languages().len(),
1306 "file_size_bytes": file_size,
1307 "storage_budget": {
1308 "mode": budget_report.mode,
1309 "max_bytes": budget_report.max_bytes,
1310 "horizon_years": budget_report.horizon_years,
1311 "target_fraction": budget_report.target_fraction,
1312 "current_size_bytes": budget_report.current_size_bytes,
1313 "projected_size_bytes": budget_report.projected_size_bytes,
1314 "family_size_bytes": budget_report.family_size_bytes,
1315 "over_budget": budget_report.over_budget,
1316 "backups_trimmed": budget_report.backups_trimmed,
1317 "bytes_freed": budget_report.bytes_freed
1318 },
1319 "coverage": coverage_json,
1320 });
1321 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1322 }
1323 }
1324
1325 Ok(())
1326}
1327
1328#[derive(Debug, Clone)]
1329struct AcbStorageBudgetReport {
1330 mode: &'static str,
1331 max_bytes: u64,
1332 horizon_years: u32,
1333 target_fraction: f32,
1334 current_size_bytes: u64,
1335 projected_size_bytes: Option<u64>,
1336 family_size_bytes: u64,
1337 over_budget: bool,
1338 backups_trimmed: usize,
1339 bytes_freed: u64,
1340}
1341
1342#[derive(Debug, Clone)]
1343struct BackupEntry {
1344 path: PathBuf,
1345 size: u64,
1346 modified: SystemTime,
1347}
1348
1349fn maybe_enforce_storage_budget_on_output(
1350 out_path: &Path,
1351) -> Result<AcbStorageBudgetReport, Box<dyn std::error::Error>> {
1352 let mode = StorageBudgetMode::from_env("ACB_STORAGE_BUDGET_MODE");
1353 let max_bytes = read_env_u64("ACB_STORAGE_BUDGET_BYTES", DEFAULT_STORAGE_BUDGET_BYTES).max(1);
1354 let horizon_years = read_env_u32(
1355 "ACB_STORAGE_BUDGET_HORIZON_YEARS",
1356 DEFAULT_STORAGE_BUDGET_HORIZON_YEARS,
1357 )
1358 .max(1);
1359 let target_fraction =
1360 read_env_f32("ACB_STORAGE_BUDGET_TARGET_FRACTION", 0.85).clamp(0.50, 0.99);
1361
1362 let current_meta = std::fs::metadata(out_path)?;
1363 let current_size = current_meta.len();
1364 let current_modified = current_meta.modified().unwrap_or(SystemTime::now());
1365 let mut backups = list_backup_entries(out_path)?;
1366 let mut family_size = current_size.saturating_add(backups.iter().map(|b| b.size).sum::<u64>());
1367 let projected =
1368 projected_size_from_samples(&backups, current_modified, current_size, horizon_years);
1369 let over_budget = current_size > max_bytes || projected.map(|v| v > max_bytes).unwrap_or(false);
1370
1371 let mut trimmed = 0usize;
1372 let mut bytes_freed = 0u64;
1373
1374 if mode == StorageBudgetMode::Warn && over_budget {
1375 tracing::warn!(
1376 "ACB storage budget warning: current={} projected={:?} limit={}",
1377 current_size,
1378 projected,
1379 max_bytes
1380 );
1381 }
1382
1383 if mode == StorageBudgetMode::AutoRollup && (over_budget || family_size > max_bytes) {
1384 let target_bytes = ((max_bytes as f64 * target_fraction as f64).round() as u64).max(1);
1385 backups.sort_by_key(|b| b.modified);
1386 for backup in backups {
1387 if family_size <= target_bytes {
1388 break;
1389 }
1390 if std::fs::remove_file(&backup.path).is_ok() {
1391 family_size = family_size.saturating_sub(backup.size);
1392 trimmed = trimmed.saturating_add(1);
1393 bytes_freed = bytes_freed.saturating_add(backup.size);
1394 }
1395 }
1396
1397 if trimmed > 0 {
1398 tracing::info!(
1399 "ACB storage budget rollup: trimmed_backups={} freed_bytes={} family_size={}",
1400 trimmed,
1401 bytes_freed,
1402 family_size
1403 );
1404 }
1405 }
1406
1407 Ok(AcbStorageBudgetReport {
1408 mode: mode.as_str(),
1409 max_bytes,
1410 horizon_years,
1411 target_fraction,
1412 current_size_bytes: current_size,
1413 projected_size_bytes: projected,
1414 family_size_bytes: family_size,
1415 over_budget,
1416 backups_trimmed: trimmed,
1417 bytes_freed,
1418 })
1419}
1420
1421fn list_backup_entries(out_path: &Path) -> Result<Vec<BackupEntry>, Box<dyn std::error::Error>> {
1422 let backups_dir = resolve_backup_dir(out_path);
1423 if !backups_dir.exists() {
1424 return Ok(Vec::new());
1425 }
1426
1427 let original_name = out_path
1428 .file_name()
1429 .and_then(|n| n.to_str())
1430 .unwrap_or("graph.acb");
1431
1432 let mut out = Vec::new();
1433 for entry in std::fs::read_dir(&backups_dir)? {
1434 let entry = entry?;
1435 let name = entry.file_name();
1436 let Some(name_str) = name.to_str() else {
1437 continue;
1438 };
1439 if !(name_str.starts_with(original_name) && name_str.ends_with(".bak")) {
1440 continue;
1441 }
1442 let meta = entry.metadata()?;
1443 out.push(BackupEntry {
1444 path: entry.path(),
1445 size: meta.len(),
1446 modified: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),
1447 });
1448 }
1449 Ok(out)
1450}
1451
1452fn projected_size_from_samples(
1453 backups: &[BackupEntry],
1454 current_modified: SystemTime,
1455 current_size: u64,
1456 horizon_years: u32,
1457) -> Option<u64> {
1458 let mut samples = backups
1459 .iter()
1460 .map(|b| (b.modified, b.size))
1461 .collect::<Vec<_>>();
1462 samples.push((current_modified, current_size));
1463 if samples.len() < 2 {
1464 return None;
1465 }
1466 samples.sort_by_key(|(ts, _)| *ts);
1467 let (first_ts, first_size) = samples.first().copied()?;
1468 let (last_ts, last_size) = samples.last().copied()?;
1469 if last_ts <= first_ts {
1470 return None;
1471 }
1472 let span_secs = last_ts
1473 .duration_since(first_ts)
1474 .ok()?
1475 .as_secs_f64()
1476 .max(1.0);
1477 let delta = (last_size as f64 - first_size as f64).max(0.0);
1478 if delta <= 0.0 {
1479 return Some(current_size);
1480 }
1481 let per_sec = delta / span_secs;
1482 let horizon_secs = (horizon_years.max(1) as f64) * 365.25 * 24.0 * 3600.0;
1483 let projected = (current_size as f64 + per_sec * horizon_secs).round();
1484 Some(projected.max(0.0).min(u64::MAX as f64) as u64)
1485}
1486
1487fn maybe_backup_existing_output(
1488 out_path: &Path,
1489) -> Result<Option<PathBuf>, Box<dyn std::error::Error>> {
1490 if !auto_backup_enabled() || !out_path.exists() || !out_path.is_file() {
1491 return Ok(None);
1492 }
1493
1494 let backups_dir = resolve_backup_dir(out_path);
1495 std::fs::create_dir_all(&backups_dir)?;
1496
1497 let original_name = out_path
1498 .file_name()
1499 .and_then(|n| n.to_str())
1500 .unwrap_or("graph.acb");
1501 let ts = chrono::Utc::now().format("%Y%m%d%H%M%S");
1502 let backup_path = backups_dir.join(format!("{original_name}.{ts}.bak"));
1503 std::fs::copy(out_path, &backup_path)?;
1504 prune_old_backups(&backups_dir, original_name, auto_backup_retention())?;
1505
1506 Ok(Some(backup_path))
1507}
1508
1509fn auto_backup_enabled() -> bool {
1510 match std::env::var("ACB_AUTO_BACKUP") {
1511 Ok(v) => {
1512 let value = v.trim().to_ascii_lowercase();
1513 value != "0" && value != "false" && value != "off" && value != "no"
1514 }
1515 Err(_) => true,
1516 }
1517}
1518
1519fn auto_backup_retention() -> usize {
1520 let default_retention = match read_env_string("ACB_AUTONOMIC_PROFILE")
1521 .unwrap_or_else(|| "desktop".to_string())
1522 .to_ascii_lowercase()
1523 .as_str()
1524 {
1525 "cloud" => 40,
1526 "aggressive" => 12,
1527 _ => 20,
1528 };
1529 std::env::var("ACB_AUTO_BACKUP_RETENTION")
1530 .ok()
1531 .and_then(|v| v.parse::<usize>().ok())
1532 .unwrap_or(default_retention)
1533 .max(1)
1534}
1535
1536fn resolve_backup_dir(out_path: &Path) -> PathBuf {
1537 if let Ok(custom) = std::env::var("ACB_AUTO_BACKUP_DIR") {
1538 let trimmed = custom.trim();
1539 if !trimmed.is_empty() {
1540 return PathBuf::from(trimmed);
1541 }
1542 }
1543 out_path
1544 .parent()
1545 .unwrap_or_else(|| Path::new("."))
1546 .join(".acb-backups")
1547}
1548
1549fn read_env_string(name: &str) -> Option<String> {
1550 std::env::var(name).ok().map(|v| v.trim().to_string())
1551}
1552
1553fn read_env_u64(name: &str, default_value: u64) -> u64 {
1554 std::env::var(name)
1555 .ok()
1556 .and_then(|v| v.parse::<u64>().ok())
1557 .unwrap_or(default_value)
1558}
1559
1560fn read_env_u32(name: &str, default_value: u32) -> u32 {
1561 std::env::var(name)
1562 .ok()
1563 .and_then(|v| v.parse::<u32>().ok())
1564 .unwrap_or(default_value)
1565}
1566
1567fn read_env_f32(name: &str, default_value: f32) -> f32 {
1568 std::env::var(name)
1569 .ok()
1570 .and_then(|v| v.parse::<f32>().ok())
1571 .unwrap_or(default_value)
1572}
1573
1574fn prune_old_backups(
1575 backup_dir: &Path,
1576 original_name: &str,
1577 retention: usize,
1578) -> Result<(), Box<dyn std::error::Error>> {
1579 let mut backups = std::fs::read_dir(backup_dir)?
1580 .filter_map(Result::ok)
1581 .filter(|entry| {
1582 entry
1583 .file_name()
1584 .to_str()
1585 .map(|name| name.starts_with(original_name) && name.ends_with(".bak"))
1586 .unwrap_or(false)
1587 })
1588 .collect::<Vec<_>>();
1589
1590 if backups.len() <= retention {
1591 return Ok(());
1592 }
1593
1594 backups.sort_by_key(|entry| {
1595 entry
1596 .metadata()
1597 .and_then(|m| m.modified())
1598 .ok()
1599 .unwrap_or(SystemTime::UNIX_EPOCH)
1600 });
1601
1602 let to_remove = backups.len().saturating_sub(retention);
1603 for entry in backups.into_iter().take(to_remove) {
1604 let _ = std::fs::remove_file(entry.path());
1605 }
1606 Ok(())
1607}
1608
1609fn cmd_budget(
1610 file: &Path,
1611 max_bytes: u64,
1612 horizon_years: u32,
1613 cli: &Cli,
1614) -> Result<(), Box<dyn std::error::Error>> {
1615 validate_acb_path(file)?;
1616 let s = styled(cli);
1617 let current_meta = std::fs::metadata(file)?;
1618 let current_size = current_meta.len();
1619 let current_modified = current_meta.modified().unwrap_or(SystemTime::now());
1620 let backups = list_backup_entries(file)?;
1621 let family_size = current_size.saturating_add(backups.iter().map(|b| b.size).sum::<u64>());
1622 let projected =
1623 projected_size_from_samples(&backups, current_modified, current_size, horizon_years);
1624 let over_budget = current_size > max_bytes || projected.map(|v| v > max_bytes).unwrap_or(false);
1625 let daily_budget_bytes = max_bytes as f64 / ((horizon_years.max(1) as f64) * 365.25);
1626
1627 let stdout = std::io::stdout();
1628 let mut out = stdout.lock();
1629
1630 match cli.format {
1631 OutputFormat::Text => {
1632 let status = if over_budget {
1633 s.red("over-budget")
1634 } else {
1635 s.green("within-budget")
1636 };
1637 let _ = writeln!(out, "\n {} {}\n", s.info(), s.bold("ACB Storage Budget"));
1638 let _ = writeln!(out, " File: {}", file.display());
1639 let _ = writeln!(out, " Current: {}", format_size(current_size));
1640 if let Some(v) = projected {
1641 let _ = writeln!(
1642 out,
1643 " Projected: {} ({}y)",
1644 format_size(v),
1645 horizon_years
1646 );
1647 } else {
1648 let _ = writeln!(
1649 out,
1650 " Projected: unavailable (need backup history samples)"
1651 );
1652 }
1653 let _ = writeln!(out, " Family: {}", format_size(family_size));
1654 let _ = writeln!(out, " Budget: {}", format_size(max_bytes));
1655 let _ = writeln!(out, " Status: {}", status);
1656 let _ = writeln!(
1657 out,
1658 " Guidance: {:.1} KB/day target growth",
1659 daily_budget_bytes / 1024.0
1660 );
1661 let _ = writeln!(
1662 out,
1663 " Suggested env: ACB_STORAGE_BUDGET_MODE=auto-rollup ACB_STORAGE_BUDGET_BYTES={} ACB_STORAGE_BUDGET_HORIZON_YEARS={}",
1664 max_bytes,
1665 horizon_years
1666 );
1667 let _ = writeln!(out);
1668 }
1669 OutputFormat::Json => {
1670 let obj = serde_json::json!({
1671 "file": file.display().to_string(),
1672 "current_size_bytes": current_size,
1673 "projected_size_bytes": projected,
1674 "family_size_bytes": family_size,
1675 "max_budget_bytes": max_bytes,
1676 "horizon_years": horizon_years,
1677 "over_budget": over_budget,
1678 "daily_budget_bytes": daily_budget_bytes,
1679 "daily_budget_kb": daily_budget_bytes / 1024.0,
1680 "guidance": {
1681 "recommended_policy_mode": if over_budget { "auto-rollup" } else { "warn" },
1682 "env": {
1683 "ACB_STORAGE_BUDGET_MODE": "auto-rollup|warn|off",
1684 "ACB_STORAGE_BUDGET_BYTES": max_bytes,
1685 "ACB_STORAGE_BUDGET_HORIZON_YEARS": horizon_years,
1686 }
1687 }
1688 });
1689 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1690 }
1691 }
1692 Ok(())
1693}
1694
1695fn cmd_info(file: &PathBuf, cli: &Cli) -> Result<(), Box<dyn std::error::Error>> {
1700 let s = styled(cli);
1701 validate_acb_path(file)?;
1702 let graph = AcbReader::read_from_file(file)?;
1703
1704 let data = std::fs::read(file)?;
1706 let header_bytes: [u8; 128] = data[..128]
1707 .try_into()
1708 .map_err(|_| "File too small for header")?;
1709 let header = FileHeader::from_bytes(&header_bytes)?;
1710 let file_size = data.len() as u64;
1711
1712 let stdout = std::io::stdout();
1713 let mut out = stdout.lock();
1714
1715 match cli.format {
1716 OutputFormat::Text => {
1717 let _ = writeln!(
1718 out,
1719 "\n {} {}",
1720 s.info(),
1721 s.bold(&file.display().to_string())
1722 );
1723 let _ = writeln!(out, " Version: v{}", header.version);
1724 let _ = writeln!(
1725 out,
1726 " Units: {}",
1727 s.bold(&graph.unit_count().to_string())
1728 );
1729 let _ = writeln!(
1730 out,
1731 " Edges: {}",
1732 s.bold(&graph.edge_count().to_string())
1733 );
1734 let _ = writeln!(
1735 out,
1736 " Languages: {}",
1737 s.bold(&graph.languages().len().to_string())
1738 );
1739 let _ = writeln!(out, " Dimension: {}", header.dimension);
1740 let _ = writeln!(out, " File size: {}", format_size(file_size));
1741 let _ = writeln!(out);
1742 for lang in graph.languages() {
1743 let count = graph.units().iter().filter(|u| u.language == *lang).count();
1744 let _ = writeln!(
1745 out,
1746 " {} {} {}",
1747 s.arrow(),
1748 s.cyan(&format!("{:12}", lang)),
1749 s.dim(&format!("{} units", count))
1750 );
1751 }
1752 let _ = writeln!(out);
1753 }
1754 OutputFormat::Json => {
1755 let mut lang_map = serde_json::Map::new();
1756 for lang in graph.languages() {
1757 let count = graph.units().iter().filter(|u| u.language == *lang).count();
1758 lang_map.insert(lang.to_string(), serde_json::json!(count));
1759 }
1760 let obj = serde_json::json!({
1761 "file": file.display().to_string(),
1762 "version": header.version,
1763 "units": graph.unit_count(),
1764 "edges": graph.edge_count(),
1765 "languages": graph.languages().len(),
1766 "dimension": header.dimension,
1767 "file_size_bytes": file_size,
1768 "language_breakdown": lang_map,
1769 });
1770 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1771 }
1772 }
1773
1774 Ok(())
1775}
1776
1777fn cmd_health(file: &Path, limit: usize, cli: &Cli) -> Result<(), Box<dyn std::error::Error>> {
1778 validate_acb_path(file)?;
1779 let graph = AcbReader::read_from_file(file)?;
1780 let engine = QueryEngine::new();
1781 let s = styled(cli);
1782
1783 let prophecy = engine.prophecy(
1784 &graph,
1785 ProphecyParams {
1786 top_k: limit,
1787 min_risk: 0.45,
1788 },
1789 )?;
1790 let test_gaps = engine.test_gap(
1791 &graph,
1792 TestGapParams {
1793 min_changes: 5,
1794 min_complexity: 10,
1795 unit_types: vec![],
1796 },
1797 )?;
1798 let hotspots = engine.hotspot_detection(
1799 &graph,
1800 HotspotParams {
1801 top_k: limit,
1802 min_score: 0.55,
1803 unit_types: vec![],
1804 },
1805 )?;
1806 let dead_code = engine.dead_code(
1807 &graph,
1808 DeadCodeParams {
1809 unit_types: vec![],
1810 include_tests_as_roots: true,
1811 },
1812 )?;
1813
1814 let high_risk = prophecy
1815 .predictions
1816 .iter()
1817 .filter(|p| p.risk_score >= 0.70)
1818 .count();
1819 let avg_risk = if prophecy.predictions.is_empty() {
1820 0.0
1821 } else {
1822 prophecy
1823 .predictions
1824 .iter()
1825 .map(|p| p.risk_score)
1826 .sum::<f32>()
1827 / prophecy.predictions.len() as f32
1828 };
1829 let status = if high_risk >= 3 || test_gaps.len() >= 8 {
1830 "fail"
1831 } else if high_risk > 0 || !test_gaps.is_empty() || !hotspots.is_empty() {
1832 "warn"
1833 } else {
1834 "pass"
1835 };
1836
1837 let stdout = std::io::stdout();
1838 let mut out = stdout.lock();
1839 match cli.format {
1840 OutputFormat::Text => {
1841 let status_label = match status {
1842 "pass" => s.green("PASS"),
1843 "warn" => s.yellow("WARN"),
1844 _ => s.red("FAIL"),
1845 };
1846 let _ = writeln!(
1847 out,
1848 "\n Graph health for {} [{}]\n",
1849 s.bold(&file.display().to_string()),
1850 status_label
1851 );
1852 let _ = writeln!(out, " Units: {}", graph.unit_count());
1853 let _ = writeln!(out, " Edges: {}", graph.edge_count());
1854 let _ = writeln!(out, " Avg risk: {:.2}", avg_risk);
1855 let _ = writeln!(out, " High risk: {}", high_risk);
1856 let _ = writeln!(out, " Test gaps: {}", test_gaps.len());
1857 let _ = writeln!(out, " Hotspots: {}", hotspots.len());
1858 let _ = writeln!(out, " Dead code: {}", dead_code.len());
1859 let _ = writeln!(out);
1860
1861 if !prophecy.predictions.is_empty() {
1862 let _ = writeln!(out, " Top risk predictions:");
1863 for p in prophecy.predictions.iter().take(5) {
1864 let name = graph
1865 .get_unit(p.unit_id)
1866 .map(|u| u.qualified_name.clone())
1867 .unwrap_or_else(|| format!("unit_{}", p.unit_id));
1868 let _ = writeln!(out, " {} {:.2} {}", s.arrow(), p.risk_score, name);
1869 }
1870 let _ = writeln!(out);
1871 }
1872
1873 if !test_gaps.is_empty() {
1874 let _ = writeln!(out, " Top test gaps:");
1875 for g in test_gaps.iter().take(5) {
1876 let name = graph
1877 .get_unit(g.unit_id)
1878 .map(|u| u.qualified_name.clone())
1879 .unwrap_or_else(|| format!("unit_{}", g.unit_id));
1880 let _ = writeln!(
1881 out,
1882 " {} {:.2} {} ({})",
1883 s.arrow(),
1884 g.priority,
1885 name,
1886 g.reason
1887 );
1888 }
1889 let _ = writeln!(out);
1890 }
1891
1892 let _ = writeln!(
1893 out,
1894 " Next: acb gate {} --unit-id <id> --max-risk 0.60",
1895 file.display()
1896 );
1897 let _ = writeln!(out);
1898 }
1899 OutputFormat::Json => {
1900 let predictions = prophecy
1901 .predictions
1902 .iter()
1903 .map(|p| {
1904 serde_json::json!({
1905 "unit_id": p.unit_id,
1906 "name": graph.get_unit(p.unit_id).map(|u| u.qualified_name.clone()).unwrap_or_default(),
1907 "risk_score": p.risk_score,
1908 "reason": p.reason,
1909 })
1910 })
1911 .collect::<Vec<_>>();
1912 let gaps = test_gaps
1913 .iter()
1914 .map(|g| {
1915 serde_json::json!({
1916 "unit_id": g.unit_id,
1917 "name": graph.get_unit(g.unit_id).map(|u| u.qualified_name.clone()).unwrap_or_default(),
1918 "priority": g.priority,
1919 "reason": g.reason,
1920 })
1921 })
1922 .collect::<Vec<_>>();
1923 let hotspot_rows = hotspots
1924 .iter()
1925 .map(|h| {
1926 serde_json::json!({
1927 "unit_id": h.unit_id,
1928 "name": graph.get_unit(h.unit_id).map(|u| u.qualified_name.clone()).unwrap_or_default(),
1929 "score": h.score,
1930 "factors": h.factors,
1931 })
1932 })
1933 .collect::<Vec<_>>();
1934 let dead_rows = dead_code
1935 .iter()
1936 .map(|u| {
1937 serde_json::json!({
1938 "unit_id": u.id,
1939 "name": u.qualified_name,
1940 "type": u.unit_type.label(),
1941 })
1942 })
1943 .collect::<Vec<_>>();
1944
1945 let obj = serde_json::json!({
1946 "status": status,
1947 "graph": file.display().to_string(),
1948 "summary": {
1949 "units": graph.unit_count(),
1950 "edges": graph.edge_count(),
1951 "avg_risk": avg_risk,
1952 "high_risk_count": high_risk,
1953 "test_gap_count": test_gaps.len(),
1954 "hotspot_count": hotspots.len(),
1955 "dead_code_count": dead_code.len(),
1956 },
1957 "risk_predictions": predictions,
1958 "test_gaps": gaps,
1959 "hotspots": hotspot_rows,
1960 "dead_code": dead_rows,
1961 });
1962 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1963 }
1964 }
1965
1966 Ok(())
1967}
1968
1969fn cmd_gate(
1970 file: &Path,
1971 unit_id: u64,
1972 max_risk: f32,
1973 depth: u32,
1974 require_tests: bool,
1975 cli: &Cli,
1976) -> Result<(), Box<dyn std::error::Error>> {
1977 validate_acb_path(file)?;
1978 let graph = AcbReader::read_from_file(file)?;
1979 let engine = QueryEngine::new();
1980 let s = styled(cli);
1981
1982 let result = engine.impact_analysis(
1983 &graph,
1984 ImpactParams {
1985 unit_id,
1986 max_depth: depth,
1987 edge_types: vec![],
1988 },
1989 )?;
1990 let untested_count = result.impacted.iter().filter(|u| !u.has_tests).count();
1991 let risk_pass = result.overall_risk <= max_risk;
1992 let test_pass = !require_tests || untested_count == 0;
1993 let passed = risk_pass && test_pass;
1994
1995 let stdout = std::io::stdout();
1996 let mut out = stdout.lock();
1997
1998 match cli.format {
1999 OutputFormat::Text => {
2000 let label = if passed {
2001 s.green("PASS")
2002 } else {
2003 s.red("FAIL")
2004 };
2005 let unit_name = graph
2006 .get_unit(unit_id)
2007 .map(|u| u.qualified_name.clone())
2008 .unwrap_or_else(|| format!("unit_{}", unit_id));
2009 let _ = writeln!(out, "\n Gate {} for {}\n", label, s.bold(&unit_name));
2010 let _ = writeln!(
2011 out,
2012 " Overall risk: {:.2} (max {:.2})",
2013 result.overall_risk, max_risk
2014 );
2015 let _ = writeln!(out, " Impacted: {}", result.impacted.len());
2016 let _ = writeln!(out, " Untested: {}", untested_count);
2017 let _ = writeln!(out, " Require tests: {}", require_tests);
2018 if !result.recommendations.is_empty() {
2019 let _ = writeln!(out);
2020 for rec in &result.recommendations {
2021 let _ = writeln!(out, " {} {}", s.info(), rec);
2022 }
2023 }
2024 let _ = writeln!(out);
2025 }
2026 OutputFormat::Json => {
2027 let obj = serde_json::json!({
2028 "gate": if passed { "pass" } else { "fail" },
2029 "file": file.display().to_string(),
2030 "unit_id": unit_id,
2031 "max_risk": max_risk,
2032 "overall_risk": result.overall_risk,
2033 "impacted_count": result.impacted.len(),
2034 "untested_count": untested_count,
2035 "require_tests": require_tests,
2036 "recommendations": result.recommendations,
2037 });
2038 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2039 }
2040 }
2041
2042 if !passed {
2043 return Err(format!(
2044 "{} gate failed: risk_pass={} test_pass={} (risk {:.2} / max {:.2}, untested {})",
2045 s.fail(),
2046 risk_pass,
2047 test_pass,
2048 result.overall_risk,
2049 max_risk,
2050 untested_count
2051 )
2052 .into());
2053 }
2054
2055 Ok(())
2056}
2057
2058fn cmd_query(
2063 file: &Path,
2064 query_type: &str,
2065 name: Option<&str>,
2066 unit_id: Option<u64>,
2067 depth: u32,
2068 limit: usize,
2069 cli: &Cli,
2070) -> Result<(), Box<dyn std::error::Error>> {
2071 validate_acb_path(file)?;
2072 let graph = AcbReader::read_from_file(file)?;
2073 let engine = QueryEngine::new();
2074 let s = styled(cli);
2075
2076 match query_type {
2077 "symbol" | "sym" | "s" => query_symbol(&graph, &engine, name, limit, cli, &s),
2078 "deps" | "dep" | "d" => query_deps(&graph, &engine, unit_id, depth, cli, &s),
2079 "rdeps" | "rdep" | "r" => query_rdeps(&graph, &engine, unit_id, depth, cli, &s),
2080 "impact" | "imp" | "i" => query_impact(&graph, &engine, unit_id, depth, cli, &s),
2081 "calls" | "call" | "c" => query_calls(&graph, &engine, unit_id, depth, cli, &s),
2082 "similar" | "sim" => query_similar(&graph, &engine, unit_id, limit, cli, &s),
2083 "prophecy" | "predict" | "p" => query_prophecy(&graph, &engine, limit, cli, &s),
2084 "stability" | "stab" => query_stability(&graph, &engine, unit_id, cli, &s),
2085 "coupling" | "couple" => query_coupling(&graph, &engine, unit_id, cli, &s),
2086 "test-gap" | "testgap" | "gaps" => query_test_gap(&graph, &engine, limit, cli, &s),
2087 "hotspot" | "hotspots" => query_hotspots(&graph, &engine, limit, cli, &s),
2088 "dead" | "dead-code" | "deadcode" => query_dead_code(&graph, &engine, limit, cli, &s),
2089 other => {
2090 let known = [
2091 "symbol",
2092 "deps",
2093 "rdeps",
2094 "impact",
2095 "calls",
2096 "similar",
2097 "prophecy",
2098 "stability",
2099 "coupling",
2100 "test-gap",
2101 "hotspots",
2102 "dead-code",
2103 ];
2104 let suggestion = known
2105 .iter()
2106 .filter(|k| k.starts_with(&other[..1.min(other.len())]))
2107 .copied()
2108 .collect::<Vec<_>>();
2109 let hint = if suggestion.is_empty() {
2110 format!("Available: {}", known.join(", "))
2111 } else {
2112 format!("Did you mean: {}?", suggestion.join(", "))
2113 };
2114 Err(format!(
2115 "{} Unknown query type: {}\n {} {}",
2116 s.fail(),
2117 other,
2118 s.info(),
2119 hint
2120 )
2121 .into())
2122 }
2123 }
2124}
2125
2126fn query_symbol(
2127 graph: &CodeGraph,
2128 engine: &QueryEngine,
2129 name: Option<&str>,
2130 limit: usize,
2131 cli: &Cli,
2132 s: &Styled,
2133) -> Result<(), Box<dyn std::error::Error>> {
2134 let search_name = name.ok_or_else(|| {
2135 format!(
2136 "{} --name is required for symbol queries\n {} Example: acb query file.acb symbol --name UserService",
2137 s.fail(),
2138 s.info()
2139 )
2140 })?;
2141 let params = SymbolLookupParams {
2142 name: search_name.to_string(),
2143 mode: MatchMode::Contains,
2144 limit,
2145 ..Default::default()
2146 };
2147 let results = engine.symbol_lookup(graph, params)?;
2148
2149 let stdout = std::io::stdout();
2150 let mut out = stdout.lock();
2151
2152 match cli.format {
2153 OutputFormat::Text => {
2154 let _ = writeln!(
2155 out,
2156 "\n Symbol lookup: {} ({} results)\n",
2157 s.bold(&format!("\"{}\"", search_name)),
2158 results.len()
2159 );
2160 if results.is_empty() {
2161 let _ = writeln!(
2162 out,
2163 " {} No matches found. Try a broader search term.",
2164 s.warn()
2165 );
2166 }
2167 for (i, unit) in results.iter().enumerate() {
2168 let _ = writeln!(
2169 out,
2170 " {:>3}. {} {} {}",
2171 s.dim(&format!("#{}", i + 1)),
2172 s.bold(&unit.qualified_name),
2173 s.dim(&format!("({})", unit.unit_type)),
2174 s.dim(&format!(
2175 "{}:{}",
2176 unit.file_path.display(),
2177 unit.span.start_line
2178 ))
2179 );
2180 }
2181 let _ = writeln!(out);
2182 }
2183 OutputFormat::Json => {
2184 let entries: Vec<serde_json::Value> = results
2185 .iter()
2186 .map(|u| {
2187 serde_json::json!({
2188 "id": u.id,
2189 "name": u.name,
2190 "qualified_name": u.qualified_name,
2191 "unit_type": u.unit_type.label(),
2192 "language": u.language.name(),
2193 "file": u.file_path.display().to_string(),
2194 "line": u.span.start_line,
2195 })
2196 })
2197 .collect();
2198 let obj = serde_json::json!({
2199 "query": "symbol",
2200 "name": search_name,
2201 "count": results.len(),
2202 "results": entries,
2203 });
2204 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2205 }
2206 }
2207 Ok(())
2208}
2209
2210fn query_deps(
2211 graph: &CodeGraph,
2212 engine: &QueryEngine,
2213 unit_id: Option<u64>,
2214 depth: u32,
2215 cli: &Cli,
2216 s: &Styled,
2217) -> Result<(), Box<dyn std::error::Error>> {
2218 let uid = unit_id.ok_or_else(|| {
2219 format!(
2220 "{} --unit-id is required for deps queries\n {} Find an ID first: acb query file.acb symbol --name <name>",
2221 s.fail(), s.info()
2222 )
2223 })?;
2224 let params = DependencyParams {
2225 unit_id: uid,
2226 max_depth: depth,
2227 edge_types: vec![],
2228 include_transitive: true,
2229 };
2230 let result = engine.dependency_graph(graph, params)?;
2231
2232 let stdout = std::io::stdout();
2233 let mut out = stdout.lock();
2234
2235 match cli.format {
2236 OutputFormat::Text => {
2237 let root_name = graph
2238 .get_unit(uid)
2239 .map(|u| u.qualified_name.as_str())
2240 .unwrap_or("?");
2241 let _ = writeln!(
2242 out,
2243 "\n Dependencies of {} ({} found)\n",
2244 s.bold(root_name),
2245 result.nodes.len()
2246 );
2247 for node in &result.nodes {
2248 let unit_name = graph
2249 .get_unit(node.unit_id)
2250 .map(|u| u.qualified_name.as_str())
2251 .unwrap_or("?");
2252 let indent = " ".repeat(node.depth as usize);
2253 let _ = writeln!(
2254 out,
2255 " {}{} {} {}",
2256 indent,
2257 s.arrow(),
2258 s.cyan(unit_name),
2259 s.dim(&format!("[id:{}]", node.unit_id))
2260 );
2261 }
2262 let _ = writeln!(out);
2263 }
2264 OutputFormat::Json => {
2265 let entries: Vec<serde_json::Value> = result
2266 .nodes
2267 .iter()
2268 .map(|n| {
2269 let unit_name = graph
2270 .get_unit(n.unit_id)
2271 .map(|u| u.qualified_name.clone())
2272 .unwrap_or_default();
2273 serde_json::json!({
2274 "unit_id": n.unit_id,
2275 "name": unit_name,
2276 "depth": n.depth,
2277 })
2278 })
2279 .collect();
2280 let obj = serde_json::json!({
2281 "query": "deps",
2282 "root_id": uid,
2283 "count": result.nodes.len(),
2284 "results": entries,
2285 });
2286 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2287 }
2288 }
2289 Ok(())
2290}
2291
2292fn query_rdeps(
2293 graph: &CodeGraph,
2294 engine: &QueryEngine,
2295 unit_id: Option<u64>,
2296 depth: u32,
2297 cli: &Cli,
2298 s: &Styled,
2299) -> Result<(), Box<dyn std::error::Error>> {
2300 let uid = unit_id.ok_or_else(|| {
2301 format!(
2302 "{} --unit-id is required for rdeps queries\n {} Find an ID first: acb query file.acb symbol --name <name>",
2303 s.fail(), s.info()
2304 )
2305 })?;
2306 let params = DependencyParams {
2307 unit_id: uid,
2308 max_depth: depth,
2309 edge_types: vec![],
2310 include_transitive: true,
2311 };
2312 let result = engine.reverse_dependency(graph, params)?;
2313
2314 let stdout = std::io::stdout();
2315 let mut out = stdout.lock();
2316
2317 match cli.format {
2318 OutputFormat::Text => {
2319 let root_name = graph
2320 .get_unit(uid)
2321 .map(|u| u.qualified_name.as_str())
2322 .unwrap_or("?");
2323 let _ = writeln!(
2324 out,
2325 "\n Reverse dependencies of {} ({} found)\n",
2326 s.bold(root_name),
2327 result.nodes.len()
2328 );
2329 for node in &result.nodes {
2330 let unit_name = graph
2331 .get_unit(node.unit_id)
2332 .map(|u| u.qualified_name.as_str())
2333 .unwrap_or("?");
2334 let indent = " ".repeat(node.depth as usize);
2335 let _ = writeln!(
2336 out,
2337 " {}{} {} {}",
2338 indent,
2339 s.arrow(),
2340 s.cyan(unit_name),
2341 s.dim(&format!("[id:{}]", node.unit_id))
2342 );
2343 }
2344 let _ = writeln!(out);
2345 }
2346 OutputFormat::Json => {
2347 let entries: Vec<serde_json::Value> = result
2348 .nodes
2349 .iter()
2350 .map(|n| {
2351 let unit_name = graph
2352 .get_unit(n.unit_id)
2353 .map(|u| u.qualified_name.clone())
2354 .unwrap_or_default();
2355 serde_json::json!({
2356 "unit_id": n.unit_id,
2357 "name": unit_name,
2358 "depth": n.depth,
2359 })
2360 })
2361 .collect();
2362 let obj = serde_json::json!({
2363 "query": "rdeps",
2364 "root_id": uid,
2365 "count": result.nodes.len(),
2366 "results": entries,
2367 });
2368 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2369 }
2370 }
2371 Ok(())
2372}
2373
2374fn query_impact(
2375 graph: &CodeGraph,
2376 engine: &QueryEngine,
2377 unit_id: Option<u64>,
2378 depth: u32,
2379 cli: &Cli,
2380 s: &Styled,
2381) -> Result<(), Box<dyn std::error::Error>> {
2382 let uid =
2383 unit_id.ok_or_else(|| format!("{} --unit-id is required for impact queries", s.fail()))?;
2384 let params = ImpactParams {
2385 unit_id: uid,
2386 max_depth: depth,
2387 edge_types: vec![],
2388 };
2389 let result = engine.impact_analysis(graph, params)?;
2390
2391 let stdout = std::io::stdout();
2392 let mut out = stdout.lock();
2393
2394 match cli.format {
2395 OutputFormat::Text => {
2396 let root_name = graph
2397 .get_unit(uid)
2398 .map(|u| u.qualified_name.as_str())
2399 .unwrap_or("?");
2400
2401 let risk_label = if result.overall_risk >= 0.7 {
2402 s.red("HIGH")
2403 } else if result.overall_risk >= 0.4 {
2404 s.yellow("MEDIUM")
2405 } else {
2406 s.green("LOW")
2407 };
2408
2409 let _ = writeln!(
2410 out,
2411 "\n Impact analysis for {} (risk: {})\n",
2412 s.bold(root_name),
2413 risk_label,
2414 );
2415 let _ = writeln!(
2416 out,
2417 " {} impacted units, overall risk {:.2}\n",
2418 result.impacted.len(),
2419 result.overall_risk
2420 );
2421 for imp in &result.impacted {
2422 let unit_name = graph
2423 .get_unit(imp.unit_id)
2424 .map(|u| u.qualified_name.as_str())
2425 .unwrap_or("?");
2426 let risk_sym = if imp.risk_score >= 0.7 {
2427 s.fail()
2428 } else if imp.risk_score >= 0.4 {
2429 s.warn()
2430 } else {
2431 s.ok()
2432 };
2433 let test_badge = if imp.has_tests {
2434 s.green("tested")
2435 } else {
2436 s.red("untested")
2437 };
2438 let _ = writeln!(
2439 out,
2440 " {} {} {} risk:{:.2} {}",
2441 risk_sym,
2442 s.cyan(unit_name),
2443 s.dim(&format!("(depth {})", imp.depth)),
2444 imp.risk_score,
2445 test_badge,
2446 );
2447 }
2448 if !result.recommendations.is_empty() {
2449 let _ = writeln!(out);
2450 for rec in &result.recommendations {
2451 let _ = writeln!(out, " {} {}", s.info(), rec);
2452 }
2453 }
2454 let _ = writeln!(out);
2455 }
2456 OutputFormat::Json => {
2457 let entries: Vec<serde_json::Value> = result
2458 .impacted
2459 .iter()
2460 .map(|imp| {
2461 serde_json::json!({
2462 "unit_id": imp.unit_id,
2463 "depth": imp.depth,
2464 "risk_score": imp.risk_score,
2465 "has_tests": imp.has_tests,
2466 })
2467 })
2468 .collect();
2469 let obj = serde_json::json!({
2470 "query": "impact",
2471 "root_id": uid,
2472 "count": result.impacted.len(),
2473 "overall_risk": result.overall_risk,
2474 "results": entries,
2475 "recommendations": result.recommendations,
2476 });
2477 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2478 }
2479 }
2480 Ok(())
2481}
2482
2483fn query_calls(
2484 graph: &CodeGraph,
2485 engine: &QueryEngine,
2486 unit_id: Option<u64>,
2487 depth: u32,
2488 cli: &Cli,
2489 s: &Styled,
2490) -> Result<(), Box<dyn std::error::Error>> {
2491 let uid =
2492 unit_id.ok_or_else(|| format!("{} --unit-id is required for calls queries", s.fail()))?;
2493 let params = CallGraphParams {
2494 unit_id: uid,
2495 direction: CallDirection::Both,
2496 max_depth: depth,
2497 };
2498 let result = engine.call_graph(graph, params)?;
2499
2500 let stdout = std::io::stdout();
2501 let mut out = stdout.lock();
2502
2503 match cli.format {
2504 OutputFormat::Text => {
2505 let root_name = graph
2506 .get_unit(uid)
2507 .map(|u| u.qualified_name.as_str())
2508 .unwrap_or("?");
2509 let _ = writeln!(
2510 out,
2511 "\n Call graph for {} ({} nodes)\n",
2512 s.bold(root_name),
2513 result.nodes.len()
2514 );
2515 for (nid, d) in &result.nodes {
2516 let unit_name = graph
2517 .get_unit(*nid)
2518 .map(|u| u.qualified_name.as_str())
2519 .unwrap_or("?");
2520 let indent = " ".repeat(*d as usize);
2521 let _ = writeln!(out, " {}{} {}", indent, s.arrow(), s.cyan(unit_name),);
2522 }
2523 let _ = writeln!(out);
2524 }
2525 OutputFormat::Json => {
2526 let entries: Vec<serde_json::Value> = result
2527 .nodes
2528 .iter()
2529 .map(|(nid, d)| {
2530 let unit_name = graph
2531 .get_unit(*nid)
2532 .map(|u| u.qualified_name.clone())
2533 .unwrap_or_default();
2534 serde_json::json!({
2535 "unit_id": nid,
2536 "name": unit_name,
2537 "depth": d,
2538 })
2539 })
2540 .collect();
2541 let obj = serde_json::json!({
2542 "query": "calls",
2543 "root_id": uid,
2544 "count": result.nodes.len(),
2545 "results": entries,
2546 });
2547 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2548 }
2549 }
2550 Ok(())
2551}
2552
2553fn query_similar(
2554 graph: &CodeGraph,
2555 engine: &QueryEngine,
2556 unit_id: Option<u64>,
2557 limit: usize,
2558 cli: &Cli,
2559 s: &Styled,
2560) -> Result<(), Box<dyn std::error::Error>> {
2561 let uid =
2562 unit_id.ok_or_else(|| format!("{} --unit-id is required for similar queries", s.fail()))?;
2563 let params = SimilarityParams {
2564 unit_id: uid,
2565 top_k: limit,
2566 min_similarity: 0.0,
2567 };
2568 let results = engine.similarity(graph, params)?;
2569
2570 let stdout = std::io::stdout();
2571 let mut out = stdout.lock();
2572
2573 match cli.format {
2574 OutputFormat::Text => {
2575 let root_name = graph
2576 .get_unit(uid)
2577 .map(|u| u.qualified_name.as_str())
2578 .unwrap_or("?");
2579 let _ = writeln!(
2580 out,
2581 "\n Similar to {} ({} matches)\n",
2582 s.bold(root_name),
2583 results.len()
2584 );
2585 for (i, m) in results.iter().enumerate() {
2586 let unit_name = graph
2587 .get_unit(m.unit_id)
2588 .map(|u| u.qualified_name.as_str())
2589 .unwrap_or("?");
2590 let score_str = format!("{:.2}%", m.score * 100.0);
2591 let _ = writeln!(
2592 out,
2593 " {:>3}. {} {} {}",
2594 s.dim(&format!("#{}", i + 1)),
2595 s.cyan(unit_name),
2596 s.dim(&format!("[id:{}]", m.unit_id)),
2597 s.yellow(&score_str),
2598 );
2599 }
2600 let _ = writeln!(out);
2601 }
2602 OutputFormat::Json => {
2603 let entries: Vec<serde_json::Value> = results
2604 .iter()
2605 .map(|m| {
2606 serde_json::json!({
2607 "unit_id": m.unit_id,
2608 "score": m.score,
2609 })
2610 })
2611 .collect();
2612 let obj = serde_json::json!({
2613 "query": "similar",
2614 "root_id": uid,
2615 "count": results.len(),
2616 "results": entries,
2617 });
2618 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2619 }
2620 }
2621 Ok(())
2622}
2623
2624fn query_prophecy(
2625 graph: &CodeGraph,
2626 engine: &QueryEngine,
2627 limit: usize,
2628 cli: &Cli,
2629 s: &Styled,
2630) -> Result<(), Box<dyn std::error::Error>> {
2631 let params = ProphecyParams {
2632 top_k: limit,
2633 min_risk: 0.0,
2634 };
2635 let result = engine.prophecy(graph, params)?;
2636
2637 let stdout = std::io::stdout();
2638 let mut out = stdout.lock();
2639
2640 match cli.format {
2641 OutputFormat::Text => {
2642 let _ = writeln!(
2643 out,
2644 "\n {} Code prophecy ({} predictions)\n",
2645 s.info(),
2646 result.predictions.len()
2647 );
2648 if result.predictions.is_empty() {
2649 let _ = writeln!(
2650 out,
2651 " {} No high-risk predictions. Codebase looks stable!",
2652 s.ok()
2653 );
2654 }
2655 for pred in &result.predictions {
2656 let unit_name = graph
2657 .get_unit(pred.unit_id)
2658 .map(|u| u.qualified_name.as_str())
2659 .unwrap_or("?");
2660 let risk_sym = if pred.risk_score >= 0.7 {
2661 s.fail()
2662 } else if pred.risk_score >= 0.4 {
2663 s.warn()
2664 } else {
2665 s.ok()
2666 };
2667 let _ = writeln!(
2668 out,
2669 " {} {} {}: {}",
2670 risk_sym,
2671 s.cyan(unit_name),
2672 s.dim(&format!("(risk {:.2})", pred.risk_score)),
2673 pred.reason,
2674 );
2675 }
2676 let _ = writeln!(out);
2677 }
2678 OutputFormat::Json => {
2679 let entries: Vec<serde_json::Value> = result
2680 .predictions
2681 .iter()
2682 .map(|p| {
2683 serde_json::json!({
2684 "unit_id": p.unit_id,
2685 "risk_score": p.risk_score,
2686 "reason": p.reason,
2687 })
2688 })
2689 .collect();
2690 let obj = serde_json::json!({
2691 "query": "prophecy",
2692 "count": result.predictions.len(),
2693 "results": entries,
2694 });
2695 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2696 }
2697 }
2698 Ok(())
2699}
2700
2701fn query_stability(
2702 graph: &CodeGraph,
2703 engine: &QueryEngine,
2704 unit_id: Option<u64>,
2705 cli: &Cli,
2706 s: &Styled,
2707) -> Result<(), Box<dyn std::error::Error>> {
2708 let uid = unit_id
2709 .ok_or_else(|| format!("{} --unit-id is required for stability queries", s.fail()))?;
2710 let result: StabilityResult = engine.stability_analysis(graph, uid)?;
2711
2712 let stdout = std::io::stdout();
2713 let mut out = stdout.lock();
2714
2715 match cli.format {
2716 OutputFormat::Text => {
2717 let root_name = graph
2718 .get_unit(uid)
2719 .map(|u| u.qualified_name.as_str())
2720 .unwrap_or("?");
2721
2722 let score_color = if result.overall_score >= 0.7 {
2723 s.green(&format!("{:.2}", result.overall_score))
2724 } else if result.overall_score >= 0.4 {
2725 s.yellow(&format!("{:.2}", result.overall_score))
2726 } else {
2727 s.red(&format!("{:.2}", result.overall_score))
2728 };
2729
2730 let _ = writeln!(
2731 out,
2732 "\n Stability of {}: {}\n",
2733 s.bold(root_name),
2734 score_color,
2735 );
2736 for factor in &result.factors {
2737 let _ = writeln!(
2738 out,
2739 " {} {} = {:.2}: {}",
2740 s.arrow(),
2741 s.bold(&factor.name),
2742 factor.value,
2743 s.dim(&factor.description),
2744 );
2745 }
2746 let _ = writeln!(out, "\n {} {}", s.info(), result.recommendation);
2747 let _ = writeln!(out);
2748 }
2749 OutputFormat::Json => {
2750 let factors: Vec<serde_json::Value> = result
2751 .factors
2752 .iter()
2753 .map(|f| {
2754 serde_json::json!({
2755 "name": f.name,
2756 "value": f.value,
2757 "description": f.description,
2758 })
2759 })
2760 .collect();
2761 let obj = serde_json::json!({
2762 "query": "stability",
2763 "unit_id": uid,
2764 "overall_score": result.overall_score,
2765 "factors": factors,
2766 "recommendation": result.recommendation,
2767 });
2768 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2769 }
2770 }
2771 Ok(())
2772}
2773
2774fn query_coupling(
2775 graph: &CodeGraph,
2776 engine: &QueryEngine,
2777 unit_id: Option<u64>,
2778 cli: &Cli,
2779 s: &Styled,
2780) -> Result<(), Box<dyn std::error::Error>> {
2781 let params = CouplingParams {
2782 unit_id,
2783 min_strength: 0.0,
2784 };
2785 let results = engine.coupling_detection(graph, params)?;
2786
2787 let stdout = std::io::stdout();
2788 let mut out = stdout.lock();
2789
2790 match cli.format {
2791 OutputFormat::Text => {
2792 let _ = writeln!(
2793 out,
2794 "\n Coupling analysis ({} pairs detected)\n",
2795 results.len()
2796 );
2797 if results.is_empty() {
2798 let _ = writeln!(out, " {} No tightly coupled pairs detected.", s.ok());
2799 }
2800 for c in &results {
2801 let name_a = graph
2802 .get_unit(c.unit_a)
2803 .map(|u| u.qualified_name.as_str())
2804 .unwrap_or("?");
2805 let name_b = graph
2806 .get_unit(c.unit_b)
2807 .map(|u| u.qualified_name.as_str())
2808 .unwrap_or("?");
2809 let strength_str = format!("{:.0}%", c.strength * 100.0);
2810 let _ = writeln!(
2811 out,
2812 " {} {} {} {} {}",
2813 s.warn(),
2814 s.cyan(name_a),
2815 s.dim("<->"),
2816 s.cyan(name_b),
2817 s.yellow(&strength_str),
2818 );
2819 }
2820 let _ = writeln!(out);
2821 }
2822 OutputFormat::Json => {
2823 let entries: Vec<serde_json::Value> = results
2824 .iter()
2825 .map(|c| {
2826 serde_json::json!({
2827 "unit_a": c.unit_a,
2828 "unit_b": c.unit_b,
2829 "strength": c.strength,
2830 "kind": format!("{:?}", c.kind),
2831 })
2832 })
2833 .collect();
2834 let obj = serde_json::json!({
2835 "query": "coupling",
2836 "count": results.len(),
2837 "results": entries,
2838 });
2839 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2840 }
2841 }
2842 Ok(())
2843}
2844
2845fn query_test_gap(
2846 graph: &CodeGraph,
2847 engine: &QueryEngine,
2848 limit: usize,
2849 cli: &Cli,
2850 s: &Styled,
2851) -> Result<(), Box<dyn std::error::Error>> {
2852 let mut gaps = engine.test_gap(
2853 graph,
2854 TestGapParams {
2855 min_changes: 5,
2856 min_complexity: 10,
2857 unit_types: vec![],
2858 },
2859 )?;
2860 if limit > 0 {
2861 gaps.truncate(limit);
2862 }
2863
2864 let stdout = std::io::stdout();
2865 let mut out = stdout.lock();
2866 match cli.format {
2867 OutputFormat::Text => {
2868 let _ = writeln!(out, "\n Test gaps ({} results)\n", gaps.len());
2869 for g in &gaps {
2870 let name = graph
2871 .get_unit(g.unit_id)
2872 .map(|u| u.qualified_name.as_str())
2873 .unwrap_or("?");
2874 let _ = writeln!(
2875 out,
2876 " {} {} priority:{:.2} {}",
2877 s.arrow(),
2878 s.cyan(name),
2879 g.priority,
2880 s.dim(&g.reason)
2881 );
2882 }
2883 let _ = writeln!(out);
2884 }
2885 OutputFormat::Json => {
2886 let rows = gaps
2887 .iter()
2888 .map(|g| {
2889 serde_json::json!({
2890 "unit_id": g.unit_id,
2891 "name": graph.get_unit(g.unit_id).map(|u| u.qualified_name.clone()).unwrap_or_default(),
2892 "priority": g.priority,
2893 "reason": g.reason,
2894 })
2895 })
2896 .collect::<Vec<_>>();
2897 let obj = serde_json::json!({
2898 "query": "test-gap",
2899 "count": rows.len(),
2900 "results": rows,
2901 });
2902 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2903 }
2904 }
2905 Ok(())
2906}
2907
2908fn query_hotspots(
2909 graph: &CodeGraph,
2910 engine: &QueryEngine,
2911 limit: usize,
2912 cli: &Cli,
2913 s: &Styled,
2914) -> Result<(), Box<dyn std::error::Error>> {
2915 let hotspots = engine.hotspot_detection(
2916 graph,
2917 HotspotParams {
2918 top_k: limit,
2919 min_score: 0.55,
2920 unit_types: vec![],
2921 },
2922 )?;
2923
2924 let stdout = std::io::stdout();
2925 let mut out = stdout.lock();
2926 match cli.format {
2927 OutputFormat::Text => {
2928 let _ = writeln!(out, "\n Hotspots ({} results)\n", hotspots.len());
2929 for h in &hotspots {
2930 let name = graph
2931 .get_unit(h.unit_id)
2932 .map(|u| u.qualified_name.as_str())
2933 .unwrap_or("?");
2934 let _ = writeln!(out, " {} {} score:{:.2}", s.arrow(), s.cyan(name), h.score);
2935 }
2936 let _ = writeln!(out);
2937 }
2938 OutputFormat::Json => {
2939 let rows = hotspots
2940 .iter()
2941 .map(|h| {
2942 serde_json::json!({
2943 "unit_id": h.unit_id,
2944 "name": graph.get_unit(h.unit_id).map(|u| u.qualified_name.clone()).unwrap_or_default(),
2945 "score": h.score,
2946 "factors": h.factors,
2947 })
2948 })
2949 .collect::<Vec<_>>();
2950 let obj = serde_json::json!({
2951 "query": "hotspots",
2952 "count": rows.len(),
2953 "results": rows,
2954 });
2955 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
2956 }
2957 }
2958 Ok(())
2959}
2960
2961fn query_dead_code(
2962 graph: &CodeGraph,
2963 engine: &QueryEngine,
2964 limit: usize,
2965 cli: &Cli,
2966 s: &Styled,
2967) -> Result<(), Box<dyn std::error::Error>> {
2968 let mut dead = engine.dead_code(
2969 graph,
2970 DeadCodeParams {
2971 unit_types: vec![],
2972 include_tests_as_roots: true,
2973 },
2974 )?;
2975 if limit > 0 {
2976 dead.truncate(limit);
2977 }
2978
2979 let stdout = std::io::stdout();
2980 let mut out = stdout.lock();
2981 match cli.format {
2982 OutputFormat::Text => {
2983 let _ = writeln!(out, "\n Dead code ({} results)\n", dead.len());
2984 for unit in &dead {
2985 let _ = writeln!(
2986 out,
2987 " {} {} {}",
2988 s.arrow(),
2989 s.cyan(&unit.qualified_name),
2990 s.dim(&format!("({})", unit.unit_type.label()))
2991 );
2992 }
2993 let _ = writeln!(out);
2994 }
2995 OutputFormat::Json => {
2996 let rows = dead
2997 .iter()
2998 .map(|u| {
2999 serde_json::json!({
3000 "unit_id": u.id,
3001 "name": u.qualified_name,
3002 "unit_type": u.unit_type.label(),
3003 "file": u.file_path.display().to_string(),
3004 "line": u.span.start_line,
3005 })
3006 })
3007 .collect::<Vec<_>>();
3008 let obj = serde_json::json!({
3009 "query": "dead-code",
3010 "count": rows.len(),
3011 "results": rows,
3012 });
3013 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
3014 }
3015 }
3016 Ok(())
3017}
3018
3019fn cmd_get(file: &Path, unit_id: u64, cli: &Cli) -> Result<(), Box<dyn std::error::Error>> {
3024 let s = styled(cli);
3025 validate_acb_path(file)?;
3026 let graph = AcbReader::read_from_file(file)?;
3027
3028 let unit = graph.get_unit(unit_id).ok_or_else(|| {
3029 format!(
3030 "{} Unit {} not found\n {} Use 'acb query ... symbol' to find valid unit IDs",
3031 s.fail(),
3032 unit_id,
3033 s.info()
3034 )
3035 })?;
3036
3037 let outgoing = graph.edges_from(unit_id);
3038 let incoming = graph.edges_to(unit_id);
3039
3040 let stdout = std::io::stdout();
3041 let mut out = stdout.lock();
3042
3043 match cli.format {
3044 OutputFormat::Text => {
3045 let _ = writeln!(
3046 out,
3047 "\n {} {}",
3048 s.info(),
3049 s.bold(&format!("Unit {}", unit.id))
3050 );
3051 let _ = writeln!(out, " Name: {}", s.cyan(&unit.name));
3052 let _ = writeln!(out, " Qualified name: {}", s.bold(&unit.qualified_name));
3053 let _ = writeln!(out, " Type: {}", unit.unit_type);
3054 let _ = writeln!(out, " Language: {}", unit.language);
3055 let _ = writeln!(
3056 out,
3057 " File: {}",
3058 s.cyan(&unit.file_path.display().to_string())
3059 );
3060 let _ = writeln!(out, " Span: {}", unit.span);
3061 let _ = writeln!(out, " Visibility: {}", unit.visibility);
3062 let _ = writeln!(out, " Complexity: {}", unit.complexity);
3063 if unit.is_async {
3064 let _ = writeln!(out, " Async: {}", s.green("yes"));
3065 }
3066 if unit.is_generator {
3067 let _ = writeln!(out, " Generator: {}", s.green("yes"));
3068 }
3069
3070 let stability_str = format!("{:.2}", unit.stability_score);
3071 let stability_color = if unit.stability_score >= 0.7 {
3072 s.green(&stability_str)
3073 } else if unit.stability_score >= 0.4 {
3074 s.yellow(&stability_str)
3075 } else {
3076 s.red(&stability_str)
3077 };
3078 let _ = writeln!(out, " Stability: {}", stability_color);
3079
3080 if let Some(sig) = &unit.signature {
3081 let _ = writeln!(out, " Signature: {}", s.dim(sig));
3082 }
3083 if let Some(doc) = &unit.doc_summary {
3084 let _ = writeln!(out, " Doc: {}", s.dim(doc));
3085 }
3086
3087 if !outgoing.is_empty() {
3088 let _ = writeln!(
3089 out,
3090 "\n {} Outgoing edges ({})",
3091 s.arrow(),
3092 outgoing.len()
3093 );
3094 for edge in &outgoing {
3095 let target_name = graph
3096 .get_unit(edge.target_id)
3097 .map(|u| u.qualified_name.as_str())
3098 .unwrap_or("?");
3099 let _ = writeln!(
3100 out,
3101 " {} {} {}",
3102 s.arrow(),
3103 s.cyan(target_name),
3104 s.dim(&format!("({})", edge.edge_type))
3105 );
3106 }
3107 }
3108 if !incoming.is_empty() {
3109 let _ = writeln!(
3110 out,
3111 "\n {} Incoming edges ({})",
3112 s.arrow(),
3113 incoming.len()
3114 );
3115 for edge in &incoming {
3116 let source_name = graph
3117 .get_unit(edge.source_id)
3118 .map(|u| u.qualified_name.as_str())
3119 .unwrap_or("?");
3120 let _ = writeln!(
3121 out,
3122 " {} {} {}",
3123 s.arrow(),
3124 s.cyan(source_name),
3125 s.dim(&format!("({})", edge.edge_type))
3126 );
3127 }
3128 }
3129 let _ = writeln!(out);
3130 }
3131 OutputFormat::Json => {
3132 let out_edges: Vec<serde_json::Value> = outgoing
3133 .iter()
3134 .map(|e| {
3135 serde_json::json!({
3136 "target_id": e.target_id,
3137 "edge_type": e.edge_type.label(),
3138 "weight": e.weight,
3139 })
3140 })
3141 .collect();
3142 let in_edges: Vec<serde_json::Value> = incoming
3143 .iter()
3144 .map(|e| {
3145 serde_json::json!({
3146 "source_id": e.source_id,
3147 "edge_type": e.edge_type.label(),
3148 "weight": e.weight,
3149 })
3150 })
3151 .collect();
3152 let obj = serde_json::json!({
3153 "id": unit.id,
3154 "name": unit.name,
3155 "qualified_name": unit.qualified_name,
3156 "unit_type": unit.unit_type.label(),
3157 "language": unit.language.name(),
3158 "file": unit.file_path.display().to_string(),
3159 "span": unit.span.to_string(),
3160 "visibility": unit.visibility.to_string(),
3161 "complexity": unit.complexity,
3162 "is_async": unit.is_async,
3163 "is_generator": unit.is_generator,
3164 "stability_score": unit.stability_score,
3165 "signature": unit.signature,
3166 "doc_summary": unit.doc_summary,
3167 "outgoing_edges": out_edges,
3168 "incoming_edges": in_edges,
3169 });
3170 let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
3171 }
3172 }
3173
3174 Ok(())
3175}