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
75pub 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 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 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}