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