Skip to main content

lean_ctx/tools/
ctx_review.rs

1use crate::core::tokens::count_tokens;
2use std::path::Path;
3
4/// Dispatches code review actions (review, diff-review, checklist).
5pub 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
154/// Finds test files related to the given source file by naming conventions.
155pub 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}