lean_ctx/tools/
ctx_review.rs1use crate::core::tokens::count_tokens;
2use std::path::Path;
3
4pub fn handle(action: &str, path: Option<&str>, root: &str, depth: Option<usize>) -> String {
6 match action {
7 "review" => handle_review(path, root, depth.unwrap_or(3)),
8 "diff-review" => handle_diff_review(path, root),
9 "checklist" => handle_checklist(path, root, depth.unwrap_or(3)),
10 _ => "Unknown action. Use: review, diff-review, checklist".to_string(),
11 }
12}
13
14fn handle_review(path: Option<&str>, root: &str, depth: usize) -> String {
15 let Some(target) = path else {
16 return "path is required for 'review' action".to_string();
17 };
18
19 let mut sections = Vec::new();
20
21 sections.push(format!("## Review: {target}\n"));
22
23 let impact = super::ctx_impact::handle("analyze", Some(target), root, Some(depth), None);
24 if !impact.contains("No") && !impact.contains("empty") {
25 sections.push("### Impact Analysis".to_string());
26 sections.push(impact);
27 }
28
29 let file_stem = Path::new(target)
30 .file_stem()
31 .and_then(|s| s.to_str())
32 .unwrap_or("");
33
34 if !file_stem.is_empty() {
35 let callers = super::ctx_callgraph::handle(file_stem, None, root, "callers");
36 if !callers.contains("No callers") {
37 sections.push("### Callers".to_string());
38 sections.push(callers);
39 }
40 }
41
42 let tests = find_related_tests(target, root);
43 if tests.is_empty() {
44 sections.push("### Related Tests".to_string());
45 sections.push(" (no test files found)".to_string());
46 } else {
47 sections.push("### Related Tests".to_string());
48 for t in &tests {
49 sections.push(format!(" - {t}"));
50 }
51 }
52
53 let smells = super::ctx_smells::handle("file", None, Some(target), root, None);
54 if !smells.contains("No smells") {
55 sections.push("### Code Smells".to_string());
56 sections.push(smells);
57 }
58
59 let output = sections.join("\n");
60 let tok = count_tokens(&output);
61 format!("{output}\n\n[{tok} tok]")
62}
63
64fn handle_diff_review(diff_input: Option<&str>, root: &str) -> String {
65 let Some(diff_text) = diff_input else {
66 return "path (git diff output) is required for 'diff-review'".to_string();
67 };
68
69 let changed_files = extract_changed_files(diff_text);
70 if changed_files.is_empty() {
71 return "No changed files detected in diff input.".to_string();
72 }
73
74 let mut sections = Vec::new();
75 sections.push(format!(
76 "## Diff Review: {} file(s) changed\n",
77 changed_files.len()
78 ));
79
80 for file in &changed_files {
81 sections.push(format!("---\n### {file}"));
82 let review = handle_review(Some(file), root, 2);
83 sections.push(review);
84 }
85
86 let output = sections.join("\n");
87 let tok = count_tokens(&output);
88 format!("{output}\n\n[{tok} tok]")
89}
90
91fn handle_checklist(path: Option<&str>, root: &str, depth: usize) -> String {
92 let Some(target) = path else {
93 return "path is required for 'checklist' action".to_string();
94 };
95
96 let mut questions = Vec::new();
97
98 questions.push(format!(
99 "- [ ] Are all public API changes in `{target}` backward-compatible?"
100 ));
101
102 let impact = super::ctx_impact::handle("analyze", Some(target), root, Some(depth), None);
103 let affected_count = impact.lines().filter(|l| l.contains("→")).count();
104
105 if affected_count > 0 {
106 questions.push(format!(
107 "- [ ] {affected_count} downstream file(s) affected — have they been reviewed?"
108 ));
109 questions.push(
110 "- [ ] Do downstream consumers handle the changed interface correctly?".to_string(),
111 );
112 }
113
114 let tests = find_related_tests(target, root);
115 if tests.is_empty() {
116 questions.push(format!(
117 "- [ ] No tests found for `{target}` — should tests be added?"
118 ));
119 } else {
120 questions.push(format!(
121 "- [ ] {} test file(s) found — do they still pass?",
122 tests.len()
123 ));
124 for t in &tests {
125 questions.push(format!(" - `{t}`"));
126 }
127 }
128
129 questions.push("- [ ] Are error paths handled gracefully?".to_string());
130 questions.push("- [ ] Is logging/telemetry appropriate (no sensitive data)?".to_string());
131
132 let output = format!("## Review Checklist: {target}\n\n{}", questions.join("\n"));
133 let tok = count_tokens(&output);
134 format!("{output}\n\n[{tok} tok]")
135}
136
137fn extract_changed_files(diff_text: &str) -> Vec<String> {
138 let mut files = Vec::new();
139 for line in diff_text.lines() {
140 if let Some(rest) = line.strip_prefix("+++ b/") {
141 files.push(rest.to_string());
142 } else if let Some(rest) = line.strip_prefix("diff --git a/") {
143 if let Some(b_part) = rest.split(" b/").nth(1) {
144 if !files.contains(&b_part.to_string()) {
145 files.push(b_part.to_string());
146 }
147 }
148 }
149 }
150 files.dedup();
151 files
152}
153
154pub fn find_related_tests(file_path: &str, root: &str) -> Vec<String> {
156 let p = Path::new(file_path);
157 let Some(stem) = p.file_stem().and_then(|s| s.to_str()) else {
158 return vec![];
159 };
160
161 let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
162
163 let patterns = vec![
164 format!("{stem}_test.{ext}"),
165 format!("{stem}.test.{ext}"),
166 format!("{stem}.spec.{ext}"),
167 format!("{stem}_spec.{ext}"),
168 format!("test_{stem}.{ext}"),
169 format!("{stem}_tests.{ext}"),
170 format!("{stem}.test.ts"),
171 format!("{stem}.test.tsx"),
172 format!("{stem}.spec.ts"),
173 format!("{stem}.spec.tsx"),
174 format!("{stem}_test.rs"),
175 format!("{stem}_test.py"),
176 format!("test_{stem}.py"),
177 format!("{stem}_test.go"),
178 ];
179
180 let root_path = Path::new(root);
181 let mut found = Vec::new();
182
183 fn walk_for_tests(
184 dir: &Path,
185 patterns: &[String],
186 root: &Path,
187 found: &mut Vec<String>,
188 max_depth: usize,
189 ) {
190 if max_depth == 0 {
191 return;
192 }
193 let Ok(entries) = std::fs::read_dir(dir) else {
194 return;
195 };
196 for entry in entries.flatten() {
197 let path = entry.path();
198 let name = entry.file_name().to_string_lossy().to_string();
199
200 if name.starts_with('.') || name == "node_modules" || name == "target" {
201 continue;
202 }
203
204 if path.is_dir() {
205 walk_for_tests(&path, patterns, root, found, max_depth - 1);
206 } else if patterns.contains(&name) {
207 let rel = path
208 .strip_prefix(root)
209 .unwrap_or(&path)
210 .to_string_lossy()
211 .to_string();
212 found.push(rel);
213 }
214 }
215 }
216
217 walk_for_tests(root_path, &patterns, root_path, &mut found, 8);
218 found.sort();
219 found.dedup();
220 found
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 #[test]
228 fn extract_changed_files_from_diff() {
229 let diff = "diff --git a/src/main.rs b/src/main.rs\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1,3 +1,4 @@\n+use foo;\n";
230 let files = extract_changed_files(diff);
231 assert_eq!(files, vec!["src/main.rs"]);
232 }
233
234 #[test]
235 fn extract_changed_files_multiple() {
236 let diff = "diff --git a/a.rs b/a.rs\n+++ b/a.rs\ndiff --git a/b.rs b/b.rs\n+++ b/b.rs\n";
237 let files = extract_changed_files(diff);
238 assert_eq!(files.len(), 2);
239 assert!(files.contains(&"a.rs".to_string()));
240 assert!(files.contains(&"b.rs".to_string()));
241 }
242
243 #[test]
244 fn find_related_tests_patterns() {
245 let dir = tempfile::tempdir().unwrap();
246 let src = dir.path().join("utils.ts");
247 std::fs::write(&src, "export function foo() {}").unwrap();
248 let test_file = dir.path().join("utils.test.ts");
249 std::fs::write(&test_file, "test('foo', () => {})").unwrap();
250 let spec_file = dir.path().join("utils.spec.ts");
251 std::fs::write(&spec_file, "describe('foo', () => {})").unwrap();
252
253 let found = find_related_tests("utils.ts", dir.path().to_str().unwrap());
254 assert!(found.iter().any(|f| f.contains("utils.test.ts")));
255 assert!(found.iter().any(|f| f.contains("utils.spec.ts")));
256 }
257
258 #[test]
259 fn checklist_always_has_minimum_questions() {
260 let dir = tempfile::tempdir().unwrap();
261 let f = dir.path().join("foo.rs");
262 std::fs::write(&f, "fn bar() {}").unwrap();
263
264 let output = handle_checklist(Some("foo.rs"), dir.path().to_str().unwrap(), 2);
265 let checkbox_count = output.matches("- [ ]").count();
266 assert!(
267 checkbox_count >= 3,
268 "Expected at least 3 questions, got {checkbox_count}"
269 );
270 }
271}