Skip to main content

zag_agent/
review.rs

1//! Library-level implementation of `zag review`.
2//!
3//! Gathers a git diff (staged/unstaged/untracked, against a base branch, or
4//! at a specific commit), wraps it in the review prompt template, and runs
5//! it through a provider. For Codex, the provider's native `codex review`
6//! command is used instead; every other provider uses the generic prompt
7//! path.
8//!
9//! # Example
10//!
11//! ```no_run
12//! use zag_agent::review::{ReviewParams, run_review};
13//!
14//! # async fn example() -> anyhow::Result<()> {
15//! let output = run_review(ReviewParams {
16//!     provider: "claude".to_string(),
17//!     uncommitted: true,
18//!     ..ReviewParams::default()
19//! })
20//! .await?;
21//! println!("{}", output.map(|o| o.result.unwrap_or_default()).unwrap_or_default());
22//! # Ok(()) }
23//! ```
24
25use 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
33/// Raw `prompts/review/1_0_0.md` source, including YAML front matter.
34const REVIEW_TEMPLATE_SOURCE: &str = include_str!("../prompts/review/1_0_0.md");
35
36/// Review prompt template (front matter stripped) — `{DIFF}`,
37/// `{TITLE_SECTION}`, `{PROMPT}` are replaced at run time.
38pub fn review_template() -> &'static str {
39    crate::prompts::strip_front_matter(REVIEW_TEMPLATE_SOURCE)
40}
41
42/// Parameters for [`run_review`].
43pub struct ReviewParams {
44    /// Provider name (e.g. `"claude"`, `"codex"`).
45    pub provider: String,
46    /// Include staged, unstaged, and untracked changes.
47    pub uncommitted: bool,
48    /// Diff against this base branch (e.g. `Some("main")`).
49    pub base: Option<String>,
50    /// Review the diff of a specific commit.
51    pub commit: Option<String>,
52    /// Optional title to render in the review prompt.
53    pub title: Option<String>,
54    /// Free-form reviewer instructions appended to the prompt.
55    pub prompt: Option<String>,
56    /// System prompt override.
57    pub system_prompt: Option<String>,
58    /// Model override.
59    pub model: Option<String>,
60    /// Working directory.
61    pub root: Option<String>,
62    /// Skip permission prompts.
63    pub auto_approve: bool,
64    /// Additional directories to include.
65    pub add_dirs: Vec<String>,
66    /// Progress handler for status / spinner callbacks. Defaults to
67    /// [`SilentProgress`].
68    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
90/// Gather `git diff` content for the given review targets. The returned
91/// string concatenates all requested diffs; bails if nothing matched.
92///
93/// `uncommitted` = `true` captures staged, unstaged, AND untracked files.
94pub 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        // Also capture untracked files as pseudo-diffs so the reviewer sees new files.
114        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
161/// Render a review prompt from [`review_template`] with the given diff,
162/// optional title, and optional reviewer prompt.
163pub 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
176/// Run a review, returning the structured agent output (or `None` when the
177/// provider doesn't surface a result — e.g. the codex-native path).
178pub 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;