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