Skip to main content

agentic_codebase/cli/
repl_commands.rs

1//! Slash command parsing and dispatch for the ACB REPL.
2//!
3//! Each slash command maps to functionality from the existing CLI,
4//! adapted for interactive session context (e.g., loaded graph tracking).
5
6use std::path::{Path, PathBuf};
7
8use crate::cli::output::{format_size, Styled};
9use crate::cli::repl_complete::COMMANDS;
10use crate::engine::query::{
11    CallDirection, CallGraphParams, CouplingParams, DependencyParams, ImpactParams, MatchMode,
12    ProphecyParams, QueryEngine, SimilarityParams, SymbolLookupParams,
13};
14use crate::format::{AcbReader, AcbWriter};
15use crate::graph::CodeGraph;
16use crate::parse::parser::{ParseOptions, Parser};
17use crate::semantic::analyzer::{AnalyzeOptions, SemanticAnalyzer};
18
19/// Session state preserved across commands.
20pub struct ReplState {
21    /// Currently loaded .acb graph for querying.
22    pub graph: Option<CodeGraph>,
23    /// Path to the loaded .acb file.
24    pub graph_path: Option<PathBuf>,
25}
26
27impl Default for ReplState {
28    fn default() -> Self {
29        Self::new()
30    }
31}
32
33impl ReplState {
34    pub fn new() -> Self {
35        Self {
36            graph: None,
37            graph_path: None,
38        }
39    }
40
41    /// Get the loaded graph or print an error hint.
42    fn require_graph(&self) -> Option<&CodeGraph> {
43        if let Some(ref g) = self.graph {
44            Some(g)
45        } else {
46            let s = Styled::auto();
47            eprintln!(
48                "  {} No graph loaded. Use {} or {}",
49                s.info(),
50                s.bold("/load <file.acb>"),
51                s.bold("/compile <dir>")
52            );
53            None
54        }
55    }
56}
57
58/// Parse and execute a slash command. Returns `true` if the REPL should exit.
59pub fn execute(input: &str, state: &mut ReplState) -> Result<bool, Box<dyn std::error::Error>> {
60    let input = input.trim();
61    if input.is_empty() {
62        return Ok(false);
63    }
64
65    // Strip leading / if present
66    let input = input.strip_prefix('/').unwrap_or(input);
67
68    // Bare `/` → show help
69    if input.is_empty() {
70        cmd_help();
71        return Ok(false);
72    }
73
74    // Split into command and arguments
75    let mut parts = input.splitn(2, ' ');
76    let cmd = parts.next().unwrap_or("");
77    let args = parts.next().unwrap_or("").trim();
78
79    match cmd {
80        "exit" | "quit" => return Ok(true),
81        "help" | "h" | "?" => cmd_help(),
82        "clear" | "cls" => cmd_clear(),
83        "compile" | "build" => cmd_compile(args, state)?,
84        "info" => cmd_info(args, state)?,
85        "load" => cmd_load(args, state)?,
86        "query" | "q" => cmd_query(args, state)?,
87        "get" => cmd_get(args, state)?,
88        "units" | "ls" => cmd_units(state)?,
89        _ => {
90            let s = Styled::auto();
91            if let Some(suggestion) = crate::cli::repl_complete::suggest_command(cmd) {
92                eprintln!(
93                    "  {} Unknown command '/{cmd}'. Did you mean {}?",
94                    s.warn(),
95                    s.bold(suggestion)
96                );
97            } else {
98                eprintln!(
99                    "  {} Unknown command '/{cmd}'. Type {} for commands.",
100                    s.warn(),
101                    s.bold("/help"),
102                );
103            }
104        }
105    }
106
107    Ok(false)
108}
109
110/// /help — Show available commands.
111fn cmd_help() {
112    let s = Styled::auto();
113    eprintln!();
114    eprintln!("  {}", s.bold("Commands:"));
115    eprintln!();
116    for (cmd, desc) in COMMANDS {
117        eprintln!("    {:<22} {}", s.cyan(cmd), s.dim(desc));
118    }
119    eprintln!();
120    eprintln!(
121        "  {}",
122        s.dim("Tip: Tab completion works for commands, query types, and .acb files.")
123    );
124    eprintln!();
125}
126
127/// /clear — Clear the terminal.
128fn cmd_clear() {
129    eprint!("\x1b[2J\x1b[H");
130}
131
132/// /compile <dir> — Compile a directory into an .acb graph.
133fn cmd_compile(args: &str, state: &mut ReplState) -> Result<(), Box<dyn std::error::Error>> {
134    let s = Styled::auto();
135
136    if args.is_empty() {
137        eprintln!("  {} Usage: {}", s.info(), s.bold("/compile <directory>"));
138        return Ok(());
139    }
140
141    let tokens: Vec<&str> = args.split_whitespace().collect();
142    let dir_path = Path::new(tokens[0]);
143
144    if !dir_path.exists() || !dir_path.is_dir() {
145        eprintln!(
146            "  {} Not a valid directory: {}",
147            s.fail(),
148            dir_path.display()
149        );
150        return Ok(());
151    }
152
153    let out_name = dir_path
154        .file_name()
155        .map(|n| n.to_string_lossy().to_string())
156        .unwrap_or_else(|| "output".to_string());
157    let out_path = PathBuf::from(format!("{}.acb", out_name));
158
159    eprintln!();
160    eprintln!(
161        "  {} Compiling {} {} {}",
162        s.info(),
163        s.bold(&dir_path.display().to_string()),
164        s.arrow(),
165        s.cyan(&out_path.display().to_string()),
166    );
167
168    let parser = Parser::new();
169    let parse_result = parser.parse_directory(dir_path, &ParseOptions::default())?;
170    eprintln!(
171        "  {} Parsed {} files ({} units)",
172        s.ok(),
173        parse_result.stats.files_parsed,
174        parse_result.units.len(),
175    );
176
177    let analyzer = SemanticAnalyzer::new();
178    let graph = analyzer.analyze(parse_result.units, &AnalyzeOptions::default())?;
179
180    let writer = AcbWriter::with_default_dimension();
181    writer.write_to_file(&graph, &out_path)?;
182
183    let file_size = std::fs::metadata(&out_path).map(|m| m.len()).unwrap_or(0);
184    eprintln!(
185        "  {} Compiled: {} units, {} edges ({})",
186        s.ok(),
187        s.bold(&graph.unit_count().to_string()),
188        graph.edge_count(),
189        s.dim(&format_size(file_size)),
190    );
191
192    // Auto-load the compiled graph
193    state.graph_path = Some(out_path.clone());
194    state.graph = Some(graph);
195    eprintln!(
196        "  {} Graph loaded. Try: {}",
197        s.info(),
198        s.cyan("/query symbol --name <search>")
199    );
200    eprintln!();
201
202    Ok(())
203}
204
205/// /load <file.acb> — Load an .acb file into the session.
206fn cmd_load(args: &str, state: &mut ReplState) -> Result<(), Box<dyn std::error::Error>> {
207    let s = Styled::auto();
208
209    if args.is_empty() {
210        eprintln!("  {} Usage: {}", s.info(), s.bold("/load <file.acb>"));
211        return Ok(());
212    }
213
214    let path = PathBuf::from(args.split_whitespace().next().unwrap_or(args));
215    if !path.exists() {
216        eprintln!("  {} File not found: {}", s.fail(), path.display());
217        return Ok(());
218    }
219
220    let graph = AcbReader::read_from_file(&path)?;
221    eprintln!(
222        "  {} Loaded {} ({} units, {} edges)",
223        s.ok(),
224        s.bold(&path.display().to_string()),
225        graph.unit_count(),
226        graph.edge_count(),
227    );
228
229    state.graph_path = Some(path);
230    state.graph = Some(graph);
231    Ok(())
232}
233
234/// /info [file] — Display summary of loaded graph or a specified file.
235fn cmd_info(args: &str, state: &mut ReplState) -> Result<(), Box<dyn std::error::Error>> {
236    let s = Styled::auto();
237
238    let graph = if args.is_empty() {
239        match state.require_graph() {
240            Some(g) => g,
241            None => return Ok(()),
242        }
243    } else {
244        let path = PathBuf::from(args.split_whitespace().next().unwrap_or(args));
245        let g = AcbReader::read_from_file(&path)?;
246        state.graph_path = Some(path);
247        state.graph = Some(g);
248        state.graph.as_ref().unwrap()
249    };
250
251    let file_label = state
252        .graph_path
253        .as_ref()
254        .map(|p| p.display().to_string())
255        .unwrap_or_else(|| "(in-memory)".to_string());
256
257    eprintln!();
258    eprintln!("  {} {}", s.info(), s.bold(&file_label));
259    eprintln!(
260        "     Units:     {}",
261        s.bold(&graph.unit_count().to_string())
262    );
263    eprintln!(
264        "     Edges:     {}",
265        s.bold(&graph.edge_count().to_string())
266    );
267    eprintln!(
268        "     Languages: {}",
269        s.bold(&graph.languages().len().to_string())
270    );
271    for lang in graph.languages() {
272        let count = graph.units().iter().filter(|u| u.language == *lang).count();
273        eprintln!(
274            "     {} {} {}",
275            s.arrow(),
276            s.cyan(&format!("{:12}", lang)),
277            s.dim(&format!("{} units", count))
278        );
279    }
280    eprintln!();
281
282    Ok(())
283}
284
285/// /query <type> [flags] — Run a query against the loaded graph.
286fn cmd_query(args: &str, state: &mut ReplState) -> Result<(), Box<dyn std::error::Error>> {
287    let s = Styled::auto();
288    let graph = match state.require_graph() {
289        Some(g) => g,
290        None => return Ok(()),
291    };
292
293    let engine = QueryEngine::new();
294    let tokens: Vec<&str> = args.split_whitespace().collect();
295
296    if tokens.is_empty() {
297        eprintln!(
298            "  {} Usage: {}",
299            s.info(),
300            s.bold("/query <type> [--name <n>] [--unit-id <id>] [--depth <d>] [--limit <l>]")
301        );
302        eprintln!(
303            "  {} Types: symbol, deps, rdeps, impact, calls, similar, prophecy, stability, coupling",
304            s.dim("  ")
305        );
306        return Ok(());
307    }
308
309    let query_type = tokens[0];
310    let mut name: Option<String> = None;
311    let mut unit_id: Option<u64> = None;
312    let mut depth: u32 = 3;
313    let mut limit: usize = 20;
314
315    // Simple flag parser
316    let mut i = 1;
317    while i < tokens.len() {
318        match tokens[i] {
319            "--name" | "-n" if i + 1 < tokens.len() => {
320                name = Some(tokens[i + 1].to_string());
321                i += 2;
322            }
323            "--unit-id" | "-u" if i + 1 < tokens.len() => {
324                unit_id = tokens[i + 1].parse().ok();
325                i += 2;
326            }
327            "--depth" | "-d" if i + 1 < tokens.len() => {
328                depth = tokens[i + 1].parse().unwrap_or(3);
329                i += 2;
330            }
331            "--limit" | "-l" if i + 1 < tokens.len() => {
332                limit = tokens[i + 1].parse().unwrap_or(20);
333                i += 2;
334            }
335            _ => {
336                // Bare argument — treat as name for symbol, or unit-id for others
337                if query_type == "symbol" && name.is_none() {
338                    name = Some(tokens[i].to_string());
339                } else if unit_id.is_none() {
340                    unit_id = tokens[i].parse().ok();
341                }
342                i += 1;
343            }
344        }
345    }
346
347    match query_type {
348        "symbol" | "sym" | "s" => {
349            let search = match name {
350                Some(n) => n,
351                None => {
352                    eprintln!("  {} --name is required for symbol queries", s.fail());
353                    return Ok(());
354                }
355            };
356            let params = SymbolLookupParams {
357                name: search.clone(),
358                mode: MatchMode::Contains,
359                limit,
360                ..Default::default()
361            };
362            let results = engine.symbol_lookup(graph, params)?;
363            eprintln!(
364                "\n  Symbol lookup: {} ({} results)\n",
365                s.bold(&format!("\"{}\"", search)),
366                results.len()
367            );
368            for (i, unit) in results.iter().enumerate() {
369                eprintln!(
370                    "  {:>3}. {} {} {}",
371                    s.dim(&format!("#{}", i + 1)),
372                    s.bold(&unit.qualified_name),
373                    s.dim(&format!("({})", unit.unit_type)),
374                    s.dim(&format!("[id:{}]", unit.id))
375                );
376            }
377            eprintln!();
378        }
379
380        "deps" | "dep" | "d" => {
381            let uid = match unit_id {
382                Some(u) => u,
383                None => {
384                    eprintln!("  {} --unit-id is required for deps queries", s.fail());
385                    return Ok(());
386                }
387            };
388            let params = DependencyParams {
389                unit_id: uid,
390                max_depth: depth,
391                edge_types: vec![],
392                include_transitive: true,
393            };
394            let result = engine.dependency_graph(graph, params)?;
395            let root = graph
396                .get_unit(uid)
397                .map(|u| u.qualified_name.as_str())
398                .unwrap_or("?");
399            eprintln!(
400                "\n  Dependencies of {} ({} found)\n",
401                s.bold(root),
402                result.nodes.len()
403            );
404            for node in &result.nodes {
405                let name = graph
406                    .get_unit(node.unit_id)
407                    .map(|u| u.qualified_name.as_str())
408                    .unwrap_or("?");
409                let indent = "  ".repeat(node.depth as usize);
410                eprintln!("  {}{} {}", indent, s.arrow(), s.cyan(name));
411            }
412            eprintln!();
413        }
414
415        "rdeps" | "rdep" | "r" => {
416            let uid = match unit_id {
417                Some(u) => u,
418                None => {
419                    eprintln!("  {} --unit-id is required for rdeps queries", s.fail());
420                    return Ok(());
421                }
422            };
423            let params = DependencyParams {
424                unit_id: uid,
425                max_depth: depth,
426                edge_types: vec![],
427                include_transitive: true,
428            };
429            let result = engine.reverse_dependency(graph, params)?;
430            let root = graph
431                .get_unit(uid)
432                .map(|u| u.qualified_name.as_str())
433                .unwrap_or("?");
434            eprintln!(
435                "\n  Reverse deps of {} ({} found)\n",
436                s.bold(root),
437                result.nodes.len()
438            );
439            for node in &result.nodes {
440                let name = graph
441                    .get_unit(node.unit_id)
442                    .map(|u| u.qualified_name.as_str())
443                    .unwrap_or("?");
444                let indent = "  ".repeat(node.depth as usize);
445                eprintln!("  {}{} {}", indent, s.arrow(), s.cyan(name));
446            }
447            eprintln!();
448        }
449
450        "impact" | "imp" | "i" => {
451            let uid = match unit_id {
452                Some(u) => u,
453                None => {
454                    eprintln!("  {} --unit-id is required for impact queries", s.fail());
455                    return Ok(());
456                }
457            };
458            let params = ImpactParams {
459                unit_id: uid,
460                max_depth: depth,
461                edge_types: vec![],
462            };
463            let result = engine.impact_analysis(graph, params)?;
464            let root = graph
465                .get_unit(uid)
466                .map(|u| u.qualified_name.as_str())
467                .unwrap_or("?");
468
469            let risk_label = if result.overall_risk >= 0.7 {
470                s.red("HIGH")
471            } else if result.overall_risk >= 0.4 {
472                s.yellow("MEDIUM")
473            } else {
474                s.green("LOW")
475            };
476
477            eprintln!("\n  Impact of {} (risk: {})\n", s.bold(root), risk_label,);
478            for imp in &result.impacted {
479                let name = graph
480                    .get_unit(imp.unit_id)
481                    .map(|u| u.qualified_name.as_str())
482                    .unwrap_or("?");
483                let risk_sym = if imp.risk_score >= 0.7 {
484                    s.fail()
485                } else if imp.risk_score >= 0.4 {
486                    s.warn()
487                } else {
488                    s.ok()
489                };
490                eprintln!(
491                    "  {} {} {} risk:{:.2}",
492                    risk_sym,
493                    s.cyan(name),
494                    s.dim(&format!("(depth {})", imp.depth)),
495                    imp.risk_score,
496                );
497            }
498            eprintln!();
499        }
500
501        "calls" | "call" | "c" => {
502            let uid = match unit_id {
503                Some(u) => u,
504                None => {
505                    eprintln!("  {} --unit-id is required for calls queries", s.fail());
506                    return Ok(());
507                }
508            };
509            let params = CallGraphParams {
510                unit_id: uid,
511                direction: CallDirection::Both,
512                max_depth: depth,
513            };
514            let result = engine.call_graph(graph, params)?;
515            let root = graph
516                .get_unit(uid)
517                .map(|u| u.qualified_name.as_str())
518                .unwrap_or("?");
519            eprintln!(
520                "\n  Call graph for {} ({} nodes)\n",
521                s.bold(root),
522                result.nodes.len()
523            );
524            for (nid, d) in &result.nodes {
525                let name = graph
526                    .get_unit(*nid)
527                    .map(|u| u.qualified_name.as_str())
528                    .unwrap_or("?");
529                let indent = "  ".repeat(*d as usize);
530                eprintln!("  {}{} {}", indent, s.arrow(), s.cyan(name));
531            }
532            eprintln!();
533        }
534
535        "similar" | "sim" => {
536            let uid = match unit_id {
537                Some(u) => u,
538                None => {
539                    eprintln!("  {} --unit-id is required for similar queries", s.fail());
540                    return Ok(());
541                }
542            };
543            let params = SimilarityParams {
544                unit_id: uid,
545                top_k: limit,
546                min_similarity: 0.0,
547            };
548            let results = engine.similarity(graph, params)?;
549            let root = graph
550                .get_unit(uid)
551                .map(|u| u.qualified_name.as_str())
552                .unwrap_or("?");
553            eprintln!(
554                "\n  Similar to {} ({} matches)\n",
555                s.bold(root),
556                results.len()
557            );
558            for (i, m) in results.iter().enumerate() {
559                let name = graph
560                    .get_unit(m.unit_id)
561                    .map(|u| u.qualified_name.as_str())
562                    .unwrap_or("?");
563                eprintln!(
564                    "  {:>3}. {} {}",
565                    s.dim(&format!("#{}", i + 1)),
566                    s.cyan(name),
567                    s.yellow(&format!("{:.1}%", m.score * 100.0)),
568                );
569            }
570            eprintln!();
571        }
572
573        "prophecy" | "predict" | "p" => {
574            let params = ProphecyParams {
575                top_k: limit,
576                min_risk: 0.0,
577            };
578            let result = engine.prophecy(graph, params)?;
579            eprintln!(
580                "\n  {} Prophecy ({} predictions)\n",
581                s.info(),
582                result.predictions.len()
583            );
584            if result.predictions.is_empty() {
585                eprintln!("  {} Codebase looks stable!", s.ok());
586            }
587            for pred in &result.predictions {
588                let name = graph
589                    .get_unit(pred.unit_id)
590                    .map(|u| u.qualified_name.as_str())
591                    .unwrap_or("?");
592                let risk_sym = if pred.risk_score >= 0.7 {
593                    s.fail()
594                } else if pred.risk_score >= 0.4 {
595                    s.warn()
596                } else {
597                    s.ok()
598                };
599                eprintln!(
600                    "  {} {} {}: {}",
601                    risk_sym,
602                    s.cyan(name),
603                    s.dim(&format!("(risk {:.2})", pred.risk_score)),
604                    pred.reason,
605                );
606            }
607            eprintln!();
608        }
609
610        "stability" | "stab" => {
611            let uid = match unit_id {
612                Some(u) => u,
613                None => {
614                    eprintln!("  {} --unit-id is required for stability queries", s.fail());
615                    return Ok(());
616                }
617            };
618            let result = engine.stability_analysis(graph, uid)?;
619            let root = graph
620                .get_unit(uid)
621                .map(|u| u.qualified_name.as_str())
622                .unwrap_or("?");
623            let score_color = if result.overall_score >= 0.7 {
624                s.green(&format!("{:.2}", result.overall_score))
625            } else if result.overall_score >= 0.4 {
626                s.yellow(&format!("{:.2}", result.overall_score))
627            } else {
628                s.red(&format!("{:.2}", result.overall_score))
629            };
630            eprintln!("\n  Stability of {}: {}\n", s.bold(root), score_color);
631            for factor in &result.factors {
632                eprintln!(
633                    "  {} {} = {:.2}: {}",
634                    s.arrow(),
635                    s.bold(&factor.name),
636                    factor.value,
637                    s.dim(&factor.description),
638                );
639            }
640            eprintln!();
641        }
642
643        "coupling" | "couple" => {
644            let params = CouplingParams {
645                unit_id,
646                min_strength: 0.0,
647            };
648            let results = engine.coupling_detection(graph, params)?;
649            eprintln!("\n  Coupling analysis ({} pairs)\n", results.len());
650            if results.is_empty() {
651                eprintln!("  {} No tightly coupled pairs detected.", s.ok());
652            }
653            for c in &results {
654                let name_a = graph
655                    .get_unit(c.unit_a)
656                    .map(|u| u.qualified_name.as_str())
657                    .unwrap_or("?");
658                let name_b = graph
659                    .get_unit(c.unit_b)
660                    .map(|u| u.qualified_name.as_str())
661                    .unwrap_or("?");
662                eprintln!(
663                    "  {} {} {} {} {}",
664                    s.warn(),
665                    s.cyan(name_a),
666                    s.dim("<->"),
667                    s.cyan(name_b),
668                    s.yellow(&format!("{:.0}%", c.strength * 100.0)),
669                );
670            }
671            eprintln!();
672        }
673
674        other => {
675            let known = [
676                "symbol",
677                "deps",
678                "rdeps",
679                "impact",
680                "calls",
681                "similar",
682                "prophecy",
683                "stability",
684                "coupling",
685            ];
686            eprintln!(
687                "  {} Unknown query type: {}. Available: {}",
688                s.fail(),
689                other,
690                known.join(", ")
691            );
692        }
693    }
694
695    Ok(())
696}
697
698/// /get <unit-id> — Show detailed unit info.
699fn cmd_get(args: &str, state: &mut ReplState) -> Result<(), Box<dyn std::error::Error>> {
700    let s = Styled::auto();
701    let graph = match state.require_graph() {
702        Some(g) => g,
703        None => return Ok(()),
704    };
705
706    let uid: u64 = match args.split_whitespace().next().and_then(|s| s.parse().ok()) {
707        Some(id) => id,
708        None => {
709            eprintln!("  {} Usage: {}", s.info(), s.bold("/get <unit-id>"));
710            return Ok(());
711        }
712    };
713
714    let unit = match graph.get_unit(uid) {
715        Some(u) => u,
716        None => {
717            eprintln!("  {} Unit {} not found", s.fail(), uid);
718            return Ok(());
719        }
720    };
721
722    let outgoing = graph.edges_from(uid);
723    let incoming = graph.edges_to(uid);
724
725    eprintln!();
726    eprintln!("  {} {}", s.info(), s.bold(&format!("Unit {}", unit.id)));
727    eprintln!("     Name:           {}", s.cyan(&unit.name));
728    eprintln!("     Qualified name: {}", s.bold(&unit.qualified_name));
729    eprintln!("     Type:           {}", unit.unit_type);
730    eprintln!("     Language:       {}", unit.language);
731    eprintln!(
732        "     File:           {}",
733        s.cyan(&unit.file_path.display().to_string())
734    );
735    eprintln!("     Span:           {}", unit.span);
736    eprintln!("     Complexity:     {}", unit.complexity);
737    eprintln!("     Stability:      {:.2}", unit.stability_score);
738
739    if let Some(sig) = &unit.signature {
740        eprintln!("     Signature:      {}", s.dim(sig));
741    }
742    if let Some(doc) = &unit.doc_summary {
743        eprintln!("     Doc:            {}", s.dim(doc));
744    }
745
746    if !outgoing.is_empty() {
747        eprintln!("\n     {} Outgoing edges ({})", s.arrow(), outgoing.len());
748        for edge in &outgoing {
749            let target_name = graph
750                .get_unit(edge.target_id)
751                .map(|u| u.qualified_name.as_str())
752                .unwrap_or("?");
753            eprintln!(
754                "       {} {} {}",
755                s.arrow(),
756                s.cyan(target_name),
757                s.dim(&format!("({})", edge.edge_type))
758            );
759        }
760    }
761    if !incoming.is_empty() {
762        eprintln!("\n     {} Incoming edges ({})", s.arrow(), incoming.len());
763        for edge in &incoming {
764            let source_name = graph
765                .get_unit(edge.source_id)
766                .map(|u| u.qualified_name.as_str())
767                .unwrap_or("?");
768            eprintln!(
769                "       {} {} {}",
770                s.arrow(),
771                s.cyan(source_name),
772                s.dim(&format!("({})", edge.edge_type))
773            );
774        }
775    }
776    eprintln!();
777
778    Ok(())
779}
780
781/// /units — List all units in the loaded graph.
782fn cmd_units(state: &mut ReplState) -> Result<(), Box<dyn std::error::Error>> {
783    let s = Styled::auto();
784    let graph = match state.require_graph() {
785        Some(g) => g,
786        None => return Ok(()),
787    };
788
789    eprintln!("\n  {} units in graph:\n", graph.unit_count());
790    for unit in graph.units() {
791        eprintln!(
792            "  {:>5}  {} {} {}",
793            s.dim(&format!("[{}]", unit.id)),
794            s.bold(&unit.qualified_name),
795            s.dim(&format!("({})", unit.unit_type)),
796            s.dim(&format!("c:{}", unit.complexity)),
797        );
798    }
799    eprintln!();
800
801    Ok(())
802}