Skip to main content

lean_ctx/tools/
ctx_quality.rs

1//! `ctx_quality` — code-health surface for agents.
2//!
3//! The agent-facing twin of `lean-ctx health`: it reads the same engine
4//! (`crate::core::code_health`) and surfaces the navigability score,
5//! cognitive-complexity hotspots, and the estimated token "quality tax".
6//!
7//! Actions:
8//!   - `report` (default): project-wide score + hotspots + quality tax.
9//!   - `file`: per-function cognitive complexity + naming for one file.
10//!   - `delta`: cognitive-complexity change of a file vs its git `HEAD`.
11//!
12//! `format=json` emits machine output. Rendering is deterministic for a given
13//! repo state (#498); only the priced tax depends on the configured model.
14
15use std::path::Path;
16use std::time::Duration;
17
18use serde_json::json;
19
20use crate::core::code_health::{analyze_file, cognitive_delta, report, scan_project};
21
22/// Hotspots to surface in a project report.
23const TOP_HOTSPOTS: usize = 15;
24
25/// Entry point used by the registered MCP wrapper. `path` is the resolved
26/// (absolute) file path for `file`/`delta`; `root` is the project/repo root.
27pub fn handle(action: &str, path: Option<&str>, root: &str, format: Option<&str>) -> String {
28    let json = matches!(format, Some(f) if f.eq_ignore_ascii_case("json"));
29    let threshold = crate::core::config::Config::load()
30        .code_health
31        .cognitive_threshold;
32
33    match action {
34        "report" | "summary" => report_action(root, threshold, json),
35        "file" => file_action(path, threshold, json),
36        "delta" => delta_action(path, root, threshold, json),
37        other => format!("ctx_quality: unknown action '{other}'. Use: report | file | delta."),
38    }
39}
40
41fn report_action(root: &str, threshold: u32, json: bool) -> String {
42    let model = crate::core::gain::model_pricing::resolve_model_for_client("mcp");
43    let health = scan_project(Path::new(root), threshold, Some(&model), TOP_HOTSPOTS);
44    if json {
45        let value = report::json(&health, root);
46        serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string())
47    } else {
48        report::text(&health, root, threshold, &model)
49    }
50}
51
52fn file_action(path: Option<&str>, threshold: u32, json: bool) -> String {
53    let Some(p) = path else {
54        return "ctx_quality file: 'path' is required.".to_string();
55    };
56    let content = match std::fs::read_to_string(p) {
57        Ok(c) => c,
58        Err(e) => return format!("ctx_quality file: cannot read {p}: {e}"),
59    };
60    let ext = Path::new(p)
61        .extension()
62        .and_then(|e| e.to_str())
63        .unwrap_or("");
64    let Some(health) = analyze_file(&content, ext) else {
65        return format!("ctx_quality file: unsupported file type '{ext}' ({p}).");
66    };
67
68    let mut fns = health.functions.clone();
69    fns.sort_by(|a, b| {
70        b.cognitive
71            .cmp(&a.cognitive)
72            .then_with(|| a.line.cmp(&b.line))
73    });
74
75    if json {
76        let functions: Vec<_> = fns
77            .iter()
78            .map(|f| {
79                json!({
80                    "name": f.name,
81                    "line": f.line,
82                    "cognitive": f.cognitive,
83                    "over_threshold": f.cognitive > threshold,
84                })
85            })
86            .collect();
87        let naming: Vec<_> = health
88            .naming
89            .iter()
90            .map(|n| json!({ "name": n.name, "line": n.line, "message": n.message }))
91            .collect();
92        let value = json!({
93            "file": p,
94            "threshold": threshold,
95            "worst_cognitive": health.worst_cognitive(),
96            "functions": functions,
97            "naming": naming,
98        });
99        return serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string());
100    }
101
102    let mut out = format!(
103        "Code Health — {p}\n  worst cognitive: {}   threshold={threshold}\n",
104        health.worst_cognitive()
105    );
106    if fns.is_empty() {
107        out.push_str("  no functions analyzed.");
108        return out;
109    }
110    out.push_str("  functions (cognitive complexity):");
111    for f in &fns {
112        let flag = if f.cognitive > threshold {
113            "  (over)"
114        } else {
115            ""
116        };
117        out.push_str(&format!(
118            "\n    L{}  {}  cc={}{flag}",
119            f.line, f.name, f.cognitive
120        ));
121    }
122    if !health.naming.is_empty() {
123        out.push_str("\n  naming findings:");
124        for n in &health.naming {
125            out.push_str(&format!("\n    L{}  {}  — {}", n.line, n.name, n.message));
126        }
127    }
128    out
129}
130
131fn delta_action(path: Option<&str>, root: &str, threshold: u32, json: bool) -> String {
132    let Some(p) = path else {
133        return "ctx_quality delta: 'path' is required.".to_string();
134    };
135    let new = match std::fs::read_to_string(p) {
136        Ok(c) => c,
137        Err(e) => return format!("ctx_quality delta: cannot read {p}: {e}"),
138    };
139    let ext = Path::new(p)
140        .extension()
141        .and_then(|e| e.to_str())
142        .unwrap_or("");
143    let Some(old) = git_head_content(root, p) else {
144        return format!("ctx_quality delta: no git HEAD baseline for {p}.");
145    };
146
147    let deltas = cognitive_delta(&old, &new, ext);
148
149    if json {
150        let changes: Vec<_> = deltas
151            .iter()
152            .map(|d| {
153                json!({
154                    "name": d.name,
155                    "before": d.before,
156                    "after": d.after,
157                    "increase": d.increase(),
158                    "crosses_threshold": d.crosses_threshold(threshold),
159                })
160            })
161            .collect();
162        let value = json!({ "file": p, "threshold": threshold, "changes": changes });
163        return serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string());
164    }
165
166    if deltas.is_empty() {
167        return format!("Code Health delta — {p}\n  no cognitive-complexity changes vs HEAD.");
168    }
169    let mut out = format!("Code Health delta — {p} (vs HEAD)");
170    for d in &deltas {
171        let cross = if d.crosses_threshold(threshold) {
172            "  (crosses threshold)"
173        } else {
174            ""
175        };
176        out.push_str(&format!(
177            "\n    {}: cognitive {}->{} ({:+}){cross}",
178            d.name,
179            d.before,
180            d.after,
181            d.increase()
182        ));
183    }
184    out
185}
186
187/// File contents at git `HEAD`, or `None` when there is no committed baseline.
188fn git_head_content(root: &str, abs_path: &str) -> Option<String> {
189    let rel = Path::new(abs_path)
190        .strip_prefix(root)
191        .ok()?
192        .to_string_lossy()
193        .replace('\\', "/");
194    crate::core::git_cache::git_cached(
195        &["show", &format!("HEAD:{rel}")],
196        root,
197        Duration::from_secs(2),
198    )
199}