Skip to main content

agentic_codebase/cli/
commands.rs

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