1use crate::factory::AgentFactory;
26use crate::output::AgentOutput;
27use crate::progress::{ProgressHandler, SilentProgress};
28use crate::providers::codex::Codex;
29use anyhow::{Result, bail};
30use log::debug;
31use std::process::Command;
32
33const REVIEW_TEMPLATE_SOURCE: &str = include_str!("../prompts/review/1_0_0.md");
35
36pub fn review_template() -> &'static str {
39 crate::prompts::strip_front_matter(REVIEW_TEMPLATE_SOURCE)
40}
41
42pub struct ReviewParams {
44 pub provider: String,
46 pub uncommitted: bool,
48 pub base: Option<String>,
50 pub commit: Option<String>,
52 pub title: Option<String>,
54 pub prompt: Option<String>,
56 pub system_prompt: Option<String>,
58 pub model: Option<String>,
60 pub root: Option<String>,
62 pub auto_approve: bool,
64 pub add_dirs: Vec<String>,
66 pub progress: Box<dyn ProgressHandler>,
69}
70
71impl Default for ReviewParams {
72 fn default() -> Self {
73 Self {
74 provider: "claude".to_string(),
75 uncommitted: false,
76 base: None,
77 commit: None,
78 title: None,
79 prompt: None,
80 system_prompt: None,
81 model: None,
82 root: None,
83 auto_approve: false,
84 add_dirs: Vec::new(),
85 progress: Box::new(SilentProgress),
86 }
87 }
88}
89
90pub fn gather_diff(
95 uncommitted: bool,
96 base: Option<&str>,
97 commit: Option<&str>,
98 root: Option<&str>,
99) -> Result<String> {
100 let dir = root.unwrap_or(".");
101 let mut diffs = Vec::new();
102
103 if uncommitted {
104 let output = Command::new("git")
105 .args(["diff", "HEAD"])
106 .current_dir(dir)
107 .output()?;
108 let diff = String::from_utf8_lossy(&output.stdout).to_string();
109 if !diff.trim().is_empty() {
110 diffs.push(diff);
111 }
112
113 let untracked = Command::new("git")
115 .args(["ls-files", "--others", "--exclude-standard"])
116 .current_dir(dir)
117 .output()?;
118 let untracked_output = String::from_utf8_lossy(&untracked.stdout).to_string();
119 let files: Vec<&str> = untracked_output.lines().filter(|l| !l.is_empty()).collect();
120 for file in files {
121 let content = Command::new("git")
122 .args(["diff", "--no-index", "/dev/null", file])
123 .current_dir(dir)
124 .output()?;
125 let d = String::from_utf8_lossy(&content.stdout).to_string();
126 if !d.trim().is_empty() {
127 diffs.push(d);
128 }
129 }
130 }
131
132 if let Some(base_branch) = base {
133 let output = Command::new("git")
134 .args(["diff", &format!("{base_branch}...HEAD")])
135 .current_dir(dir)
136 .output()?;
137 let diff = String::from_utf8_lossy(&output.stdout).to_string();
138 if !diff.trim().is_empty() {
139 diffs.push(diff);
140 }
141 }
142
143 if let Some(sha) = commit {
144 let output = Command::new("git")
145 .args(["show", sha, "--format="])
146 .current_dir(dir)
147 .output()?;
148 let diff = String::from_utf8_lossy(&output.stdout).to_string();
149 if !diff.trim().is_empty() {
150 diffs.push(diff);
151 }
152 }
153
154 let combined = diffs.join("\n");
155 if combined.trim().is_empty() {
156 bail!("No diff content found for the specified review target");
157 }
158 Ok(combined)
159}
160
161pub fn build_review_prompt(diff: &str, title: Option<&str>, user_prompt: Option<&str>) -> String {
164 let title_section = match title {
165 Some(t) => format!("## Review Title\n\n{t}"),
166 None => String::new(),
167 };
168 let prompt_section = user_prompt.unwrap_or("");
169
170 review_template()
171 .replace("{DIFF}", diff)
172 .replace("{TITLE_SECTION}", &title_section)
173 .replace("{PROMPT}", prompt_section)
174}
175
176pub async fn run_review(params: ReviewParams) -> Result<Option<AgentOutput>> {
179 if !params.uncommitted && params.base.is_none() && params.commit.is_none() {
180 bail!("Review requires at least one of: uncommitted=true, base=<branch>, commit=<sha>");
181 }
182
183 if params.provider == "codex" {
184 run_codex_review(params).await.map(|_| None)
185 } else {
186 run_generic_review(params).await.map(Some)
187 }
188}
189
190async fn run_generic_review(params: ReviewParams) -> Result<AgentOutput> {
191 let ReviewParams {
192 provider,
193 uncommitted,
194 base,
195 commit,
196 title,
197 prompt,
198 system_prompt,
199 model,
200 root,
201 auto_approve,
202 add_dirs,
203 progress,
204 } = params;
205
206 debug!(
207 "Starting code review via {provider} (uncommitted={uncommitted}, base={base:?}, commit={commit:?})"
208 );
209
210 let diff = gather_diff(
211 uncommitted,
212 base.as_deref(),
213 commit.as_deref(),
214 root.as_deref(),
215 )?;
216 let review_prompt = build_review_prompt(&diff, title.as_deref(), prompt.as_deref());
217
218 progress.on_spinner_start(&format!("Initializing {provider} for review"));
219 let agent = AgentFactory::create(
220 &provider,
221 system_prompt,
222 model,
223 root.clone(),
224 auto_approve,
225 add_dirs,
226 )?;
227 progress.on_spinner_finish();
228
229 let model_name = agent.get_model().to_string();
230 progress.on_success(&format!("Review initialized with model {model_name}"));
231
232 let output = agent.run(Some(&review_prompt)).await?;
233 agent.cleanup().await?;
234 Ok(output.unwrap_or_else(|| AgentOutput::from_text(&provider, "")))
235}
236
237async fn run_codex_review(params: ReviewParams) -> Result<()> {
238 let ReviewParams {
239 uncommitted,
240 base,
241 commit,
242 title,
243 system_prompt,
244 model,
245 root,
246 auto_approve,
247 add_dirs,
248 progress,
249 ..
250 } = params;
251
252 debug!(
253 "Starting code review via Codex (uncommitted={uncommitted}, base={base:?}, commit={commit:?})"
254 );
255
256 progress.on_spinner_start("Initializing Codex for review");
257 let mut agent = AgentFactory::create(
258 "codex",
259 system_prompt,
260 model,
261 root.clone(),
262 auto_approve,
263 add_dirs,
264 )?;
265 progress.on_spinner_finish();
266
267 let model_name = agent.get_model().to_string();
268 progress.on_success(&format!("Review initialized with model {model_name}"));
269
270 let codex = agent
271 .as_any_mut()
272 .downcast_mut::<Codex>()
273 .expect("Failed to get Codex agent for review");
274
275 codex
276 .review(
277 uncommitted,
278 base.as_deref(),
279 commit.as_deref(),
280 title.as_deref(),
281 )
282 .await
283}
284
285#[cfg(test)]
286#[path = "review_tests.rs"]
287mod tests;