Skip to main content

vtcode_core/
review.rs

1use anyhow::{Result, bail};
2use std::fmt::Write as _;
3
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub enum ReviewTarget {
6    CurrentDiff,
7    LastDiff,
8    Files(Vec<String>),
9    Custom(String),
10}
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct ReviewSpec {
14    pub target: ReviewTarget,
15    pub style: Option<String>,
16    pub instructions: Option<String>,
17}
18
19pub fn build_review_spec(
20    last_diff: bool,
21    target: Option<String>,
22    files: Vec<String>,
23    style: Option<String>,
24) -> Result<ReviewSpec> {
25    build_review_spec_with_instructions(last_diff, target, files, style, None)
26}
27
28pub fn build_review_spec_with_instructions(
29    last_diff: bool,
30    target: Option<String>,
31    files: Vec<String>,
32    style: Option<String>,
33    instructions: Option<String>,
34) -> Result<ReviewSpec> {
35    if last_diff && target.is_some() {
36        bail!("--last-diff cannot be combined with --target");
37    }
38    if last_diff && !files.is_empty() {
39        bail!("--last-diff cannot be combined with explicit files");
40    }
41    if target.is_some() && !files.is_empty() {
42        bail!("--target cannot be combined with explicit files");
43    }
44
45    let style = style.map(|value| value.trim().to_string()).filter(|value| !value.is_empty());
46    let instructions = instructions
47        .map(|value| value.trim().to_string())
48        .filter(|value| !value.is_empty());
49
50    let target = if last_diff {
51        ReviewTarget::LastDiff
52    } else if let Some(target) = target {
53        let target = target.trim();
54        if target.is_empty() {
55            bail!("--target cannot be empty");
56        }
57        ReviewTarget::Custom(target.to_string())
58    } else if !files.is_empty() {
59        let files = files
60            .into_iter()
61            .map(|value| value.trim().to_string())
62            .filter(|value| !value.is_empty())
63            .collect::<Vec<_>>();
64        if files.is_empty() {
65            bail!("review files cannot be empty");
66        }
67        ReviewTarget::Files(files)
68    } else {
69        ReviewTarget::CurrentDiff
70    };
71
72    Ok(ReviewSpec { target, style, instructions })
73}
74
75/// Returns true only when the natural-language instructions explicitly ask the
76/// reviewer to implement or apply fixes. `/review` stays read-only otherwise.
77///
78/// Matching is whole-word: substring matching would escalate ordinary prose
79/// such as "review the implementation" (`implement` ⊂ `implementation`) or
80/// "how we apply theming" (`apply them` ⊂ `apply theming`).
81pub fn review_allows_mutation(spec: &ReviewSpec) -> bool {
82    let Some(instructions) = spec.instructions.as_deref() else {
83        return false;
84    };
85    let lowered = instructions.to_ascii_lowercase();
86    let words = split_instruction_words(&lowered);
87    // Intentionally narrow: "fix" alone (e.g. "focus on fixes") must not escalate.
88    // Require an action verb directed at making the change.
89    const MARKERS: &[&[&str]] = &[
90        &["implement", "the", "fix"],
91        &["implement", "fixes"],
92        &["implement", "it"],
93        &["implement", "them"],
94        &["implement"],
95        &["apply", "the", "fix"],
96        &["apply", "fixes"],
97        &["apply", "them"],
98        &["go", "ahead", "and", "fix"],
99        &["please", "fix"],
100        &["fix", "it"],
101        &["fix", "them"],
102        &["fix", "all"],
103        &["make", "the", "fix"],
104        &["make", "the", "changes"],
105    ];
106    MARKERS.iter().any(|marker| contains_word_sequence(&words, marker))
107}
108
109fn split_instruction_words(lowered: &str) -> Vec<&str> {
110    lowered
111        .split(|ch: char| !ch.is_ascii_alphanumeric())
112        .filter(|word| !word.is_empty())
113        .collect()
114}
115
116fn contains_word_sequence(words: &[&str], marker: &[&str]) -> bool {
117    !marker.is_empty() && marker.len() <= words.len() && words.windows(marker.len()).any(|window| window == marker)
118}
119
120pub fn build_review_prompt(spec: &ReviewSpec) -> String {
121    let mut prompt = String::new();
122    prompt.push_str("You are a code reviewer. Your job is to review code changes and provide actionable feedback.\n");
123    match spec.instructions.as_deref() {
124        Some(instructions) => {
125            let _ = writeln!(prompt, "\n---\n\nInput: {instructions}\n\n---");
126        }
127        None => {
128            prompt.push_str("\n---\n\nInput: (none - default review)\n\n---");
129        }
130    }
131    prompt.push_str(
132        "\n## Determining What to Review\n\n\
133         Based on the input provided, determine which type of review to perform:\n\n\
134         1. **No arguments (default)**: Review all uncommitted changes\n\
135         - Run: `git diff` for unstaged changes\n\
136         - Run: `git diff --cached` for staged changes\n\
137         - Run: `git status --short` to identify untracked (net new) files\n\n\
138         2. **Commit hash** (40-char SHA or short hash): Review that specific commit\n\
139         - First verify with `git rev-parse --verify <sha>`; then run `git show <verified-sha>`\n\
140         - Never interpolate free-form prose into `git show`. If verification fails, fall back to the default diff and say so.\n\n\
141         3. **Branch name or range** (e.g. `feature`, `main..HEAD`): Compare current branch to the specified branch\n\
142         - First verify with `git rev-parse --verify <branch>`; then run `git diff <verified-target>...HEAD`\n\
143         - Never interpolate free-form prose into `git diff`. If verification fails, fall back to the default diff and say so.\n\n\
144         4. **PR URL or number**: Review the pull request\n\
145         - Run: `gh pr view <verified-number-or-url>` to get PR context\n\
146         - Run: `gh pr diff <verified-number-or-url>` to get the diff\n\
147         - Extract only the PR number/URL from the input; never pass prose as the argument.\n\n\
148         Use best judgement when processing input. Free-form instructions after `/review` describe review *focus* (correctness, regressions, style); they are not shell arguments.\n",
149    );
150    prompt.push_str("\nExplicit target for this run:\n");
151    match &spec.target {
152        ReviewTarget::CurrentDiff => {
153            prompt.push_str("- Target: review the current git diff in the workspace.\n");
154        }
155        ReviewTarget::LastDiff => {
156            prompt.push_str("- Target: review the last committed git diff.\n");
157        }
158        ReviewTarget::Files(files) => {
159            prompt.push_str("- Target: review these files:\n");
160            for file in files {
161                let _ = writeln!(prompt, "  - {file}");
162            }
163        }
164        ReviewTarget::Custom(target) => {
165            let _ = writeln!(prompt, "- Target: review `{target}`.");
166        }
167    }
168
169    if let Some(style) = &spec.style {
170        let _ = writeln!(prompt, "- Style: {style}.");
171    }
172
173    prompt.push_str(
174        "\n## Gathering Context\n\n\
175         **Diffs alone are not enough.** After getting the diff, read the entire file(s) being modified to understand the full context. Code that looks wrong in isolation may be correct given surrounding logic - and vice versa.\n\n\
176         - Use the diff to identify which files changed\n\
177         - Use `git status --short` to identify untracked files, then read their full contents\n\
178         - Read the full file to understand existing patterns, control flow, and error handling\n\
179         - Check for existing style guide or conventions files (CONVENTIONS.md, AGENTS.md, .editorconfig, etc.)\n",
180    );
181
182    prompt.push_str(
183        "\n## What to Look For\n\n\
184         **Bugs** - Your primary focus.\n\n\
185         - Logic errors, off-by-one mistakes, incorrect conditionals\n\
186         - If-else guards: missing guards, incorrect branching, unreachable code paths\n\
187         - Edge cases: null/empty/undefined inputs, error conditions, race conditions\n\
188         - Security issues: injection, auth bypass, data exposure\n\
189         - Broken error handling that swallows failures, throws unexpectedly or returns error types that are not caught.\n\n\
190         **Structure** - Does the code fit the codebase?\n\n\
191         - Does it follow existing patterns and conventions?\n\
192         - Are there established abstractions it should use but doesn't?\n\
193         - Excessive nesting that could be flattened with early returns or extraction\n\n\
194         **Performance** - Only flag if obviously problematic.\n\n\
195         - O(n^2) on unbounded data, N+1 queries, blocking I/O on hot paths\n\n\
196         **Behavior Changes** - If a behavioral change is introduced, raise it (especially if it's possibly unintentional).\n",
197    );
198
199    prompt.push_str(
200        "\n## Reporting Findings\n\n\
201         Report every suspected bug with the concrete scenario (inputs, state, or environment) where it fails and your confidence (high, medium, or low). A lower-confidence finding with a clear scenario still helps the reader decide what to check.\n\n\
202         - Only review the changes - do not review pre-existing code that wasn't modified\n\
203         - Use available tools (callers, tests, docs) to raise or lower your confidence before reporting\n\
204         - Each finding needs a realistic scenario where it breaks; do not invent hypothetical problems\n\n\
205         **Don't be a zealot about style.** When checking code against conventions:\n\n\
206         - Verify the code is _actually_ in violation. Don't complain about else statements if early returns are already being used correctly.\n\
207         - Some \"violations\" are acceptable when they're the simplest option. A `let` statement is fine if the alternative is convoluted.\n\
208         - Excessive nesting is a legitimate concern regardless of other style choices.\n",
209    );
210
211    if review_allows_mutation(spec) {
212        prompt.push_str(
213            "\n## Fix Mode (explicitly requested)\n\n\
214             The input explicitly asks you to implement fixes. After presenting ranked findings, you may apply justified DRY/KISS refactors, fix, test, and re-review until clean.\n",
215        );
216    } else {
217        prompt.push_str(
218            "\n## Requirements\n\n\
219             - Review only. Do not modify files or run mutating commands.\n\
220             - Fix, refactor, or implement changes only when the input explicitly asks for it (standalone word \"implement\", or phrases like \"apply the fix\" or \"fix it\"). Otherwise report findings only.\n\
221             - Focus on bugs, regressions, security issues, performance issues, and missing tests.\n\
222             - Present findings first, ordered by severity.\n\
223             - Include concrete file paths and line numbers when possible.\n\
224             - If there are no findings, say that explicitly.\n",
225        );
226    }
227
228    prompt.push_str(
229        "\n## Output\n\n\
230         1. For each finding, state why it is a bug, the scenarios, environments, or inputs needed for it to arise, its severity, and your confidence.\n\
231         2. Do not overstate severity; when severity depends on the scenario, say so up front.\n\
232         3. Keep the tone neutral and specific: describe what the code does, not the author.\n\
233         4. Write so the reader can quickly understand the issue without reading too closely.\n\
234         5. Leave out praise and comments that do not help the reader act.\n",
235    );
236
237    prompt
238}
239
240#[cfg(test)]
241mod tests {
242    use super::{ReviewSpec, ReviewTarget, build_review_prompt, build_review_spec, review_allows_mutation};
243
244    #[test]
245    fn review_spec_defaults_to_current_diff() {
246        let spec = build_review_spec(false, None, Vec::new(), None).expect("spec");
247        assert_eq!(
248            spec,
249            ReviewSpec {
250                target: ReviewTarget::CurrentDiff,
251                style: None,
252                instructions: None
253            }
254        );
255    }
256
257    #[test]
258    fn review_spec_rejects_conflicting_target_selectors() {
259        let err = build_review_spec(true, Some("HEAD~1..HEAD".to_string()), Vec::new(), None)
260            .expect_err("conflicting selectors should fail");
261
262        assert!(err.to_string().contains("--last-diff"));
263    }
264
265    #[test]
266    fn review_prompt_includes_files_and_style() {
267        let spec = build_review_spec(
268            false,
269            None,
270            vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
271            Some("security".to_string()),
272        )
273        .expect("spec");
274        let prompt = build_review_prompt(&spec);
275
276        assert!(prompt.contains("Target: review these files"));
277        assert!(prompt.contains("- src/main.rs"));
278        assert!(prompt.contains("Style: security."));
279        assert!(prompt.contains("Review only."));
280    }
281
282    #[test]
283    fn review_prompt_asks_for_confidence_instead_of_certainty() {
284        let spec = build_review_spec(false, None, Vec::new(), None).expect("spec");
285        let prompt = build_review_prompt(&spec);
286
287        assert!(prompt.contains("Report every suspected bug"));
288        assert!(prompt.contains("your confidence (high, medium, or low)"));
289        assert!(!prompt.contains("Be certain"));
290        assert!(!prompt.contains("if you're unsure"));
291    }
292
293    #[test]
294    fn review_prompt_never_interpolates_prose_into_shell_commands() {
295        let spec = super::build_review_spec_with_instructions(
296            false,
297            None,
298            Vec::new(),
299            None,
300            Some("Review the full diff and nearby code for correctness".to_string()),
301        )
302        .expect("spec");
303        let prompt = build_review_prompt(&spec);
304
305        assert!(prompt.contains("Input: Review the full diff"));
306        assert!(!prompt.contains("git show Review the full diff"));
307        assert!(!prompt.contains("git diff Review the full diff"));
308        assert!(prompt.contains("they are not shell arguments"));
309        assert!(prompt.contains("Review only."));
310    }
311
312    #[test]
313    fn review_fix_escalation_requires_explicit_action_verb() {
314        let read_only = super::build_review_spec_with_instructions(
315            false,
316            None,
317            Vec::new(),
318            None,
319            Some("Review the full diff for correctness and regressions".to_string()),
320        )
321        .expect("spec");
322        assert!(!review_allows_mutation(&read_only));
323        assert!(build_review_prompt(&read_only).contains("Review only."));
324
325        let fix_mode = super::build_review_spec_with_instructions(
326            false,
327            None,
328            Vec::new(),
329            None,
330            Some("Review the full diff, then implement fixes for each one".to_string()),
331        )
332        .expect("spec");
333        assert!(review_allows_mutation(&fix_mode));
334        assert!(build_review_prompt(&fix_mode).contains("Fix Mode"));
335    }
336
337    #[test]
338    fn review_fix_escalation_ignores_substring_false_positives() {
339        // Asymmetric regression tests: "implement" ⊂ "implementation" and
340        // "apply them" ⊂ "apply theming" must not escalate.
341        for prose in [
342            "Review the implementation for correctness",
343            "Check how we apply theming in the TUI",
344            "Focus on suffix handling and prefix fixes",
345        ] {
346            let spec =
347                super::build_review_spec_with_instructions(false, None, Vec::new(), None, Some(prose.to_string()))
348                    .expect("spec");
349            assert!(!review_allows_mutation(&spec), "{prose:?} must stay read-only");
350            assert!(build_review_prompt(&spec).contains("Review only."));
351        }
352    }
353}