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