Skip to main content

agentic_codebase/cli/
commands.rs

1//! CLI command implementations.
2//!
3//! Defines the `Cli` struct (clap derive) and a top-level `run` function that
4//! dispatches to each subcommand: `compile`, `info`, `query`, `get`.
5
6use std::io::Write as _;
7use std::path::{Path, PathBuf};
8use std::time::{Instant, SystemTime};
9
10use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
11use clap_complete::Shell;
12
13use crate::cli::output::{format_size, progress, progress_done, Styled};
14use crate::engine::query::{
15    CallDirection, CallGraphParams, CouplingParams, DependencyParams, ImpactParams, MatchMode,
16    ProphecyParams, QueryEngine, SimilarityParams, StabilityResult, SymbolLookupParams,
17};
18use crate::format::{AcbReader, AcbWriter};
19use crate::graph::CodeGraph;
20use crate::parse::parser::{ParseOptions, Parser as AcbParser};
21use crate::semantic::analyzer::{AnalyzeOptions, SemanticAnalyzer};
22use crate::types::FileHeader;
23
24// ---------------------------------------------------------------------------
25// CLI definition
26// ---------------------------------------------------------------------------
27
28/// AgenticCodebase -- Semantic code compiler for AI agents.
29#[derive(Parser)]
30#[command(
31    name = "acb",
32    about = "AgenticCodebase \u{2014} Semantic code compiler for AI agents",
33    long_about = "AgenticCodebase compiles multi-language codebases into navigable concept \
34                   graphs that AI agents can query. Supports Python, Rust, TypeScript, and Go.\n\n\
35                   Quick start:\n\
36                   \x20 acb compile ./my-project            # build a graph\n\
37                   \x20 acb info my-project.acb             # inspect the graph\n\
38                   \x20 acb query my-project.acb symbol --name UserService\n\
39                   \x20 acb query my-project.acb impact --unit-id 42\n\n\
40                   For AI agent integration, use the companion MCP server: acb-mcp",
41    after_help = "Run 'acb <command> --help' for details on a specific command.\n\
42                  Set ACB_LOG=debug for verbose tracing. Set NO_COLOR=1 to disable colors.",
43    version
44)]
45pub struct Cli {
46    #[command(subcommand)]
47    pub command: Option<Command>,
48
49    /// Output format: human-readable text or machine-readable JSON.
50    #[arg(long, short = 'f', default_value = "text", global = true)]
51    pub format: OutputFormat,
52
53    /// Show detailed progress and diagnostic messages.
54    #[arg(long, short = 'v', global = true)]
55    pub verbose: bool,
56
57    /// Suppress all non-error output.
58    #[arg(long, short = 'q', global = true)]
59    pub quiet: bool,
60}
61
62/// Output format selector.
63#[derive(Clone, ValueEnum)]
64pub enum OutputFormat {
65    /// Human-readable text with optional colors.
66    Text,
67    /// Machine-readable JSON (one object per command).
68    Json,
69}
70
71/// Top-level subcommands.
72#[derive(Subcommand)]
73pub enum Command {
74    /// Compile a repository into an .acb graph file.
75    ///
76    /// Recursively scans the source directory, parses all supported languages
77    /// (Python, Rust, TypeScript, Go), performs semantic analysis, and writes
78    /// a compact binary .acb file for fast querying.
79    ///
80    /// Examples:
81    ///   acb compile ./src
82    ///   acb compile ./src -o myapp.acb
83    ///   acb compile ./src --exclude="*test*" --exclude="vendor"
84    #[command(alias = "build")]
85    Compile {
86        /// Path to the source directory to compile.
87        path: PathBuf,
88
89        /// Output file path (default: <directory-name>.acb in current dir).
90        #[arg(short, long)]
91        output: Option<PathBuf>,
92
93        /// Glob patterns to exclude from parsing (may be repeated).
94        #[arg(long, short = 'e')]
95        exclude: Vec<String>,
96
97        /// Include test files in the compilation (default: true).
98        #[arg(long, default_value_t = true)]
99        include_tests: bool,
100    },
101
102    /// Display summary information about an .acb graph file.
103    ///
104    /// Shows version, unit/edge counts, language breakdown, and file size.
105    /// Useful for verifying a compilation was successful.
106    ///
107    /// Examples:
108    ///   acb info project.acb
109    ///   acb info project.acb --format json
110    #[command(alias = "stat")]
111    Info {
112        /// Path to the .acb file.
113        file: PathBuf,
114    },
115
116    /// Run a query against a compiled .acb graph.
117    ///
118    /// Available query types:
119    ///   symbol     Find code units by name (--name required)
120    ///   deps       Forward dependencies of a unit (--unit-id required)
121    ///   rdeps      Reverse dependencies (who depends on this unit)
122    ///   impact     Impact analysis with risk scoring
123    ///   calls      Call graph exploration
124    ///   similar    Find structurally similar code units
125    ///   prophecy   Predict which units are likely to break
126    ///   stability  Stability score for a specific unit
127    ///   coupling   Detect tightly coupled unit pairs
128    ///
129    /// Examples:
130    ///   acb query project.acb symbol --name "UserService"
131    ///   acb query project.acb deps --unit-id 42 --depth 5
132    ///   acb query project.acb impact --unit-id 42
133    ///   acb query project.acb prophecy --limit 10
134    #[command(alias = "q")]
135    Query {
136        /// Path to the .acb file.
137        file: PathBuf,
138
139        /// Query type: symbol, deps, rdeps, impact, calls, similar,
140        /// prophecy, stability, coupling.
141        query_type: String,
142
143        /// Search string for symbol queries.
144        #[arg(long, short = 'n')]
145        name: Option<String>,
146
147        /// Unit ID for unit-centric queries (deps, impact, calls, etc.).
148        #[arg(long, short = 'u')]
149        unit_id: Option<u64>,
150
151        /// Maximum traversal depth (default: 3).
152        #[arg(long, short = 'd', default_value_t = 3)]
153        depth: u32,
154
155        /// Maximum results to return (default: 20).
156        #[arg(long, short = 'l', default_value_t = 20)]
157        limit: usize,
158    },
159
160    /// Get detailed information about a specific code unit by ID.
161    ///
162    /// Displays all metadata, edges, and relationships for the unit.
163    /// Use `acb query ... symbol` first to find the unit ID.
164    ///
165    /// Examples:
166    ///   acb get project.acb 42
167    ///   acb get project.acb 42 --format json
168    Get {
169        /// Path to the .acb file.
170        file: PathBuf,
171
172        /// Unit ID to look up.
173        unit_id: u64,
174    },
175
176    /// Generate shell completion scripts.
177    ///
178    /// Outputs a completion script for the specified shell to stdout.
179    /// Source it in your shell profile for tab completion.
180    ///
181    /// Examples:
182    ///   acb completions bash > ~/.local/share/bash-completion/completions/acb
183    ///   acb completions zsh > ~/.zfunc/_acb
184    ///   acb completions fish > ~/.config/fish/completions/acb.fish
185    Completions {
186        /// Shell type (bash, zsh, fish, powershell, elvish).
187        shell: Shell,
188    },
189}
190
191// ---------------------------------------------------------------------------
192// Top-level dispatcher
193// ---------------------------------------------------------------------------
194
195/// Run the CLI with the parsed arguments.
196///
197/// Writes output to stdout. Returns an error on failure.
198pub fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
199    let command_name = match &cli.command {
200        None => "repl",
201        Some(Command::Compile { .. }) => "compile",
202        Some(Command::Info { .. }) => "info",
203        Some(Command::Query { .. }) => "query",
204        Some(Command::Get { .. }) => "get",
205        Some(Command::Completions { .. }) => "completions",
206    };
207    let started = Instant::now();
208    let result = match &cli.command {
209        // No subcommand → launch interactive REPL
210        None => crate::cli::repl::run(),
211
212        Some(Command::Compile {
213            path,
214            output,
215            exclude,
216            include_tests,
217        }) => cmd_compile(path, output.as_deref(), exclude, *include_tests, &cli),
218        Some(Command::Info { file }) => cmd_info(file, &cli),
219        Some(Command::Query {
220            file,
221            query_type,
222            name,
223            unit_id,
224            depth,
225            limit,
226        }) => cmd_query(
227            file,
228            query_type,
229            name.as_deref(),
230            *unit_id,
231            *depth,
232            *limit,
233            &cli,
234        ),
235        Some(Command::Get { file, unit_id }) => cmd_get(file, *unit_id, &cli),
236        Some(Command::Completions { shell }) => {
237            let mut cmd = Cli::command();
238            clap_complete::generate(*shell, &mut cmd, "acb", &mut std::io::stdout());
239            Ok(())
240        }
241    };
242
243    emit_cli_health_ledger(command_name, started.elapsed(), result.is_ok());
244    result
245}
246
247// ---------------------------------------------------------------------------
248// Helpers
249// ---------------------------------------------------------------------------
250
251fn emit_cli_health_ledger(command: &str, duration: std::time::Duration, ok: bool) {
252    let dir = resolve_health_ledger_dir();
253    if std::fs::create_dir_all(&dir).is_err() {
254        return;
255    }
256    let path = dir.join("agentic-codebase-cli.json");
257    let tmp = dir.join("agentic-codebase-cli.json.tmp");
258    let profile = read_env_string("ACB_AUTONOMIC_PROFILE").unwrap_or_else(|| "desktop".to_string());
259    let payload = serde_json::json!({
260        "project": "AgenticCodebase",
261        "surface": "cli",
262        "timestamp": chrono::Utc::now().to_rfc3339(),
263        "status": if ok { "ok" } else { "error" },
264        "autonomic": {
265            "profile": profile.to_ascii_lowercase(),
266            "command": command,
267            "duration_ms": duration.as_millis(),
268        }
269    });
270    let Ok(bytes) = serde_json::to_vec_pretty(&payload) else {
271        return;
272    };
273    if std::fs::write(&tmp, bytes).is_err() {
274        return;
275    }
276    let _ = std::fs::rename(&tmp, &path);
277}
278
279fn resolve_health_ledger_dir() -> PathBuf {
280    if let Some(custom) = read_env_string("ACB_HEALTH_LEDGER_DIR") {
281        if !custom.is_empty() {
282            return PathBuf::from(custom);
283        }
284    }
285    if let Some(custom) = read_env_string("AGENTRA_HEALTH_LEDGER_DIR") {
286        if !custom.is_empty() {
287            return PathBuf::from(custom);
288        }
289    }
290    let home = std::env::var("HOME")
291        .ok()
292        .map(PathBuf::from)
293        .unwrap_or_else(|| PathBuf::from("."));
294    home.join(".agentra").join("health-ledger")
295}
296
297/// Get the styled output helper, respecting --format json (always plain).
298fn styled(cli: &Cli) -> Styled {
299    match cli.format {
300        OutputFormat::Json => Styled::plain(),
301        OutputFormat::Text => Styled::auto(),
302    }
303}
304
305/// Validate that the path points to an existing file with .acb extension.
306fn validate_acb_path(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
307    let s = Styled::auto();
308    if !path.exists() {
309        return Err(format!(
310            "{} File not found: {}\n  {} Check the path and try again",
311            s.fail(),
312            path.display(),
313            s.info()
314        )
315        .into());
316    }
317    if !path.is_file() {
318        return Err(format!(
319            "{} Not a file: {}\n  {} Provide a path to an .acb file, not a directory",
320            s.fail(),
321            path.display(),
322            s.info()
323        )
324        .into());
325    }
326    if path.extension().and_then(|e| e.to_str()) != Some("acb") {
327        return Err(format!(
328            "{} Expected .acb file, got: {}\n  {} Compile a repository first: acb compile <dir>",
329            s.fail(),
330            path.display(),
331            s.info()
332        )
333        .into());
334    }
335    Ok(())
336}
337
338// ---------------------------------------------------------------------------
339// compile
340// ---------------------------------------------------------------------------
341
342fn cmd_compile(
343    path: &Path,
344    output: Option<&std::path::Path>,
345    exclude: &[String],
346    include_tests: bool,
347    cli: &Cli,
348) -> Result<(), Box<dyn std::error::Error>> {
349    let s = styled(cli);
350
351    if !path.exists() {
352        return Err(format!(
353            "{} Path does not exist: {}\n  {} Create the directory or check the path",
354            s.fail(),
355            path.display(),
356            s.info()
357        )
358        .into());
359    }
360    if !path.is_dir() {
361        return Err(format!(
362            "{} Path is not a directory: {}\n  {} Provide the root directory of a source repository",
363            s.fail(),
364            path.display(),
365            s.info()
366        )
367        .into());
368    }
369
370    let out_path = match output {
371        Some(p) => p.to_path_buf(),
372        None => {
373            let dir_name = path
374                .file_name()
375                .map(|n| n.to_string_lossy().to_string())
376                .unwrap_or_else(|| "output".to_string());
377            PathBuf::from(format!("{}.acb", dir_name))
378        }
379    };
380
381    // Build parse options.
382    let mut opts = ParseOptions {
383        include_tests,
384        ..ParseOptions::default()
385    };
386    for pat in exclude {
387        opts.exclude.push(pat.clone());
388    }
389
390    if !cli.quiet {
391        if let OutputFormat::Text = cli.format {
392            eprintln!(
393                "  {} Compiling {} {} {}",
394                s.info(),
395                s.bold(&path.display().to_string()),
396                s.arrow(),
397                s.cyan(&out_path.display().to_string()),
398            );
399        }
400    }
401
402    // 1. Parse
403    if cli.verbose {
404        eprintln!("  {} Parsing source files...", s.info());
405    }
406    let parser = AcbParser::new();
407    let parse_result = parser.parse_directory(path, &opts)?;
408
409    if !cli.quiet {
410        if let OutputFormat::Text = cli.format {
411            eprintln!(
412                "  {} Parsed {} files ({} units found)",
413                s.ok(),
414                parse_result.stats.files_parsed,
415                parse_result.units.len(),
416            );
417            if !parse_result.errors.is_empty() {
418                eprintln!(
419                    "  {} {} parse errors (use --verbose to see details)",
420                    s.warn(),
421                    parse_result.errors.len()
422                );
423            }
424        }
425    }
426
427    if cli.verbose && !parse_result.errors.is_empty() {
428        for err in &parse_result.errors {
429            eprintln!("    {} {:?}", s.warn(), err);
430        }
431    }
432
433    // 2. Semantic analysis
434    if cli.verbose {
435        eprintln!("  {} Running semantic analysis...", s.info());
436    }
437    let unit_count = parse_result.units.len();
438    progress("Analyzing", 0, unit_count);
439    let analyzer = SemanticAnalyzer::new();
440    let analyze_opts = AnalyzeOptions::default();
441    let graph = analyzer.analyze(parse_result.units, &analyze_opts)?;
442    progress("Analyzing", unit_count, unit_count);
443    progress_done();
444
445    if cli.verbose {
446        eprintln!(
447            "  {} Graph built: {} units, {} edges",
448            s.ok(),
449            graph.unit_count(),
450            graph.edge_count()
451        );
452    }
453
454    // 3. Write .acb
455    if cli.verbose {
456        eprintln!("  {} Writing binary format...", s.info());
457    }
458    let backup_path = maybe_backup_existing_output(&out_path)?;
459    if cli.verbose {
460        if let Some(backup) = &backup_path {
461            eprintln!(
462                "  {} Backed up previous graph to {}",
463                s.info(),
464                s.dim(&backup.display().to_string())
465            );
466        }
467    }
468    let writer = AcbWriter::with_default_dimension();
469    writer.write_to_file(&graph, &out_path)?;
470
471    // Final output
472    let file_size = std::fs::metadata(&out_path).map(|m| m.len()).unwrap_or(0);
473
474    let stdout = std::io::stdout();
475    let mut out = stdout.lock();
476
477    match cli.format {
478        OutputFormat::Text => {
479            if !cli.quiet {
480                let _ = writeln!(out);
481                let _ = writeln!(out, "  {} Compiled successfully!", s.ok());
482                let _ = writeln!(
483                    out,
484                    "     Units:     {}",
485                    s.bold(&graph.unit_count().to_string())
486                );
487                let _ = writeln!(
488                    out,
489                    "     Edges:     {}",
490                    s.bold(&graph.edge_count().to_string())
491                );
492                let _ = writeln!(
493                    out,
494                    "     Languages: {}",
495                    s.bold(&graph.languages().len().to_string())
496                );
497                let _ = writeln!(out, "     Size:      {}", s.dim(&format_size(file_size)));
498                let _ = writeln!(out);
499                let _ = writeln!(
500                    out,
501                    "  Next: {} or {}",
502                    s.cyan(&format!("acb info {}", out_path.display())),
503                    s.cyan(&format!(
504                        "acb query {} symbol --name <search>",
505                        out_path.display()
506                    )),
507                );
508            }
509        }
510        OutputFormat::Json => {
511            let obj = serde_json::json!({
512                "status": "ok",
513                "source": path.display().to_string(),
514                "output": out_path.display().to_string(),
515                "units": graph.unit_count(),
516                "edges": graph.edge_count(),
517                "languages": graph.languages().len(),
518                "file_size_bytes": file_size,
519            });
520            let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
521        }
522    }
523
524    Ok(())
525}
526
527fn maybe_backup_existing_output(
528    out_path: &Path,
529) -> Result<Option<PathBuf>, Box<dyn std::error::Error>> {
530    if !auto_backup_enabled() || !out_path.exists() || !out_path.is_file() {
531        return Ok(None);
532    }
533
534    let backups_dir = resolve_backup_dir(out_path);
535    std::fs::create_dir_all(&backups_dir)?;
536
537    let original_name = out_path
538        .file_name()
539        .and_then(|n| n.to_str())
540        .unwrap_or("graph.acb");
541    let ts = chrono::Utc::now().format("%Y%m%d%H%M%S");
542    let backup_path = backups_dir.join(format!("{original_name}.{ts}.bak"));
543    std::fs::copy(out_path, &backup_path)?;
544    prune_old_backups(&backups_dir, original_name, auto_backup_retention())?;
545
546    Ok(Some(backup_path))
547}
548
549fn auto_backup_enabled() -> bool {
550    match std::env::var("ACB_AUTO_BACKUP") {
551        Ok(v) => {
552            let value = v.trim().to_ascii_lowercase();
553            value != "0" && value != "false" && value != "off" && value != "no"
554        }
555        Err(_) => true,
556    }
557}
558
559fn auto_backup_retention() -> usize {
560    let default_retention = match read_env_string("ACB_AUTONOMIC_PROFILE")
561        .unwrap_or_else(|| "desktop".to_string())
562        .to_ascii_lowercase()
563        .as_str()
564    {
565        "cloud" => 40,
566        "aggressive" => 12,
567        _ => 20,
568    };
569    std::env::var("ACB_AUTO_BACKUP_RETENTION")
570        .ok()
571        .and_then(|v| v.parse::<usize>().ok())
572        .unwrap_or(default_retention)
573        .max(1)
574}
575
576fn resolve_backup_dir(out_path: &Path) -> PathBuf {
577    if let Ok(custom) = std::env::var("ACB_AUTO_BACKUP_DIR") {
578        let trimmed = custom.trim();
579        if !trimmed.is_empty() {
580            return PathBuf::from(trimmed);
581        }
582    }
583    out_path
584        .parent()
585        .unwrap_or_else(|| Path::new("."))
586        .join(".acb-backups")
587}
588
589fn read_env_string(name: &str) -> Option<String> {
590    std::env::var(name).ok().map(|v| v.trim().to_string())
591}
592
593fn prune_old_backups(
594    backup_dir: &Path,
595    original_name: &str,
596    retention: usize,
597) -> Result<(), Box<dyn std::error::Error>> {
598    let mut backups = std::fs::read_dir(backup_dir)?
599        .filter_map(Result::ok)
600        .filter(|entry| {
601            entry
602                .file_name()
603                .to_str()
604                .map(|name| name.starts_with(original_name) && name.ends_with(".bak"))
605                .unwrap_or(false)
606        })
607        .collect::<Vec<_>>();
608
609    if backups.len() <= retention {
610        return Ok(());
611    }
612
613    backups.sort_by_key(|entry| {
614        entry
615            .metadata()
616            .and_then(|m| m.modified())
617            .ok()
618            .unwrap_or(SystemTime::UNIX_EPOCH)
619    });
620
621    let to_remove = backups.len().saturating_sub(retention);
622    for entry in backups.into_iter().take(to_remove) {
623        let _ = std::fs::remove_file(entry.path());
624    }
625    Ok(())
626}
627
628// ---------------------------------------------------------------------------
629// info
630// ---------------------------------------------------------------------------
631
632fn cmd_info(file: &PathBuf, cli: &Cli) -> Result<(), Box<dyn std::error::Error>> {
633    let s = styled(cli);
634    validate_acb_path(file)?;
635    let graph = AcbReader::read_from_file(file)?;
636
637    // Also read header for metadata.
638    let data = std::fs::read(file)?;
639    let header_bytes: [u8; 128] = data[..128]
640        .try_into()
641        .map_err(|_| "File too small for header")?;
642    let header = FileHeader::from_bytes(&header_bytes)?;
643    let file_size = data.len() as u64;
644
645    let stdout = std::io::stdout();
646    let mut out = stdout.lock();
647
648    match cli.format {
649        OutputFormat::Text => {
650            let _ = writeln!(
651                out,
652                "\n  {} {}",
653                s.info(),
654                s.bold(&file.display().to_string())
655            );
656            let _ = writeln!(out, "     Version:   v{}", header.version);
657            let _ = writeln!(
658                out,
659                "     Units:     {}",
660                s.bold(&graph.unit_count().to_string())
661            );
662            let _ = writeln!(
663                out,
664                "     Edges:     {}",
665                s.bold(&graph.edge_count().to_string())
666            );
667            let _ = writeln!(
668                out,
669                "     Languages: {}",
670                s.bold(&graph.languages().len().to_string())
671            );
672            let _ = writeln!(out, "     Dimension: {}", header.dimension);
673            let _ = writeln!(out, "     File size: {}", format_size(file_size));
674            let _ = writeln!(out);
675            for lang in graph.languages() {
676                let count = graph.units().iter().filter(|u| u.language == *lang).count();
677                let _ = writeln!(
678                    out,
679                    "     {} {} {}",
680                    s.arrow(),
681                    s.cyan(&format!("{:12}", lang)),
682                    s.dim(&format!("{} units", count))
683                );
684            }
685            let _ = writeln!(out);
686        }
687        OutputFormat::Json => {
688            let mut lang_map = serde_json::Map::new();
689            for lang in graph.languages() {
690                let count = graph.units().iter().filter(|u| u.language == *lang).count();
691                lang_map.insert(lang.to_string(), serde_json::json!(count));
692            }
693            let obj = serde_json::json!({
694                "file": file.display().to_string(),
695                "version": header.version,
696                "units": graph.unit_count(),
697                "edges": graph.edge_count(),
698                "languages": graph.languages().len(),
699                "dimension": header.dimension,
700                "file_size_bytes": file_size,
701                "language_breakdown": lang_map,
702            });
703            let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
704        }
705    }
706
707    Ok(())
708}
709
710// ---------------------------------------------------------------------------
711// query
712// ---------------------------------------------------------------------------
713
714fn cmd_query(
715    file: &Path,
716    query_type: &str,
717    name: Option<&str>,
718    unit_id: Option<u64>,
719    depth: u32,
720    limit: usize,
721    cli: &Cli,
722) -> Result<(), Box<dyn std::error::Error>> {
723    validate_acb_path(file)?;
724    let graph = AcbReader::read_from_file(file)?;
725    let engine = QueryEngine::new();
726    let s = styled(cli);
727
728    match query_type {
729        "symbol" | "sym" | "s" => query_symbol(&graph, &engine, name, limit, cli, &s),
730        "deps" | "dep" | "d" => query_deps(&graph, &engine, unit_id, depth, cli, &s),
731        "rdeps" | "rdep" | "r" => query_rdeps(&graph, &engine, unit_id, depth, cli, &s),
732        "impact" | "imp" | "i" => query_impact(&graph, &engine, unit_id, depth, cli, &s),
733        "calls" | "call" | "c" => query_calls(&graph, &engine, unit_id, depth, cli, &s),
734        "similar" | "sim" => query_similar(&graph, &engine, unit_id, limit, cli, &s),
735        "prophecy" | "predict" | "p" => query_prophecy(&graph, &engine, limit, cli, &s),
736        "stability" | "stab" => query_stability(&graph, &engine, unit_id, cli, &s),
737        "coupling" | "couple" => query_coupling(&graph, &engine, unit_id, cli, &s),
738        other => {
739            let known = [
740                "symbol",
741                "deps",
742                "rdeps",
743                "impact",
744                "calls",
745                "similar",
746                "prophecy",
747                "stability",
748                "coupling",
749            ];
750            let suggestion = known
751                .iter()
752                .filter(|k| k.starts_with(&other[..1.min(other.len())]))
753                .copied()
754                .collect::<Vec<_>>();
755            let hint = if suggestion.is_empty() {
756                format!("Available: {}", known.join(", "))
757            } else {
758                format!("Did you mean: {}?", suggestion.join(", "))
759            };
760            Err(format!(
761                "{} Unknown query type: {}\n  {} {}",
762                s.fail(),
763                other,
764                s.info(),
765                hint
766            )
767            .into())
768        }
769    }
770}
771
772fn query_symbol(
773    graph: &CodeGraph,
774    engine: &QueryEngine,
775    name: Option<&str>,
776    limit: usize,
777    cli: &Cli,
778    s: &Styled,
779) -> Result<(), Box<dyn std::error::Error>> {
780    let search_name = name.ok_or_else(|| {
781        format!(
782            "{} --name is required for symbol queries\n  {} Example: acb query file.acb symbol --name UserService",
783            s.fail(),
784            s.info()
785        )
786    })?;
787    let params = SymbolLookupParams {
788        name: search_name.to_string(),
789        mode: MatchMode::Contains,
790        limit,
791        ..Default::default()
792    };
793    let results = engine.symbol_lookup(graph, params)?;
794
795    let stdout = std::io::stdout();
796    let mut out = stdout.lock();
797
798    match cli.format {
799        OutputFormat::Text => {
800            let _ = writeln!(
801                out,
802                "\n  Symbol lookup: {} ({} results)\n",
803                s.bold(&format!("\"{}\"", search_name)),
804                results.len()
805            );
806            if results.is_empty() {
807                let _ = writeln!(
808                    out,
809                    "  {} No matches found. Try a broader search term.",
810                    s.warn()
811                );
812            }
813            for (i, unit) in results.iter().enumerate() {
814                let _ = writeln!(
815                    out,
816                    "  {:>3}. {} {} {}",
817                    s.dim(&format!("#{}", i + 1)),
818                    s.bold(&unit.qualified_name),
819                    s.dim(&format!("({})", unit.unit_type)),
820                    s.dim(&format!(
821                        "{}:{}",
822                        unit.file_path.display(),
823                        unit.span.start_line
824                    ))
825                );
826            }
827            let _ = writeln!(out);
828        }
829        OutputFormat::Json => {
830            let entries: Vec<serde_json::Value> = results
831                .iter()
832                .map(|u| {
833                    serde_json::json!({
834                        "id": u.id,
835                        "name": u.name,
836                        "qualified_name": u.qualified_name,
837                        "unit_type": u.unit_type.label(),
838                        "language": u.language.name(),
839                        "file": u.file_path.display().to_string(),
840                        "line": u.span.start_line,
841                    })
842                })
843                .collect();
844            let obj = serde_json::json!({
845                "query": "symbol",
846                "name": search_name,
847                "count": results.len(),
848                "results": entries,
849            });
850            let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
851        }
852    }
853    Ok(())
854}
855
856fn query_deps(
857    graph: &CodeGraph,
858    engine: &QueryEngine,
859    unit_id: Option<u64>,
860    depth: u32,
861    cli: &Cli,
862    s: &Styled,
863) -> Result<(), Box<dyn std::error::Error>> {
864    let uid = unit_id.ok_or_else(|| {
865        format!(
866            "{} --unit-id is required for deps queries\n  {} Find an ID first: acb query file.acb symbol --name <name>",
867            s.fail(), s.info()
868        )
869    })?;
870    let params = DependencyParams {
871        unit_id: uid,
872        max_depth: depth,
873        edge_types: vec![],
874        include_transitive: true,
875    };
876    let result = engine.dependency_graph(graph, params)?;
877
878    let stdout = std::io::stdout();
879    let mut out = stdout.lock();
880
881    match cli.format {
882        OutputFormat::Text => {
883            let root_name = graph
884                .get_unit(uid)
885                .map(|u| u.qualified_name.as_str())
886                .unwrap_or("?");
887            let _ = writeln!(
888                out,
889                "\n  Dependencies of {} ({} found)\n",
890                s.bold(root_name),
891                result.nodes.len()
892            );
893            for node in &result.nodes {
894                let unit_name = graph
895                    .get_unit(node.unit_id)
896                    .map(|u| u.qualified_name.as_str())
897                    .unwrap_or("?");
898                let indent = "  ".repeat(node.depth as usize);
899                let _ = writeln!(
900                    out,
901                    "  {}{} {} {}",
902                    indent,
903                    s.arrow(),
904                    s.cyan(unit_name),
905                    s.dim(&format!("[id:{}]", node.unit_id))
906                );
907            }
908            let _ = writeln!(out);
909        }
910        OutputFormat::Json => {
911            let entries: Vec<serde_json::Value> = result
912                .nodes
913                .iter()
914                .map(|n| {
915                    let unit_name = graph
916                        .get_unit(n.unit_id)
917                        .map(|u| u.qualified_name.clone())
918                        .unwrap_or_default();
919                    serde_json::json!({
920                        "unit_id": n.unit_id,
921                        "name": unit_name,
922                        "depth": n.depth,
923                    })
924                })
925                .collect();
926            let obj = serde_json::json!({
927                "query": "deps",
928                "root_id": uid,
929                "count": result.nodes.len(),
930                "results": entries,
931            });
932            let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
933        }
934    }
935    Ok(())
936}
937
938fn query_rdeps(
939    graph: &CodeGraph,
940    engine: &QueryEngine,
941    unit_id: Option<u64>,
942    depth: u32,
943    cli: &Cli,
944    s: &Styled,
945) -> Result<(), Box<dyn std::error::Error>> {
946    let uid = unit_id.ok_or_else(|| {
947        format!(
948            "{} --unit-id is required for rdeps queries\n  {} Find an ID first: acb query file.acb symbol --name <name>",
949            s.fail(), s.info()
950        )
951    })?;
952    let params = DependencyParams {
953        unit_id: uid,
954        max_depth: depth,
955        edge_types: vec![],
956        include_transitive: true,
957    };
958    let result = engine.reverse_dependency(graph, params)?;
959
960    let stdout = std::io::stdout();
961    let mut out = stdout.lock();
962
963    match cli.format {
964        OutputFormat::Text => {
965            let root_name = graph
966                .get_unit(uid)
967                .map(|u| u.qualified_name.as_str())
968                .unwrap_or("?");
969            let _ = writeln!(
970                out,
971                "\n  Reverse dependencies of {} ({} found)\n",
972                s.bold(root_name),
973                result.nodes.len()
974            );
975            for node in &result.nodes {
976                let unit_name = graph
977                    .get_unit(node.unit_id)
978                    .map(|u| u.qualified_name.as_str())
979                    .unwrap_or("?");
980                let indent = "  ".repeat(node.depth as usize);
981                let _ = writeln!(
982                    out,
983                    "  {}{} {} {}",
984                    indent,
985                    s.arrow(),
986                    s.cyan(unit_name),
987                    s.dim(&format!("[id:{}]", node.unit_id))
988                );
989            }
990            let _ = writeln!(out);
991        }
992        OutputFormat::Json => {
993            let entries: Vec<serde_json::Value> = result
994                .nodes
995                .iter()
996                .map(|n| {
997                    let unit_name = graph
998                        .get_unit(n.unit_id)
999                        .map(|u| u.qualified_name.clone())
1000                        .unwrap_or_default();
1001                    serde_json::json!({
1002                        "unit_id": n.unit_id,
1003                        "name": unit_name,
1004                        "depth": n.depth,
1005                    })
1006                })
1007                .collect();
1008            let obj = serde_json::json!({
1009                "query": "rdeps",
1010                "root_id": uid,
1011                "count": result.nodes.len(),
1012                "results": entries,
1013            });
1014            let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1015        }
1016    }
1017    Ok(())
1018}
1019
1020fn query_impact(
1021    graph: &CodeGraph,
1022    engine: &QueryEngine,
1023    unit_id: Option<u64>,
1024    depth: u32,
1025    cli: &Cli,
1026    s: &Styled,
1027) -> Result<(), Box<dyn std::error::Error>> {
1028    let uid =
1029        unit_id.ok_or_else(|| format!("{} --unit-id is required for impact queries", s.fail()))?;
1030    let params = ImpactParams {
1031        unit_id: uid,
1032        max_depth: depth,
1033        edge_types: vec![],
1034    };
1035    let result = engine.impact_analysis(graph, params)?;
1036
1037    let stdout = std::io::stdout();
1038    let mut out = stdout.lock();
1039
1040    match cli.format {
1041        OutputFormat::Text => {
1042            let root_name = graph
1043                .get_unit(uid)
1044                .map(|u| u.qualified_name.as_str())
1045                .unwrap_or("?");
1046
1047            let risk_label = if result.overall_risk >= 0.7 {
1048                s.red("HIGH")
1049            } else if result.overall_risk >= 0.4 {
1050                s.yellow("MEDIUM")
1051            } else {
1052                s.green("LOW")
1053            };
1054
1055            let _ = writeln!(
1056                out,
1057                "\n  Impact analysis for {} (risk: {})\n",
1058                s.bold(root_name),
1059                risk_label,
1060            );
1061            let _ = writeln!(
1062                out,
1063                "  {} impacted units, overall risk {:.2}\n",
1064                result.impacted.len(),
1065                result.overall_risk
1066            );
1067            for imp in &result.impacted {
1068                let unit_name = graph
1069                    .get_unit(imp.unit_id)
1070                    .map(|u| u.qualified_name.as_str())
1071                    .unwrap_or("?");
1072                let risk_sym = if imp.risk_score >= 0.7 {
1073                    s.fail()
1074                } else if imp.risk_score >= 0.4 {
1075                    s.warn()
1076                } else {
1077                    s.ok()
1078                };
1079                let test_badge = if imp.has_tests {
1080                    s.green("tested")
1081                } else {
1082                    s.red("untested")
1083                };
1084                let _ = writeln!(
1085                    out,
1086                    "  {} {} {} risk:{:.2} {}",
1087                    risk_sym,
1088                    s.cyan(unit_name),
1089                    s.dim(&format!("(depth {})", imp.depth)),
1090                    imp.risk_score,
1091                    test_badge,
1092                );
1093            }
1094            if !result.recommendations.is_empty() {
1095                let _ = writeln!(out);
1096                for rec in &result.recommendations {
1097                    let _ = writeln!(out, "  {} {}", s.info(), rec);
1098                }
1099            }
1100            let _ = writeln!(out);
1101        }
1102        OutputFormat::Json => {
1103            let entries: Vec<serde_json::Value> = result
1104                .impacted
1105                .iter()
1106                .map(|imp| {
1107                    serde_json::json!({
1108                        "unit_id": imp.unit_id,
1109                        "depth": imp.depth,
1110                        "risk_score": imp.risk_score,
1111                        "has_tests": imp.has_tests,
1112                    })
1113                })
1114                .collect();
1115            let obj = serde_json::json!({
1116                "query": "impact",
1117                "root_id": uid,
1118                "count": result.impacted.len(),
1119                "overall_risk": result.overall_risk,
1120                "results": entries,
1121                "recommendations": result.recommendations,
1122            });
1123            let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1124        }
1125    }
1126    Ok(())
1127}
1128
1129fn query_calls(
1130    graph: &CodeGraph,
1131    engine: &QueryEngine,
1132    unit_id: Option<u64>,
1133    depth: u32,
1134    cli: &Cli,
1135    s: &Styled,
1136) -> Result<(), Box<dyn std::error::Error>> {
1137    let uid =
1138        unit_id.ok_or_else(|| format!("{} --unit-id is required for calls queries", s.fail()))?;
1139    let params = CallGraphParams {
1140        unit_id: uid,
1141        direction: CallDirection::Both,
1142        max_depth: depth,
1143    };
1144    let result = engine.call_graph(graph, params)?;
1145
1146    let stdout = std::io::stdout();
1147    let mut out = stdout.lock();
1148
1149    match cli.format {
1150        OutputFormat::Text => {
1151            let root_name = graph
1152                .get_unit(uid)
1153                .map(|u| u.qualified_name.as_str())
1154                .unwrap_or("?");
1155            let _ = writeln!(
1156                out,
1157                "\n  Call graph for {} ({} nodes)\n",
1158                s.bold(root_name),
1159                result.nodes.len()
1160            );
1161            for (nid, d) in &result.nodes {
1162                let unit_name = graph
1163                    .get_unit(*nid)
1164                    .map(|u| u.qualified_name.as_str())
1165                    .unwrap_or("?");
1166                let indent = "  ".repeat(*d as usize);
1167                let _ = writeln!(out, "  {}{} {}", indent, s.arrow(), s.cyan(unit_name),);
1168            }
1169            let _ = writeln!(out);
1170        }
1171        OutputFormat::Json => {
1172            let entries: Vec<serde_json::Value> = result
1173                .nodes
1174                .iter()
1175                .map(|(nid, d)| {
1176                    let unit_name = graph
1177                        .get_unit(*nid)
1178                        .map(|u| u.qualified_name.clone())
1179                        .unwrap_or_default();
1180                    serde_json::json!({
1181                        "unit_id": nid,
1182                        "name": unit_name,
1183                        "depth": d,
1184                    })
1185                })
1186                .collect();
1187            let obj = serde_json::json!({
1188                "query": "calls",
1189                "root_id": uid,
1190                "count": result.nodes.len(),
1191                "results": entries,
1192            });
1193            let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1194        }
1195    }
1196    Ok(())
1197}
1198
1199fn query_similar(
1200    graph: &CodeGraph,
1201    engine: &QueryEngine,
1202    unit_id: Option<u64>,
1203    limit: usize,
1204    cli: &Cli,
1205    s: &Styled,
1206) -> Result<(), Box<dyn std::error::Error>> {
1207    let uid =
1208        unit_id.ok_or_else(|| format!("{} --unit-id is required for similar queries", s.fail()))?;
1209    let params = SimilarityParams {
1210        unit_id: uid,
1211        top_k: limit,
1212        min_similarity: 0.0,
1213    };
1214    let results = engine.similarity(graph, params)?;
1215
1216    let stdout = std::io::stdout();
1217    let mut out = stdout.lock();
1218
1219    match cli.format {
1220        OutputFormat::Text => {
1221            let root_name = graph
1222                .get_unit(uid)
1223                .map(|u| u.qualified_name.as_str())
1224                .unwrap_or("?");
1225            let _ = writeln!(
1226                out,
1227                "\n  Similar to {} ({} matches)\n",
1228                s.bold(root_name),
1229                results.len()
1230            );
1231            for (i, m) in results.iter().enumerate() {
1232                let unit_name = graph
1233                    .get_unit(m.unit_id)
1234                    .map(|u| u.qualified_name.as_str())
1235                    .unwrap_or("?");
1236                let score_str = format!("{:.2}%", m.score * 100.0);
1237                let _ = writeln!(
1238                    out,
1239                    "  {:>3}. {} {} {}",
1240                    s.dim(&format!("#{}", i + 1)),
1241                    s.cyan(unit_name),
1242                    s.dim(&format!("[id:{}]", m.unit_id)),
1243                    s.yellow(&score_str),
1244                );
1245            }
1246            let _ = writeln!(out);
1247        }
1248        OutputFormat::Json => {
1249            let entries: Vec<serde_json::Value> = results
1250                .iter()
1251                .map(|m| {
1252                    serde_json::json!({
1253                        "unit_id": m.unit_id,
1254                        "score": m.score,
1255                    })
1256                })
1257                .collect();
1258            let obj = serde_json::json!({
1259                "query": "similar",
1260                "root_id": uid,
1261                "count": results.len(),
1262                "results": entries,
1263            });
1264            let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1265        }
1266    }
1267    Ok(())
1268}
1269
1270fn query_prophecy(
1271    graph: &CodeGraph,
1272    engine: &QueryEngine,
1273    limit: usize,
1274    cli: &Cli,
1275    s: &Styled,
1276) -> Result<(), Box<dyn std::error::Error>> {
1277    let params = ProphecyParams {
1278        top_k: limit,
1279        min_risk: 0.0,
1280    };
1281    let result = engine.prophecy(graph, params)?;
1282
1283    let stdout = std::io::stdout();
1284    let mut out = stdout.lock();
1285
1286    match cli.format {
1287        OutputFormat::Text => {
1288            let _ = writeln!(
1289                out,
1290                "\n  {} Code prophecy ({} predictions)\n",
1291                s.info(),
1292                result.predictions.len()
1293            );
1294            if result.predictions.is_empty() {
1295                let _ = writeln!(
1296                    out,
1297                    "  {} No high-risk predictions. Codebase looks stable!",
1298                    s.ok()
1299                );
1300            }
1301            for pred in &result.predictions {
1302                let unit_name = graph
1303                    .get_unit(pred.unit_id)
1304                    .map(|u| u.qualified_name.as_str())
1305                    .unwrap_or("?");
1306                let risk_sym = if pred.risk_score >= 0.7 {
1307                    s.fail()
1308                } else if pred.risk_score >= 0.4 {
1309                    s.warn()
1310                } else {
1311                    s.ok()
1312                };
1313                let _ = writeln!(
1314                    out,
1315                    "  {} {} {}: {}",
1316                    risk_sym,
1317                    s.cyan(unit_name),
1318                    s.dim(&format!("(risk {:.2})", pred.risk_score)),
1319                    pred.reason,
1320                );
1321            }
1322            let _ = writeln!(out);
1323        }
1324        OutputFormat::Json => {
1325            let entries: Vec<serde_json::Value> = result
1326                .predictions
1327                .iter()
1328                .map(|p| {
1329                    serde_json::json!({
1330                        "unit_id": p.unit_id,
1331                        "risk_score": p.risk_score,
1332                        "reason": p.reason,
1333                    })
1334                })
1335                .collect();
1336            let obj = serde_json::json!({
1337                "query": "prophecy",
1338                "count": result.predictions.len(),
1339                "results": entries,
1340            });
1341            let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1342        }
1343    }
1344    Ok(())
1345}
1346
1347fn query_stability(
1348    graph: &CodeGraph,
1349    engine: &QueryEngine,
1350    unit_id: Option<u64>,
1351    cli: &Cli,
1352    s: &Styled,
1353) -> Result<(), Box<dyn std::error::Error>> {
1354    let uid = unit_id
1355        .ok_or_else(|| format!("{} --unit-id is required for stability queries", s.fail()))?;
1356    let result: StabilityResult = engine.stability_analysis(graph, uid)?;
1357
1358    let stdout = std::io::stdout();
1359    let mut out = stdout.lock();
1360
1361    match cli.format {
1362        OutputFormat::Text => {
1363            let root_name = graph
1364                .get_unit(uid)
1365                .map(|u| u.qualified_name.as_str())
1366                .unwrap_or("?");
1367
1368            let score_color = if result.overall_score >= 0.7 {
1369                s.green(&format!("{:.2}", result.overall_score))
1370            } else if result.overall_score >= 0.4 {
1371                s.yellow(&format!("{:.2}", result.overall_score))
1372            } else {
1373                s.red(&format!("{:.2}", result.overall_score))
1374            };
1375
1376            let _ = writeln!(
1377                out,
1378                "\n  Stability of {}: {}\n",
1379                s.bold(root_name),
1380                score_color,
1381            );
1382            for factor in &result.factors {
1383                let _ = writeln!(
1384                    out,
1385                    "  {} {} = {:.2}: {}",
1386                    s.arrow(),
1387                    s.bold(&factor.name),
1388                    factor.value,
1389                    s.dim(&factor.description),
1390                );
1391            }
1392            let _ = writeln!(out, "\n  {} {}", s.info(), result.recommendation);
1393            let _ = writeln!(out);
1394        }
1395        OutputFormat::Json => {
1396            let factors: Vec<serde_json::Value> = result
1397                .factors
1398                .iter()
1399                .map(|f| {
1400                    serde_json::json!({
1401                        "name": f.name,
1402                        "value": f.value,
1403                        "description": f.description,
1404                    })
1405                })
1406                .collect();
1407            let obj = serde_json::json!({
1408                "query": "stability",
1409                "unit_id": uid,
1410                "overall_score": result.overall_score,
1411                "factors": factors,
1412                "recommendation": result.recommendation,
1413            });
1414            let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1415        }
1416    }
1417    Ok(())
1418}
1419
1420fn query_coupling(
1421    graph: &CodeGraph,
1422    engine: &QueryEngine,
1423    unit_id: Option<u64>,
1424    cli: &Cli,
1425    s: &Styled,
1426) -> Result<(), Box<dyn std::error::Error>> {
1427    let params = CouplingParams {
1428        unit_id,
1429        min_strength: 0.0,
1430    };
1431    let results = engine.coupling_detection(graph, params)?;
1432
1433    let stdout = std::io::stdout();
1434    let mut out = stdout.lock();
1435
1436    match cli.format {
1437        OutputFormat::Text => {
1438            let _ = writeln!(
1439                out,
1440                "\n  Coupling analysis ({} pairs detected)\n",
1441                results.len()
1442            );
1443            if results.is_empty() {
1444                let _ = writeln!(out, "  {} No tightly coupled pairs detected.", s.ok());
1445            }
1446            for c in &results {
1447                let name_a = graph
1448                    .get_unit(c.unit_a)
1449                    .map(|u| u.qualified_name.as_str())
1450                    .unwrap_or("?");
1451                let name_b = graph
1452                    .get_unit(c.unit_b)
1453                    .map(|u| u.qualified_name.as_str())
1454                    .unwrap_or("?");
1455                let strength_str = format!("{:.0}%", c.strength * 100.0);
1456                let _ = writeln!(
1457                    out,
1458                    "  {} {} {} {} {}",
1459                    s.warn(),
1460                    s.cyan(name_a),
1461                    s.dim("<->"),
1462                    s.cyan(name_b),
1463                    s.yellow(&strength_str),
1464                );
1465            }
1466            let _ = writeln!(out);
1467        }
1468        OutputFormat::Json => {
1469            let entries: Vec<serde_json::Value> = results
1470                .iter()
1471                .map(|c| {
1472                    serde_json::json!({
1473                        "unit_a": c.unit_a,
1474                        "unit_b": c.unit_b,
1475                        "strength": c.strength,
1476                        "kind": format!("{:?}", c.kind),
1477                    })
1478                })
1479                .collect();
1480            let obj = serde_json::json!({
1481                "query": "coupling",
1482                "count": results.len(),
1483                "results": entries,
1484            });
1485            let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1486        }
1487    }
1488    Ok(())
1489}
1490
1491// ---------------------------------------------------------------------------
1492// get
1493// ---------------------------------------------------------------------------
1494
1495fn cmd_get(file: &Path, unit_id: u64, cli: &Cli) -> Result<(), Box<dyn std::error::Error>> {
1496    let s = styled(cli);
1497    validate_acb_path(file)?;
1498    let graph = AcbReader::read_from_file(file)?;
1499
1500    let unit = graph.get_unit(unit_id).ok_or_else(|| {
1501        format!(
1502            "{} Unit {} not found\n  {} Use 'acb query ... symbol' to find valid unit IDs",
1503            s.fail(),
1504            unit_id,
1505            s.info()
1506        )
1507    })?;
1508
1509    let outgoing = graph.edges_from(unit_id);
1510    let incoming = graph.edges_to(unit_id);
1511
1512    let stdout = std::io::stdout();
1513    let mut out = stdout.lock();
1514
1515    match cli.format {
1516        OutputFormat::Text => {
1517            let _ = writeln!(
1518                out,
1519                "\n  {} {}",
1520                s.info(),
1521                s.bold(&format!("Unit {}", unit.id))
1522            );
1523            let _ = writeln!(out, "     Name:           {}", s.cyan(&unit.name));
1524            let _ = writeln!(out, "     Qualified name: {}", s.bold(&unit.qualified_name));
1525            let _ = writeln!(out, "     Type:           {}", unit.unit_type);
1526            let _ = writeln!(out, "     Language:       {}", unit.language);
1527            let _ = writeln!(
1528                out,
1529                "     File:           {}",
1530                s.cyan(&unit.file_path.display().to_string())
1531            );
1532            let _ = writeln!(out, "     Span:           {}", unit.span);
1533            let _ = writeln!(out, "     Visibility:     {}", unit.visibility);
1534            let _ = writeln!(out, "     Complexity:     {}", unit.complexity);
1535            if unit.is_async {
1536                let _ = writeln!(out, "     Async:          {}", s.green("yes"));
1537            }
1538            if unit.is_generator {
1539                let _ = writeln!(out, "     Generator:      {}", s.green("yes"));
1540            }
1541
1542            let stability_str = format!("{:.2}", unit.stability_score);
1543            let stability_color = if unit.stability_score >= 0.7 {
1544                s.green(&stability_str)
1545            } else if unit.stability_score >= 0.4 {
1546                s.yellow(&stability_str)
1547            } else {
1548                s.red(&stability_str)
1549            };
1550            let _ = writeln!(out, "     Stability:      {}", stability_color);
1551
1552            if let Some(sig) = &unit.signature {
1553                let _ = writeln!(out, "     Signature:      {}", s.dim(sig));
1554            }
1555            if let Some(doc) = &unit.doc_summary {
1556                let _ = writeln!(out, "     Doc:            {}", s.dim(doc));
1557            }
1558
1559            if !outgoing.is_empty() {
1560                let _ = writeln!(
1561                    out,
1562                    "\n     {} Outgoing edges ({})",
1563                    s.arrow(),
1564                    outgoing.len()
1565                );
1566                for edge in &outgoing {
1567                    let target_name = graph
1568                        .get_unit(edge.target_id)
1569                        .map(|u| u.qualified_name.as_str())
1570                        .unwrap_or("?");
1571                    let _ = writeln!(
1572                        out,
1573                        "       {} {} {}",
1574                        s.arrow(),
1575                        s.cyan(target_name),
1576                        s.dim(&format!("({})", edge.edge_type))
1577                    );
1578                }
1579            }
1580            if !incoming.is_empty() {
1581                let _ = writeln!(
1582                    out,
1583                    "\n     {} Incoming edges ({})",
1584                    s.arrow(),
1585                    incoming.len()
1586                );
1587                for edge in &incoming {
1588                    let source_name = graph
1589                        .get_unit(edge.source_id)
1590                        .map(|u| u.qualified_name.as_str())
1591                        .unwrap_or("?");
1592                    let _ = writeln!(
1593                        out,
1594                        "       {} {} {}",
1595                        s.arrow(),
1596                        s.cyan(source_name),
1597                        s.dim(&format!("({})", edge.edge_type))
1598                    );
1599                }
1600            }
1601            let _ = writeln!(out);
1602        }
1603        OutputFormat::Json => {
1604            let out_edges: Vec<serde_json::Value> = outgoing
1605                .iter()
1606                .map(|e| {
1607                    serde_json::json!({
1608                        "target_id": e.target_id,
1609                        "edge_type": e.edge_type.label(),
1610                        "weight": e.weight,
1611                    })
1612                })
1613                .collect();
1614            let in_edges: Vec<serde_json::Value> = incoming
1615                .iter()
1616                .map(|e| {
1617                    serde_json::json!({
1618                        "source_id": e.source_id,
1619                        "edge_type": e.edge_type.label(),
1620                        "weight": e.weight,
1621                    })
1622                })
1623                .collect();
1624            let obj = serde_json::json!({
1625                "id": unit.id,
1626                "name": unit.name,
1627                "qualified_name": unit.qualified_name,
1628                "unit_type": unit.unit_type.label(),
1629                "language": unit.language.name(),
1630                "file": unit.file_path.display().to_string(),
1631                "span": unit.span.to_string(),
1632                "visibility": unit.visibility.to_string(),
1633                "complexity": unit.complexity,
1634                "is_async": unit.is_async,
1635                "is_generator": unit.is_generator,
1636                "stability_score": unit.stability_score,
1637                "signature": unit.signature,
1638                "doc_summary": unit.doc_summary,
1639                "outgoing_edges": out_edges,
1640                "incoming_edges": in_edges,
1641            });
1642            let _ = writeln!(out, "{}", serde_json::to_string_pretty(&obj)?);
1643        }
1644    }
1645
1646    Ok(())
1647}