Skip to main content

lean_ctx/tools/
ctx_impact.rs

1//! `ctx_impact` — Graph-based impact analysis tool.
2//!
3//! Uses the SQLite-backed Property Graph to answer: "What breaks when file X changes?"
4//! Performs BFS traversal of reverse import edges to find all transitively affected files.
5
6use crate::core::property_graph::{CodeGraph, DependencyChain, Edge, EdgeKind, ImpactResult, Node};
7use crate::core::tokens::count_tokens;
8use crate::core::type_ref_edges::{DefIndex, ExtMethodIndex};
9use serde_json::{Value, json};
10use std::collections::BTreeSet;
11use std::path::Path;
12use std::process::Stdio;
13
14use crate::core::git_util::{git_dirty, git_out};
15use crate::tools::graph_meta::{graph_summary, project_meta};
16use crate::tools::output_format::{OutputFormat, parse_format};
17
18/// Extensions whose files become Property Graph source nodes. Must stay a subset
19/// of `language_capabilities::is_indexable_ext` and align with the deep-query
20/// extractors (`deep_queries::{type_defs, calls}`) so each language contributes
21/// real symbol/import/call structure rather than bare file nodes.
22const GRAPH_SOURCE_EXTS: &[&str] = &[
23    "rs", "ts", "tsx", "js", "jsx", "py", "go", "java", "gd", "cs", "kt", "kts",
24];
25
26pub fn handle(
27    action: &str,
28    path: Option<&str>,
29    root: &str,
30    depth: Option<usize>,
31    format: Option<&str>,
32) -> String {
33    let fmt = match parse_format(format) {
34        Ok(f) => f,
35        Err(e) => return e,
36    };
37
38    match action {
39        "analyze" => handle_analyze(path, root, depth.unwrap_or(5), fmt),
40        "diff" => handle_diff(root, depth.unwrap_or(5), fmt),
41        "chain" => handle_chain(path, root, fmt),
42        "build" => handle_build(root, fmt),
43        "update" => handle_update(root, fmt),
44        "status" => handle_status(root, fmt),
45        "parity" => handle_parity(root, fmt),
46        _ => "Unknown action. Use: analyze, diff, chain, build, status, update, parity".to_string(),
47    }
48}
49
50/// Shadow-mode parity proof (#682.3): build an in-memory PropertyGraph from the
51/// current graph_index and quantify whether PG reproduces everything the
52/// facade exposes (symbols, edges, dependencies) before any backend flip.
53fn handle_parity(root: &str, fmt: OutputFormat) -> String {
54    // Compare the *fresh extractor* output (a real graph_index scan, built
55    // in-memory from the file walk + signature extraction) against a
56    // PropertyGraph populated from it — the genuine "mirror is lossless"
57    // invariant. Loading the persisted index would be circular since #696 C4
58    // (it is itself materialized from the PG), yielding a meaningless trivially
59    // lossless result, so always rescan to keep this a real proof.
60    let index = crate::core::graph_index::scan_with_content_cache(root).0;
61
62    let report = match crate::core::graph_parity::compare(&index) {
63        Ok(r) => r,
64        Err(e) => return format!("Parity comparison failed: {e}"),
65    };
66
67    match fmt {
68        OutputFormat::Json => {
69            let v = json!({
70                "tool": "ctx_impact",
71                "action": "parity",
72                "lossless": report.is_lossless(),
73                "files": report.files,
74                "symbols": { "gi": report.symbol_count_gi, "pg": report.symbol_count_pg,
75                             "matched": report.symbols_matched, "checked": report.symbols_checked },
76                "edges": { "gi": report.edge_count_gi, "pg": report.edge_count_pg,
77                           "superset": report.edge_pairs_lossless },
78                "dependencies": { "lossless": report.dependencies_lossless,
79                                  "checked": report.files_checked, "extra": report.dependencies_extra },
80                "dependents": { "lossless": report.dependents_lossless, "checked": report.files_checked },
81                "divergences": report.divergences,
82            });
83            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
84        }
85        OutputFormat::Text => {
86            let body = crate::core::graph_parity::format_report(&report);
87            let tokens = count_tokens(&body);
88            format!("{body}\n[ctx_impact parity: {tokens} tok]")
89        }
90    }
91}
92
93fn open_graph(root: &str) -> Result<CodeGraph, String> {
94    CodeGraph::open(root).map_err(|e| format!("Failed to open graph: {e}"))
95}
96
97/// Open the property graph for a *query*, rebuilding first when it cannot be
98/// trusted: either empty (never built) or produced by an engine older than
99/// [`crate::core::property_graph::GRAPH_ENGINE_VERSION`] — e.g. an upgraded
100/// install whose graph predates the C#/Java `type_ref` edges (GH #398). The
101/// rebuild is one-shot and idempotent: a fresh build stamps the current engine
102/// version, so a healthy graph is returned without rebuilding.
103fn open_graph_fresh(root: &str) -> Result<CodeGraph, String> {
104    let graph = open_graph(root)?;
105    let empty = graph.node_count().unwrap_or(0) == 0;
106    let outdated = !empty && crate::core::property_graph::engine_outdated(root);
107    if empty || outdated {
108        drop(graph);
109        let build_result = handle_build(root, OutputFormat::Text);
110        tracing::info!(
111            "Rebuilt property graph before impact query ({}): {}",
112            if empty { "empty" } else { "engine outdated" },
113            &build_result[..build_result.len().min(100)]
114        );
115        return open_graph(root);
116    }
117    Ok(graph)
118}
119
120fn handle_analyze(path: Option<&str>, root: &str, max_depth: usize, fmt: OutputFormat) -> String {
121    let Some(target) = path else {
122        return "path is required for 'analyze' action".to_string();
123    };
124
125    let graph = match open_graph_fresh(root) {
126        Ok(g) => g,
127        Err(e) => return e,
128    };
129
130    if graph.node_count().unwrap_or(0) == 0 {
131        return "Graph is empty after auto-build. No supported source files found.".to_string();
132    }
133
134    let rel_target = graph_target_key(target, root);
135
136    // 1) Direct file-node match — the documented contract (a file path).
137    if graph.get_node_by_path(&rel_target).ok().flatten().is_some() {
138        let impact = match graph.impact_analysis(&rel_target, max_depth) {
139            Ok(r) => r,
140            Err(e) => return format!("Impact analysis failed: {e}"),
141        };
142        return format_impact(&impact, &rel_target, root, fmt);
143    }
144
145    // 2) Symbol-name fallback (GH #398): callers — and LLMs — routinely ask for
146    //    the impact of a *class/type* by name (`ctx_impact analyze ArcPoint`)
147    //    rather than its file path. Resolve the bare name to the file(s) that
148    //    define it and report their combined blast radius, instead of the
149    //    misleading "leaf node" answer a non-file target produced before.
150    let symbol = symbol_query_name(target);
151    if !symbol.is_empty()
152        && let Ok(def_files) = graph.resolve_symbol_def_files(&symbol)
153        && !def_files.is_empty()
154    {
155        return analyze_symbol(&graph, &symbol, &def_files, root, max_depth, fmt);
156    }
157
158    // 3) Neither a file nor a known symbol: an actionable diagnostic beats a
159    //    false "no impact".
160    analyze_unresolved(&graph, target, &rel_target, root, fmt)
161}
162
163/// Reduce a user-supplied target to a bare symbol name for the #398 fallback:
164/// drop any directory prefix and a single trailing source extension, so
165/// `Models/ArcPoint.cs`, `ArcPoint.cs` and `ArcPoint` all query `ArcPoint`.
166/// Returns an empty string for inputs that cannot name a single symbol
167/// (namespace separators, generics, globs, whitespace) — those would only
168/// produce bogus matches.
169fn symbol_query_name(target: &str) -> String {
170    let base = target.rsplit(['/', '\\']).next().unwrap_or(target).trim();
171    let stem = base
172        .rsplit_once('.')
173        .filter(|(_, ext)| GRAPH_SOURCE_EXTS.contains(ext))
174        .map_or(base, |(s, _)| s);
175    if stem.is_empty()
176        || stem.contains(|c: char| {
177            c.is_whitespace() || matches!(c, '.' | ':' | '*' | '<' | '>' | '(' | ')' | '/' | '\\')
178        })
179    {
180        return String::new();
181    }
182    stem.to_string()
183}
184
185/// Combined blast radius of every file that defines `symbol` (GH #398
186/// symbol-name fallback). The defining files are what changes, so they are
187/// excluded from the affected set; the resolved files are surfaced so the
188/// answer stays transparent. Output is sorted + capped for determinism (#498).
189fn analyze_symbol(
190    graph: &CodeGraph,
191    symbol: &str,
192    def_files: &[String],
193    root: &str,
194    max_depth: usize,
195    fmt: OutputFormat,
196) -> String {
197    let mut affected: BTreeSet<String> = BTreeSet::new();
198    let mut max_depth_reached = 0usize;
199    let mut edges_traversed = 0usize;
200    for f in def_files {
201        if let Ok(r) = graph.impact_analysis(f, max_depth) {
202            max_depth_reached = max_depth_reached.max(r.max_depth_reached);
203            edges_traversed += r.edges_traversed;
204            affected.extend(r.affected_files);
205        }
206    }
207    // The definers are the thing being changed, not impacted by it.
208    for f in def_files {
209        affected.remove(f);
210    }
211
212    let mut sorted: Vec<String> = affected.into_iter().collect();
213    let total = sorted.len();
214    let limit = crate::core::budgets::IMPACT_AFFECTED_FILES_LIMIT.max(1);
215    let truncated = total > limit;
216    if truncated {
217        sorted.truncate(limit);
218    }
219
220    match fmt {
221        OutputFormat::Json => {
222            let v = json!({
223                "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
224                "tool": "ctx_impact",
225                "action": "analyze",
226                "project": project_meta(root),
227                "graph": graph_summary(root),
228                "graph_meta": crate::core::property_graph::load_meta(root),
229                "target": symbol,
230                "resolved_from": "symbol",
231                "defined_in": def_files,
232                "max_depth_reached": max_depth_reached,
233                "edges_traversed": edges_traversed,
234                "affected_files_total": total,
235                "affected_files": sorted,
236                "truncated": truncated
237            });
238            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
239        }
240        OutputFormat::Text => {
241            let defined = def_files.join(", ");
242            if total == 0 {
243                let result = format!(
244                    "No files depend on {symbol} (defined in {defined}); it is a leaf in the dependency graph."
245                );
246                let tokens = count_tokens(&result);
247                return format!("{result}\n[ctx_impact: {tokens} tok]");
248            }
249            let mut result = format!(
250                "Impact of changing {symbol} (defined in {defined}): {total} affected files \
251                 (depth: {max_depth_reached}, edges traversed: {edges_traversed})\n"
252            );
253            for file in &sorted {
254                result.push_str(&format!("  {file}\n"));
255            }
256            if truncated {
257                result.push_str(&format!("  ... +{} more\n", total - limit));
258            }
259            let tokens = count_tokens(&result);
260            format!("{result}[ctx_impact: {tokens} tok]")
261        }
262    }
263}
264
265/// Diagnostic for an `analyze` target that matched neither a file node nor a
266/// symbol. Replaces the old silent "leaf node" answer — indistinguishable from
267/// a real leaf — with the indexed counts and a concrete next step (GH #398).
268fn analyze_unresolved(
269    graph: &CodeGraph,
270    target: &str,
271    rel_target: &str,
272    root: &str,
273    fmt: OutputFormat,
274) -> String {
275    let files = graph.file_node_count().unwrap_or(0);
276    let symbols = graph.symbol_count().unwrap_or(0);
277    match fmt {
278        OutputFormat::Json => {
279            let v = json!({
280                "tool": "ctx_impact",
281                "action": "analyze",
282                "project": project_meta(root),
283                "graph": graph_summary(root),
284                "target": target,
285                "resolved": false,
286                "indexed_files": files,
287                "indexed_symbols": symbols,
288                "hint": "Target is neither an indexed file path nor a known symbol. Pass a path relative to the project root, or rebuild with action='build'."
289            });
290            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
291        }
292        OutputFormat::Text => {
293            let result = format!(
294                "'{target}' is not a known file or symbol in the graph \
295                 ({files} files, {symbols} symbols indexed).\n  \
296                 - As a file: pass a path relative to the project root (looked up '{rel_target}').\n  \
297                 - As a class/type: check the spelling, or run ctx_impact action='build' to (re)index."
298            );
299            let tokens = count_tokens(&result);
300            format!("{result}\n[ctx_impact: {tokens} tok]")
301        }
302    }
303}
304
305fn format_impact(impact: &ImpactResult, target: &str, root: &str, fmt: OutputFormat) -> String {
306    let mut sorted = impact.affected_files.clone();
307    sorted.sort();
308
309    let total = sorted.len();
310    let limit = crate::core::budgets::IMPACT_AFFECTED_FILES_LIMIT.max(1);
311    let truncated = total > limit;
312    if truncated {
313        sorted.truncate(limit);
314    }
315
316    match fmt {
317        OutputFormat::Json => {
318            let v = json!({
319                "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
320                "tool": "ctx_impact",
321                "action": "analyze",
322                "project": project_meta(root),
323                "graph": graph_summary(root),
324                "graph_meta": crate::core::property_graph::load_meta(root),
325                "target": target,
326                "max_depth_reached": impact.max_depth_reached,
327                "edges_traversed": impact.edges_traversed,
328                "affected_files_total": total,
329                "affected_files": sorted,
330                "truncated": truncated
331            });
332            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
333        }
334        OutputFormat::Text => {
335            if total == 0 {
336                let result =
337                    format!("No files depend on {target} (leaf node in the dependency graph).");
338                let tokens = count_tokens(&result);
339                return format!("{result}\n[ctx_impact: {tokens} tok]");
340            }
341
342            let mut result = format!(
343                "Impact of changing {target}: {total} affected files (depth: {}, edges traversed: {})\n",
344                impact.max_depth_reached, impact.edges_traversed
345            );
346
347            for file in &sorted {
348                result.push_str(&format!("  {file}\n"));
349            }
350            if truncated {
351                result.push_str(&format!("  ... +{} more\n", total - limit));
352            }
353
354            let tokens = count_tokens(&result);
355            format!("{result}[ctx_impact: {tokens} tok]")
356        }
357    }
358}
359
360fn handle_diff(root: &str, max_depth: usize, fmt: OutputFormat) -> String {
361    let changed = git_changed_files(root);
362    if changed.is_empty() {
363        return match fmt {
364            OutputFormat::Json => {
365                let v = json!({
366                    "tool": "ctx_impact",
367                    "action": "diff",
368                    "changed_files": [],
369                    "blast_radius": [],
370                    "total_affected": 0
371                });
372                serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
373            }
374            OutputFormat::Text => "No uncommitted changes found.".to_string(),
375        };
376    }
377
378    let graph = match open_graph_fresh(root) {
379        Ok(g) => g,
380        Err(e) => return e,
381    };
382
383    compute_diff_impact(&graph, &changed, root, max_depth, fmt)
384}
385
386fn git_changed_files(root: &str) -> Vec<String> {
387    let output = std::process::Command::new("git")
388        .args(["diff", "--name-only", "HEAD"])
389        .current_dir(root)
390        .stdout(Stdio::piped())
391        .stderr(Stdio::null())
392        .output();
393
394    let mut files: BTreeSet<String> = BTreeSet::new();
395
396    if let Ok(o) = output
397        && o.status.success()
398    {
399        for line in String::from_utf8_lossy(&o.stdout).lines() {
400            let trimmed = line.trim();
401            if !trimmed.is_empty() {
402                files.insert(trimmed.to_string());
403            }
404        }
405    }
406
407    let staged = std::process::Command::new("git")
408        .args(["diff", "--name-only", "--cached"])
409        .current_dir(root)
410        .stdout(Stdio::piped())
411        .stderr(Stdio::null())
412        .output();
413
414    if let Ok(o) = staged
415        && o.status.success()
416    {
417        for line in String::from_utf8_lossy(&o.stdout).lines() {
418            let trimmed = line.trim();
419            if !trimmed.is_empty() {
420                files.insert(trimmed.to_string());
421            }
422        }
423    }
424
425    let untracked = std::process::Command::new("git")
426        .args(["ls-files", "--others", "--exclude-standard"])
427        .current_dir(root)
428        .stdout(Stdio::piped())
429        .stderr(Stdio::null())
430        .output();
431
432    if let Ok(o) = untracked
433        && o.status.success()
434    {
435        for line in String::from_utf8_lossy(&o.stdout).lines() {
436            let trimmed = line.trim();
437            if !trimmed.is_empty() {
438                files.insert(trimmed.to_string());
439            }
440        }
441    }
442
443    files.into_iter().collect()
444}
445
446fn compute_diff_impact(
447    graph: &CodeGraph,
448    changed: &[String],
449    root: &str,
450    max_depth: usize,
451    fmt: OutputFormat,
452) -> String {
453    let mut all_affected: BTreeSet<String> = BTreeSet::new();
454    let mut per_file: Vec<(String, Vec<String>)> = Vec::new();
455
456    for file in changed {
457        let rel = graph_target_key(file, root);
458        if let Ok(impact) = graph.impact_analysis(&rel, max_depth) {
459            let mut affected: Vec<String> = impact
460                .affected_files
461                .into_iter()
462                .filter(|f| !changed.contains(f))
463                .collect();
464            affected.sort();
465            for a in &affected {
466                all_affected.insert(a.clone());
467            }
468            if !affected.is_empty() {
469                per_file.push((rel, affected));
470            }
471        }
472    }
473
474    match fmt {
475        OutputFormat::Json => {
476            let items: Vec<Value> = per_file
477                .iter()
478                .map(|(file, affected)| {
479                    json!({
480                        "changed_file": file,
481                        "affected": affected,
482                        "count": affected.len()
483                    })
484                })
485                .collect();
486            let v = json!({
487                "tool": "ctx_impact",
488                "action": "diff",
489                "changed_files": changed,
490                "blast_radius": items,
491                "total_affected": all_affected.len()
492            });
493            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
494        }
495        OutputFormat::Text => {
496            let mut result = format!(
497                "Diff Impact Analysis ({} changed files, {} blast radius)\n\n",
498                changed.len(),
499                all_affected.len()
500            );
501            result.push_str("Changed files:\n");
502            for f in changed.iter().take(30) {
503                result.push_str(&format!("  {f}\n"));
504            }
505
506            if !per_file.is_empty() {
507                result.push_str("\nBlast radius:\n");
508                for (file, affected) in per_file.iter().take(15) {
509                    result.push_str(&format!("  {file} -> {} affected\n", affected.len()));
510                    for a in affected.iter().take(10) {
511                        result.push_str(&format!("    {a}\n"));
512                    }
513                    if affected.len() > 10 {
514                        result.push_str(&format!("    ... +{} more\n", affected.len() - 10));
515                    }
516                }
517            }
518
519            let tokens = count_tokens(&result);
520            format!("{result}\n[ctx_impact diff: {tokens} tok]")
521        }
522    }
523}
524
525fn handle_chain(path: Option<&str>, root: &str, fmt: OutputFormat) -> String {
526    let Some(spec) = path else {
527        return "path is required for 'chain' action (format: from_file->to_file)".to_string();
528    };
529
530    let (from, to) = match spec.split_once("->") {
531        Some((f, t)) => (f.trim(), t.trim()),
532        None => {
533            return format!(
534                "Invalid chain spec '{spec}'. Use format: from_file->to_file\n\
535                 Example: src/server.rs->src/core/config.rs"
536            );
537        }
538    };
539
540    let graph = match open_graph_fresh(root) {
541        Ok(g) => g,
542        Err(e) => return e,
543    };
544
545    let rel_from = graph_target_key(from, root);
546    let rel_to = graph_target_key(to, root);
547
548    match graph.dependency_chain(&rel_from, &rel_to) {
549        Ok(Some(chain)) => format_chain(&chain, root, fmt),
550        Ok(None) => match fmt {
551            OutputFormat::Json => {
552                let v = json!({
553                    "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
554                    "tool": "ctx_impact",
555                    "action": "chain",
556                    "project": project_meta(root),
557                    "graph": graph_summary(root),
558                    "graph_meta": crate::core::property_graph::load_meta(root),
559                    "from": rel_from,
560                    "to": rel_to,
561                    "found": false
562                });
563                serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
564            }
565            OutputFormat::Text => {
566                let result = format!("No dependency path from {rel_from} to {rel_to}");
567                let tokens = count_tokens(&result);
568                format!("{result}\n[ctx_impact chain: {tokens} tok]")
569            }
570        },
571        Err(e) => format!("Chain analysis failed: {e}"),
572    }
573}
574
575fn format_chain(chain: &DependencyChain, root: &str, fmt: OutputFormat) -> String {
576    match fmt {
577        OutputFormat::Json => {
578            let v = json!({
579                "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
580                "tool": "ctx_impact",
581                "action": "chain",
582                "project": project_meta(root),
583                "graph": graph_summary(root),
584                "graph_meta": crate::core::property_graph::load_meta(root),
585                "found": true,
586                "depth": chain.depth,
587                "path": chain.path
588            });
589            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
590        }
591        OutputFormat::Text => {
592            let mut result = format!("Dependency chain (depth {}):\n", chain.depth);
593            for (i, step) in chain.path.iter().enumerate() {
594                if i > 0 {
595                    result.push_str("  -> ");
596                } else {
597                    result.push_str("  ");
598                }
599                result.push_str(step);
600                result.push('\n');
601            }
602            let tokens = count_tokens(&result);
603            format!("{result}[ctx_impact chain: {tokens} tok]")
604        }
605    }
606}
607
608fn graph_target_key(path: &str, root: &str) -> String {
609    let rel = crate::core::index_paths::graph_relative_key(path, root);
610    let rel_key = crate::core::index_paths::graph_match_key(&rel);
611    if rel_key.is_empty() {
612        crate::core::index_paths::graph_match_key(path)
613    } else {
614        rel_key
615    }
616}
617
618fn walk_supported_sources(root_path: &Path) -> (Vec<String>, Vec<(String, String, String)>) {
619    let walker = ignore::WalkBuilder::new(root_path)
620        .hidden(true)
621        .git_ignore(true)
622        .require_git(false)
623        .filter_entry(crate::core::walk_filter::keep_entry)
624        .build();
625
626    let mut file_paths: Vec<String> = Vec::new();
627    let mut file_contents: Vec<(String, String, String)> = Vec::new();
628
629    for entry in walker.flatten() {
630        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
631            continue;
632        }
633
634        let path = entry.path();
635        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
636
637        if !GRAPH_SOURCE_EXTS.contains(&ext) {
638            continue;
639        }
640
641        // Canonical `/` separators: graph node keys must be platform-stable
642        // so queries like `impact_analysis("Models/Engine.cs")` match the
643        // same node on Windows (output determinism, #498).
644        let rel_path = path
645            .strip_prefix(root_path)
646            .unwrap_or(path)
647            .to_string_lossy()
648            .replace('\\', "/");
649
650        file_paths.push(rel_path.clone());
651
652        if let Ok(content) = std::fs::read_to_string(path) {
653            file_contents.push((rel_path, content, ext.to_string()));
654        }
655    }
656
657    file_paths.sort();
658    file_paths.dedup();
659    file_contents.sort_by(|a, b| a.0.cmp(&b.0));
660    (file_paths, file_contents)
661}
662
663/// Per-file analysis borrowed from the walked contents: (path, content, ext, analysis).
664type AnalyzedFile<'a> = (
665    &'a str,
666    &'a str,
667    &'a str,
668    crate::core::deep_queries::DeepAnalysis,
669);
670
671/// Analyze every walked source file once (parallel) and build the global
672/// symbol-definition index and the extension-method index. Shared by full
673/// build and incremental update on both builder paths.
674fn analyze_all(
675    file_contents: &[(String, String, String)],
676) -> (Vec<AnalyzedFile<'_>>, DefIndex, ExtMethodIndex) {
677    use rayon::prelude::*;
678    let per_file: Vec<AnalyzedFile<'_>> = file_contents
679        .par_iter()
680        .map(|(p, c, e)| {
681            (
682                p.as_str(),
683                c.as_str(),
684                e.as_str(),
685                crate::core::deep_queries::analyze(c.as_str(), e.as_str()),
686            )
687        })
688        .collect();
689
690    // Single source of truth for the #398 indexes (shared with the graph_index
691    // mirror so both builders resolve identical type-usage edges).
692    let def_index =
693        crate::core::type_ref_edges::build_def_index(per_file.iter().map(|(p, _, _, a)| (*p, a)));
694    let ext_method_index = crate::core::type_ref_edges::build_ext_method_index(
695        per_file.iter().map(|(p, _, _, a)| (*p, a)),
696    );
697
698    (per_file, def_index, ext_method_index)
699}
700
701/// Insert `TypeRef` edges for every resolved type usage:
702/// - file -> defining file (drives `impact_analysis` blast radius; the
703///   `graph_index` mirror produces the identical file edge via
704///   [`crate::core::type_ref_edges::cross_file_type_edges`] so a reindex cannot
705///   drop it — GH #398),
706/// - file -> defined type symbol (clears the symbol from `dead_code`, whose
707///   query already exempts `type_ref` targets; symbol-level edges live only on
708///   this builder path).
709fn insert_type_ref_edges(
710    graph: &CodeGraph,
711    file_node_id: i64,
712    rel_path: &str,
713    type_uses: &[crate::core::deep_queries::TypeUse],
714    def_index: &DefIndex,
715    scope: &crate::core::type_ref_edges::ResolveScope,
716) -> usize {
717    let mut added = 0usize;
718    for (target_file, type_name, line_start, line_end) in
719        crate::core::type_ref_edges::type_ref_targets(
720            def_index,
721            type_uses,
722            rel_path,
723            &scope.visible_ns,
724            scope.allow_global_fallback,
725        )
726    {
727        let Ok(target_id) = graph.upsert_node(&Node::file(&target_file)) else {
728            continue;
729        };
730        let _ = graph.upsert_edge(&Edge::new(file_node_id, target_id, EdgeKind::TypeRef));
731        added += 1;
732
733        let sym_node = Node::symbol(
734            &type_name,
735            &target_file,
736            crate::core::property_graph::NodeKind::Symbol,
737        )
738        .with_lines(line_start, line_end);
739        if let Ok(sym_id) = graph.upsert_node(&sym_node) {
740            let _ = graph.upsert_edge(&Edge::new(file_node_id, sym_id, EdgeKind::TypeRef));
741            added += 1;
742        }
743    }
744    added
745}
746
747/// Insert `TypeRef` edges for resolved C# extension-method calls: a
748/// `value.Foo()` call links the consuming file to the file that defines the
749/// `this`-parameter method `Foo`. Mirrors `insert_type_ref_edges` (file +
750/// symbol edge). Resolution is by method name alone, so the same self-filter
751/// and a failsafe cap keep it bounded; the index only ever holds genuine
752/// extension methods, which keeps the name space small and distinct.
753fn insert_ext_method_edges(
754    graph: &CodeGraph,
755    file_node_id: i64,
756    rel_path: &str,
757    calls: &[crate::core::deep_queries::CallSite],
758    ext_method_index: &ExtMethodIndex,
759) -> usize {
760    let mut added = 0usize;
761    for (target_file, method_name, line_start, line_end) in
762        crate::core::type_ref_edges::ext_method_targets(ext_method_index, calls, rel_path)
763    {
764        let Ok(target_id) = graph.upsert_node(&Node::file(&target_file)) else {
765            continue;
766        };
767        let _ = graph.upsert_edge(&Edge::new(file_node_id, target_id, EdgeKind::TypeRef));
768        added += 1;
769
770        let sym_node = Node::symbol(
771            &method_name,
772            &target_file,
773            crate::core::property_graph::NodeKind::Symbol,
774        )
775        .with_lines(line_start, line_end);
776        if let Ok(sym_id) = graph.upsert_node(&sym_node) {
777            let _ = graph.upsert_edge(&Edge::new(file_node_id, sym_id, EdgeKind::TypeRef));
778            added += 1;
779        }
780    }
781    added
782}
783
784fn normalize_git_path(line: &str) -> String {
785    line.trim().replace('\\', "/")
786}
787
788fn git_diff_name_only_lines(project_root: &Path, args: &[&str]) -> Option<Vec<String>> {
789    let out = std::process::Command::new("git")
790        .args(args)
791        .current_dir(project_root)
792        .stdout(Stdio::piped())
793        .stderr(Stdio::null())
794        .output()
795        .ok()?;
796    if !out.status.success() {
797        return None;
798    }
799    let s = String::from_utf8(out.stdout).ok()?;
800    Some(
801        s.lines()
802            .map(normalize_git_path)
803            .filter(|l| !l.is_empty())
804            .collect(),
805    )
806}
807
808fn collect_git_changed_paths(project_root: &Path, last_git_head: &str) -> Option<BTreeSet<String>> {
809    let range = format!("{last_git_head}..HEAD");
810    let mut set: BTreeSet<String> = BTreeSet::new();
811    for line in git_diff_name_only_lines(project_root, &["diff", "--name-only", &range])? {
812        set.insert(line);
813    }
814    for line in git_diff_name_only_lines(project_root, &["diff", "--name-only"])? {
815        set.insert(line);
816    }
817    for line in git_diff_name_only_lines(project_root, &["diff", "--name-only", "--cached"])? {
818        set.insert(line);
819    }
820    Some(set)
821}
822
823#[cfg(feature = "embeddings")]
824fn enclosing_symbol_name_for_line(
825    types: &[crate::core::deep_queries::TypeDef],
826    line: usize,
827) -> String {
828    let mut best: Option<(&crate::core::deep_queries::TypeDef, usize)> = None;
829    for t in types {
830        if line >= t.line && line <= t.end_line {
831            let span = t.end_line.saturating_sub(t.line);
832            match best {
833                None => best = Some((t, span)),
834                Some((_, prev_span)) => {
835                    if span < prev_span {
836                        best = Some((t, span));
837                    }
838                }
839            }
840        }
841    }
842    best.map_or_else(|| "<module>".to_string(), |(t, _)| t.name.clone())
843}
844
845#[cfg(feature = "embeddings")]
846fn resolve_call_callee_site(
847    def_index: &DefIndex,
848    callee: &str,
849    caller_file: &str,
850) -> Option<(String, usize, usize)> {
851    let sites = def_index.get(callee)?;
852    for (f, _ns, ls, le) in sites {
853        if f == caller_file {
854            return Some((f.clone(), *ls, *le));
855        }
856    }
857    let mut sorted: Vec<(String, usize, usize)> = sites
858        .iter()
859        .map(|(f, _ns, ls, le)| (f.clone(), *ls, *le))
860        .collect();
861    sorted.sort_by(|a, b| a.0.cmp(&b.0));
862    sorted.into_iter().next()
863}
864
865#[cfg(feature = "embeddings")]
866fn index_graph_file_embeddings(
867    graph: &CodeGraph,
868    rel_path: &str,
869    ext: &str,
870    analysis: &crate::core::deep_queries::DeepAnalysis,
871    resolver_ctx: &crate::core::import_resolver::ResolverContext,
872    def_index: &DefIndex,
873    ext_method_index: &ExtMethodIndex,
874) -> (usize, usize) {
875    let mut total_nodes = 0usize;
876    let mut total_edges = 0usize;
877
878    let Ok(file_node_id) = graph.upsert_node(&Node::file(rel_path)) else {
879        return (0, 0);
880    };
881    total_nodes += 1;
882
883    for type_def in &analysis.types {
884        let sym_node = Node::symbol(
885            &type_def.name,
886            rel_path,
887            crate::core::property_graph::NodeKind::Symbol,
888        )
889        .with_lines(type_def.line, type_def.end_line);
890        if let Ok(sym_id) = graph.upsert_node(&sym_node) {
891            total_nodes += 1;
892            let _ = graph.upsert_edge(&Edge::new(file_node_id, sym_id, EdgeKind::Defines));
893            total_edges += 1;
894            if type_def.is_exported {
895                let _ = graph.upsert_edge(&Edge::new(sym_id, file_node_id, EdgeKind::Exports));
896                total_edges += 1;
897            }
898        }
899    }
900
901    let resolved = crate::core::import_resolver::resolve_imports(
902        &analysis.imports,
903        rel_path,
904        ext,
905        resolver_ctx,
906    );
907
908    let mut targets: Vec<String> = resolved
909        .into_iter()
910        .filter(|imp| !imp.is_external)
911        .filter_map(|imp| imp.resolved_path)
912        .collect();
913    targets.sort();
914    targets.dedup();
915
916    for target_path in targets {
917        let Ok(target_id) = graph.upsert_node(&Node::file(&target_path)) else {
918            continue;
919        };
920        let _ = graph.upsert_edge(&Edge::new(file_node_id, target_id, EdgeKind::Imports));
921        total_edges += 1;
922    }
923
924    for call in &analysis.calls {
925        let caller_name = enclosing_symbol_name_for_line(&analysis.types, call.line);
926        let mut caller_node = Node::symbol(
927            &caller_name,
928            rel_path,
929            crate::core::property_graph::NodeKind::Symbol,
930        );
931        if let Some(t) = analysis.types.iter().find(|t| t.name == caller_name) {
932            caller_node = caller_node.with_lines(t.line, t.end_line);
933        }
934        let Ok(caller_id) = graph.upsert_node(&caller_node) else {
935            continue;
936        };
937        total_nodes += 1;
938
939        let Some((callee_file, c_line, c_end)) =
940            resolve_call_callee_site(def_index, &call.callee, rel_path)
941        else {
942            continue;
943        };
944
945        let callee_node = Node::symbol(
946            &call.callee,
947            &callee_file,
948            crate::core::property_graph::NodeKind::Symbol,
949        )
950        .with_lines(c_line, c_end);
951        let Ok(callee_id) = graph.upsert_node(&callee_node) else {
952            continue;
953        };
954        total_nodes += 1;
955        let _ = graph.upsert_edge(&Edge::new(caller_id, callee_id, EdgeKind::Calls));
956        total_edges += 1;
957
958        if callee_file != rel_path {
959            let Ok(callee_file_id) = graph.upsert_node(&Node::file(&callee_file)) else {
960                continue;
961            };
962            let _ = graph.upsert_edge(&Edge::new(file_node_id, callee_file_id, EdgeKind::Calls));
963            total_edges += 1;
964        }
965    }
966
967    // Type-usage edges close the same-namespace gap (C#/Java/Go/Kotlin,
968    // GH #398): a file consuming a project type without importing it still
969    // depends on the defining file. Scope is per-language (namespace-aware for
970    // C#/Kotlin, directory-strict for Go).
971    let scope = crate::core::type_ref_edges::resolve_scope(rel_path, ext, analysis);
972    total_edges += insert_type_ref_edges(
973        graph,
974        file_node_id,
975        rel_path,
976        &analysis.type_uses,
977        def_index,
978        &scope,
979    );
980    // Extension-method calls (`value.Foo()`) depend on the defining file too.
981    total_edges += insert_ext_method_edges(
982        graph,
983        file_node_id,
984        rel_path,
985        &analysis.calls,
986        ext_method_index,
987    );
988
989    (total_nodes, total_edges)
990}
991
992#[cfg(not(feature = "embeddings"))]
993fn index_graph_file_minimal(
994    graph: &CodeGraph,
995    rel_path: &str,
996    content: &str,
997    ext: &str,
998    analysis: &crate::core::deep_queries::DeepAnalysis,
999    resolver_ctx: &crate::core::import_resolver::ResolverContext,
1000    def_index: &DefIndex,
1001    ext_method_index: &ExtMethodIndex,
1002) -> (usize, usize) {
1003    let Ok(file_node_id) = graph.upsert_node(&Node::file(rel_path)) else {
1004        return (0, 0);
1005    };
1006    let mut total_nodes = 1usize;
1007    let mut total_edges = 0usize;
1008
1009    let resolved = crate::core::import_resolver::resolve_imports(
1010        &analysis.imports,
1011        rel_path,
1012        ext,
1013        resolver_ctx,
1014    );
1015
1016    let mut targets: Vec<String> = resolved
1017        .into_iter()
1018        .filter(|imp| !imp.is_external)
1019        .filter_map(|imp| imp.resolved_path)
1020        .filter(|p| p != rel_path)
1021        .collect();
1022    targets.sort();
1023    targets.dedup();
1024
1025    for target_path in targets {
1026        let Ok(target_id) = graph.upsert_node(&Node::file(&target_path)) else {
1027            continue;
1028        };
1029        total_nodes += 1;
1030        let _ = graph.upsert_edge(&Edge::new(file_node_id, target_id, EdgeKind::Imports));
1031        total_edges += 1;
1032    }
1033
1034    for type_def in &analysis.types {
1035        if type_def.is_exported {
1036            let sym_node = Node::symbol(
1037                &type_def.name,
1038                rel_path,
1039                crate::core::property_graph::NodeKind::Symbol,
1040            )
1041            .with_lines(type_def.line, type_def.end_line);
1042            if let Ok(sym_id) = graph.upsert_node(&sym_node) {
1043                total_nodes += 1;
1044                let _ = graph.upsert_edge(&Edge::new(file_node_id, sym_id, EdgeKind::Defines));
1045                let _ = graph.upsert_edge(&Edge::new(sym_id, file_node_id, EdgeKind::Exports));
1046                total_edges += 2;
1047            }
1048        }
1049    }
1050
1051    // Same-namespace type consumption (C#/Java/Go/Kotlin, GH #398) — see the
1052    // embeddings-path counterpart in `index_graph_file_embeddings`.
1053    let scope = crate::core::type_ref_edges::resolve_scope(rel_path, ext, analysis);
1054    total_edges += insert_type_ref_edges(
1055        graph,
1056        file_node_id,
1057        rel_path,
1058        &analysis.type_uses,
1059        def_index,
1060        &scope,
1061    );
1062    total_edges += insert_ext_method_edges(
1063        graph,
1064        file_node_id,
1065        rel_path,
1066        &analysis.calls,
1067        ext_method_index,
1068    );
1069
1070    let exports: Vec<String> = analysis
1071        .types
1072        .iter()
1073        .filter(|t| t.is_exported)
1074        .map(|t| t.name.clone())
1075        .collect();
1076    let line_count = content.lines().count();
1077    let token_count = crate::core::tokens::count_tokens(content);
1078    let hash = {
1079        use md5::{Digest, Md5};
1080        let mut h = Md5::new();
1081        h.update(content.as_bytes());
1082        crate::core::agent_identity::hex_encode(&h.finalize())
1083    };
1084    let _ = graph.upsert_file_catalog(&crate::core::property_graph::FileCatalogEntry {
1085        path: rel_path.to_string(),
1086        hash,
1087        language: ext.to_string(),
1088        line_count,
1089        token_count,
1090        exports,
1091        summary: String::new(),
1092    });
1093
1094    (total_nodes, total_edges)
1095}
1096
1097fn handle_build(root: &str, fmt: OutputFormat) -> String {
1098    let t0 = std::time::Instant::now();
1099    let root_path = Path::new(root);
1100
1101    let graph = match open_graph(root) {
1102        Ok(g) => g,
1103        Err(e) => return e,
1104    };
1105
1106    let incremental_hint: Option<&'static str> = {
1107        let nodes_ok = graph.node_count().unwrap_or(0) > 0;
1108        let has_head = crate::core::property_graph::load_meta(root)
1109            .and_then(|m| m.git_head)
1110            .is_some_and(|s| !s.is_empty());
1111        if nodes_ok && has_head {
1112            Some(
1113                "Hint: Graph already indexed — for faster refresh, use ctx_impact action='update' \
1114                 to apply incremental git-based updates instead of a full rebuild.",
1115            )
1116        } else {
1117            None
1118        }
1119    };
1120
1121    if let Err(e) = graph.clear() {
1122        return format!("Failed to clear graph: {e}");
1123    }
1124
1125    let (file_paths, file_contents) = walk_supported_sources(root_path);
1126
1127    let cs_contents: std::collections::HashMap<String, String> = file_contents
1128        .iter()
1129        .filter(|(_, _, e)| e.eq_ignore_ascii_case("cs"))
1130        .map(|(p, c, _)| (p.clone(), c.clone()))
1131        .collect();
1132    let resolver_ctx = crate::core::import_resolver::ResolverContext::new(
1133        root_path,
1134        file_paths.clone(),
1135        &cs_contents,
1136    );
1137
1138    let mut total_nodes = 0usize;
1139    let mut total_edges = 0usize;
1140
1141    let (per_file, def_index, ext_method_index) = analyze_all(&file_contents);
1142
1143    #[cfg(feature = "embeddings")]
1144    for (rel_path, _content, ext, analysis) in &per_file {
1145        let (n, e) = index_graph_file_embeddings(
1146            &graph,
1147            rel_path,
1148            ext,
1149            analysis,
1150            &resolver_ctx,
1151            &def_index,
1152            &ext_method_index,
1153        );
1154        total_nodes += n;
1155        total_edges += e;
1156    }
1157
1158    #[cfg(not(feature = "embeddings"))]
1159    for (rel_path, content, ext, analysis) in &per_file {
1160        let (n, e) = index_graph_file_minimal(
1161            &graph,
1162            rel_path,
1163            content,
1164            ext,
1165            analysis,
1166            &resolver_ctx,
1167            &def_index,
1168            &ext_method_index,
1169        );
1170        total_nodes += n;
1171        total_edges += e;
1172    }
1173
1174    let build_time_ms = t0.elapsed().as_millis() as u64;
1175
1176    let db_display = graph.db_path().display();
1177    let mut result = format!(
1178        "Graph built: {total_nodes} nodes, {total_edges} edges from {} files\n\
1179         Stored at: {db_display}\n\
1180         Build time: {build_time_ms}ms",
1181        file_contents.len(),
1182    );
1183    if let Some(h) = incremental_hint {
1184        result.push('\n');
1185        result.push_str(h);
1186    }
1187
1188    let _ = crate::core::property_graph::write_meta(
1189        root,
1190        &crate::core::property_graph::PropertyGraphMetaV1 {
1191            schema_version: 1,
1192            engine_version: crate::core::property_graph::GRAPH_ENGINE_VERSION,
1193            built_with: env!("CARGO_PKG_VERSION").to_string(),
1194            project_root: crate::core::graph_index::normalize_project_root(root),
1195            built_at: chrono::Utc::now().to_rfc3339(),
1196            git_head: git_out(root_path, &["rev-parse", "--short", "HEAD"]),
1197            git_dirty: Some(git_dirty(root_path)),
1198            nodes: graph.node_count().ok(),
1199            edges: graph.edge_count().ok(),
1200            files_indexed: Some(file_contents.len()),
1201            build_time_ms: Some(build_time_ms),
1202        },
1203    );
1204
1205    let tokens = count_tokens(&result);
1206    match fmt {
1207        OutputFormat::Json => {
1208            let mut v = serde_json::json!({
1209                "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
1210                "tool": "ctx_impact",
1211                "action": "build",
1212                "project": project_meta(root),
1213                "graph": graph_summary(root),
1214                "graph_meta": crate::core::property_graph::load_meta(root),
1215                "indexed_files": file_contents.len(),
1216                "nodes": total_nodes,
1217                "edges": total_edges,
1218                "build_time_ms": build_time_ms,
1219                "db_path": graph.db_path().display().to_string()
1220            });
1221            if let Some(h) = incremental_hint {
1222                v.as_object_mut()
1223                    .map(|m| m.insert("incremental_hint".to_string(), json!(h)));
1224            }
1225            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
1226        }
1227        OutputFormat::Text => format!("{result}\n[ctx_impact build: {tokens} tok]"),
1228    }
1229}
1230
1231fn handle_update(root: &str, fmt: OutputFormat) -> String {
1232    let t0 = std::time::Instant::now();
1233    let root_path = Path::new(root);
1234
1235    let graph = match open_graph(root) {
1236        Ok(g) => g,
1237        Err(e) => return e,
1238    };
1239
1240    if graph.node_count().unwrap_or(0) == 0 {
1241        return handle_build(root, fmt);
1242    }
1243
1244    let Some(meta) = crate::core::property_graph::load_meta(root) else {
1245        return handle_build(root, fmt);
1246    };
1247
1248    let Some(last_git_head) = meta.git_head.filter(|s| !s.is_empty()) else {
1249        return handle_build(root, fmt);
1250    };
1251
1252    let Some(changed) = collect_git_changed_paths(root_path, &last_git_head) else {
1253        return handle_build(root, fmt);
1254    };
1255
1256    let changed_count = changed.len();
1257    let (file_paths, file_contents) = walk_supported_sources(root_path);
1258    let cs_contents: std::collections::HashMap<String, String> = file_contents
1259        .iter()
1260        .filter(|(_, _, e)| e.eq_ignore_ascii_case("cs"))
1261        .map(|(p, c, _)| (p.clone(), c.clone()))
1262        .collect();
1263    let resolver_ctx = crate::core::import_resolver::ResolverContext::new(
1264        root_path,
1265        file_paths.clone(),
1266        &cs_contents,
1267    );
1268
1269    let (per_file, def_index, ext_method_index) = analyze_all(&file_contents);
1270
1271    let mut total_nodes = 0usize;
1272    let mut total_edges = 0usize;
1273
1274    for rel_path in &changed {
1275        let p = Path::new(rel_path);
1276        let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
1277        let supported = GRAPH_SOURCE_EXTS.contains(&ext);
1278        let abs = root_path.join(rel_path);
1279
1280        if !abs.exists() {
1281            if supported {
1282                let _ = graph.remove_file_nodes(rel_path);
1283            }
1284            continue;
1285        }
1286
1287        if !supported {
1288            continue;
1289        }
1290
1291        if let Err(e) = graph.remove_file_nodes(rel_path) {
1292            return format!("Failed to remove old nodes for {rel_path}: {e}");
1293        }
1294
1295        let Some((_, _content, ext_owned, analysis)) =
1296            per_file.iter().find(|(p, _, _, _)| *p == rel_path)
1297        else {
1298            continue;
1299        };
1300
1301        #[cfg(feature = "embeddings")]
1302        {
1303            let (n, e) = index_graph_file_embeddings(
1304                &graph,
1305                rel_path,
1306                ext_owned,
1307                analysis,
1308                &resolver_ctx,
1309                &def_index,
1310                &ext_method_index,
1311            );
1312            total_nodes += n;
1313            total_edges += e;
1314        }
1315
1316        #[cfg(not(feature = "embeddings"))]
1317        {
1318            let (n, e) = index_graph_file_minimal(
1319                &graph,
1320                rel_path,
1321                _content,
1322                ext_owned,
1323                analysis,
1324                &resolver_ctx,
1325                &def_index,
1326                &ext_method_index,
1327            );
1328            total_nodes += n;
1329            total_edges += e;
1330        }
1331    }
1332
1333    let elapsed_ms = t0.elapsed().as_millis() as u64;
1334
1335    let _ = crate::core::property_graph::write_meta(
1336        root,
1337        &crate::core::property_graph::PropertyGraphMetaV1 {
1338            schema_version: 1,
1339            engine_version: crate::core::property_graph::GRAPH_ENGINE_VERSION,
1340            built_with: env!("CARGO_PKG_VERSION").to_string(),
1341            project_root: crate::core::graph_index::normalize_project_root(root),
1342            built_at: chrono::Utc::now().to_rfc3339(),
1343            git_head: git_out(root_path, &["rev-parse", "--short", "HEAD"]),
1344            git_dirty: Some(git_dirty(root_path)),
1345            nodes: graph.node_count().ok(),
1346            edges: graph.edge_count().ok(),
1347            files_indexed: Some(file_contents.len()),
1348            build_time_ms: Some(elapsed_ms),
1349        },
1350    );
1351
1352    let summary = format!(
1353        "Incremental update: {changed_count} files changed, {total_nodes} nodes updated, {total_edges} edges added ({elapsed_ms}ms)"
1354    );
1355
1356    let tokens = count_tokens(&summary);
1357    match fmt {
1358        OutputFormat::Json => {
1359            let v = json!({
1360                "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
1361                "tool": "ctx_impact",
1362                "action": "update",
1363                "project": project_meta(root),
1364                "graph": graph_summary(root),
1365                "graph_meta": crate::core::property_graph::load_meta(root),
1366                "git_range_from": last_git_head,
1367                "files_changed_reported": changed_count,
1368                "nodes_added": total_nodes,
1369                "edges_added": total_edges,
1370                "update_time_ms": elapsed_ms,
1371                "db_path": graph.db_path().display().to_string()
1372            });
1373            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
1374        }
1375        OutputFormat::Text => format!("{summary}\n[ctx_impact update: {tokens} tok]"),
1376    }
1377}
1378
1379fn handle_status(root: &str, fmt: OutputFormat) -> String {
1380    let graph = match open_graph(root) {
1381        Ok(g) => g,
1382        Err(e) => return e,
1383    };
1384
1385    let nodes = graph.node_count().unwrap_or(0);
1386    let edges = graph.edge_count().unwrap_or(0);
1387
1388    if nodes == 0 {
1389        return match fmt {
1390            OutputFormat::Json => {
1391                let v = json!({
1392                    "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
1393                    "tool": "ctx_impact",
1394                    "action": "status",
1395                    "project": project_meta(root),
1396                    "graph": graph_summary(root),
1397                    "freshness": "empty",
1398                    "hint": "Run ctx_impact action='build' to index."
1399                });
1400                serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
1401            }
1402            OutputFormat::Text => {
1403                "Graph is empty. Run ctx_impact action='build' to index.".to_string()
1404            }
1405        };
1406    }
1407
1408    let root_path = Path::new(root);
1409    let meta = crate::core::property_graph::load_meta(root);
1410    let current_head = git_out(root_path, &["rev-parse", "--short", "HEAD"]);
1411    let current_dirty = git_dirty(root_path);
1412    let stale = meta.as_ref().is_some_and(|m| {
1413        let head_mismatch = match (m.git_head.as_ref(), current_head.as_ref()) {
1414            (Some(a), Some(b)) => a != b,
1415            _ => false,
1416        };
1417        let dirty_mismatch = match (m.git_dirty, Some(current_dirty)) {
1418            (Some(a), Some(b)) => a != b,
1419            _ => false,
1420        };
1421        head_mismatch || dirty_mismatch
1422    });
1423    let freshness = if stale { "stale" } else { "fresh" };
1424
1425    match fmt {
1426        OutputFormat::Json => {
1427            let v = json!({
1428                "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
1429                "tool": "ctx_impact",
1430                "action": "status",
1431                "project": project_meta(root),
1432                "graph": graph_summary(root),
1433                "freshness": freshness,
1434                "meta": meta
1435            });
1436            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
1437        }
1438        OutputFormat::Text => {
1439            let db_display = graph.db_path().display();
1440            let mut out =
1441                format!("Property Graph: {nodes} nodes, {edges} edges\nStored: {db_display}");
1442            if stale {
1443                out.push_str("\nWARNING: graph looks stale (git HEAD / dirty mismatch). Run ctx_impact action='build' to refresh.");
1444            }
1445            out
1446        }
1447    }
1448}
1449
1450#[cfg(test)]
1451#[path = "ctx_impact_tests.rs"]
1452mod tests;