lean_ctx/tools/
ctx_quality.rs1use 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
22const TOP_HOTSPOTS: usize = 15;
24
25pub 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
187fn 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}