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}
17
18pub fn build_review_spec(
19 last_diff: bool,
20 target: Option<String>,
21 files: Vec<String>,
22 style: Option<String>,
23) -> Result<ReviewSpec> {
24 if last_diff && target.is_some() {
25 bail!("--last-diff cannot be combined with --target");
26 }
27 if last_diff && !files.is_empty() {
28 bail!("--last-diff cannot be combined with explicit files");
29 }
30 if target.is_some() && !files.is_empty() {
31 bail!("--target cannot be combined with explicit files");
32 }
33
34 let style = style
35 .map(|value| value.trim().to_string())
36 .filter(|value| !value.is_empty());
37
38 let target = if last_diff {
39 ReviewTarget::LastDiff
40 } else if let Some(target) = target {
41 let target = target.trim();
42 if target.is_empty() {
43 bail!("--target cannot be empty");
44 }
45 ReviewTarget::Custom(target.to_string())
46 } else if !files.is_empty() {
47 let files = files
48 .into_iter()
49 .map(|value| value.trim().to_string())
50 .filter(|value| !value.is_empty())
51 .collect::<Vec<_>>();
52 if files.is_empty() {
53 bail!("review files cannot be empty");
54 }
55 ReviewTarget::Files(files)
56 } else {
57 ReviewTarget::CurrentDiff
58 };
59
60 Ok(ReviewSpec { target, style })
61}
62
63pub fn build_review_prompt(spec: &ReviewSpec) -> String {
64 let mut prompt = String::new();
65 prompt.push_str("Perform a code review.\n");
66 match &spec.target {
67 ReviewTarget::CurrentDiff => {
68 prompt.push_str("Target: review the current git diff in the workspace.\n");
69 }
70 ReviewTarget::LastDiff => {
71 prompt.push_str("Target: review the last committed git diff.\n");
72 }
73 ReviewTarget::Files(files) => {
74 prompt.push_str("Target: review these files:\n");
75 for file in files {
76 let _ = writeln!(prompt, "- {}", file);
77 }
78 }
79 ReviewTarget::Custom(target) => {
80 let _ = writeln!(prompt, "Target: review `{}`.", target);
81 }
82 }
83
84 if let Some(style) = &spec.style {
85 let _ = writeln!(prompt, "Style: {}.", style);
86 }
87
88 prompt.push_str(
89 "\nRequirements:\n\
90 - Review only. Do not modify files or run mutating commands.\n\
91 - Focus on bugs, regressions, security issues, performance issues, and missing tests.\n\
92 - Present findings first, ordered by severity.\n\
93 - Include concrete file paths and line numbers when possible.\n\
94 - If there are no findings, say that explicitly.\n",
95 );
96
97 prompt
98}
99
100#[cfg(test)]
101mod tests {
102 use super::{ReviewSpec, ReviewTarget, build_review_prompt, build_review_spec};
103
104 #[test]
105 fn review_spec_defaults_to_current_diff() {
106 let spec = build_review_spec(false, None, Vec::new(), None).expect("spec");
107 assert_eq!(
108 spec,
109 ReviewSpec {
110 target: ReviewTarget::CurrentDiff,
111 style: None,
112 }
113 );
114 }
115
116 #[test]
117 fn review_spec_rejects_conflicting_target_selectors() {
118 let err = build_review_spec(true, Some("HEAD~1..HEAD".to_string()), Vec::new(), None)
119 .expect_err("conflicting selectors should fail");
120
121 assert!(err.to_string().contains("--last-diff"));
122 }
123
124 #[test]
125 fn review_prompt_includes_files_and_style() {
126 let spec = build_review_spec(
127 false,
128 None,
129 vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
130 Some("security".to_string()),
131 )
132 .expect("spec");
133 let prompt = build_review_prompt(&spec);
134
135 assert!(prompt.contains("Target: review these files"));
136 assert!(prompt.contains("- src/main.rs"));
137 assert!(prompt.contains("Style: security."));
138 assert!(prompt.contains("Review only."));
139 }
140}