Skip to main content

codex_wrapper/command/
review.rs

1use crate::Codex;
2use crate::command::CodexCommand;
3use crate::command::exec::push_typed_config;
4#[cfg(feature = "json")]
5use crate::error::Error;
6use crate::error::Result;
7use crate::exec::{self, CommandOutput};
8#[cfg(feature = "json")]
9use crate::types::JsonLineEvent;
10use crate::types::{ApprovalPolicyConfig, SandboxMode, WebSearchMode};
11
12/// Run a code review non-interactively (`codex exec review`).
13///
14/// # Why not `codex review`
15///
16/// `codex-cli` exposes review at two paths, `codex review` and
17/// `codex exec review`. They are the same command: same `[PROMPT]` positional,
18/// same non-interactive behavior, and a byte-identical error when no review
19/// scope is given.
20///
21/// They are not equally capable. As of 0.145.0, top-level `codex review`
22/// accepts a strict subset of the flags, missing ten that `codex exec review`
23/// has:
24///
25/// ```text
26/// --dangerously-bypass-approvals-and-sandbox   --json
27/// --dangerously-bypass-hook-trust              --output-schema <FILE>
28/// --ephemeral                                  --skip-git-repo-check
29/// --ignore-rules                               -m, --model <MODEL>
30/// --ignore-user-config                         -o, --output-last-message <FILE>
31/// ```
32///
33/// Nothing is available on `codex review` that is not also on
34/// `codex exec review`, and the missing flags are rejected outright rather
35/// than silently ignored.
36///
37/// `--json` is the decisive one:
38/// [`execute_json_lines`](Self::execute_json_lines) depends on it, so a
39/// builder targeting the top-level path could not offer structured output at
40/// all. This wrapper therefore targets `codex exec review` only. Use
41/// [`RawCommand`](crate::RawCommand) if you need the literal `codex review`
42/// invocation.
43///
44/// `tests/contract.rs` asserts the subset relationship still holds, so if the
45/// two surfaces ever diverge the other way, CI reports it.
46#[derive(Debug, Clone)]
47pub struct ReviewCommand {
48    prompt: Option<String>,
49    approval_policy: Option<ApprovalPolicyConfig>,
50    web_search: Option<WebSearchMode>,
51    config_overrides: Vec<String>,
52    enabled_features: Vec<String>,
53    disabled_features: Vec<String>,
54    uncommitted: bool,
55    base: Option<String>,
56    commit: Option<String>,
57    model: Option<String>,
58    title: Option<String>,
59    strict_config: bool,
60    dangerously_bypass_hook_trust: bool,
61    full_auto: bool,
62    dangerously_bypass_approvals_and_sandbox: bool,
63    skip_git_repo_check: bool,
64    ephemeral: bool,
65    ignore_user_config: bool,
66    ignore_rules: bool,
67    output_schema: Option<String>,
68    json: bool,
69    output_last_message: Option<String>,
70    retry_policy: Option<crate::retry::RetryPolicy>,
71}
72
73impl ReviewCommand {
74    #[must_use]
75    pub fn new() -> Self {
76        Self {
77            prompt: None,
78            approval_policy: None,
79            web_search: None,
80            config_overrides: Vec::new(),
81            enabled_features: Vec::new(),
82            disabled_features: Vec::new(),
83            uncommitted: false,
84            base: None,
85            commit: None,
86            model: None,
87            title: None,
88            strict_config: false,
89            dangerously_bypass_hook_trust: false,
90            full_auto: false,
91            dangerously_bypass_approvals_and_sandbox: false,
92            skip_git_repo_check: false,
93            ephemeral: false,
94            ignore_user_config: false,
95            ignore_rules: false,
96            output_schema: None,
97            json: false,
98            output_last_message: None,
99            retry_policy: None,
100        }
101    }
102
103    #[must_use]
104    pub fn prompt(mut self, prompt: impl Into<String>) -> Self {
105        self.prompt = Some(prompt.into());
106        self
107    }
108
109    /// Override a config key (`-c key=value`).
110    ///
111    /// Because `-c` is last-wins, a key set here overrides the same key set by
112    /// [`approval_policy`](Self::approval_policy),
113    /// [`search_mode`](Self::search_mode), or [`full_auto`](Self::full_auto).
114    #[must_use]
115    pub fn config(mut self, key_value: impl Into<String>) -> Self {
116        self.config_overrides.push(key_value.into());
117        self
118    }
119
120    /// Set when the model asks for approval (`-c approval_policy="<value>"`).
121    ///
122    /// `codex-cli` 0.145.0 removed `--ask-for-approval` from the exec family;
123    /// the config key is the supported equivalent. Accepts an
124    /// [`ApprovalPolicy`](crate::ApprovalPolicy) directly, or an
125    /// [`ApprovalPolicyConfig`] for the two values the flag never took.
126    #[must_use]
127    pub fn approval_policy(mut self, policy: impl Into<ApprovalPolicyConfig>) -> Self {
128        self.approval_policy = Some(policy.into());
129        self
130    }
131
132    /// Enable live web search.
133    ///
134    /// Shorthand for `search_mode(WebSearchMode::Live)`, which is what the
135    /// removed `--search` flag meant.
136    #[must_use]
137    pub fn search(self) -> Self {
138        self.search_mode(WebSearchMode::Live)
139    }
140
141    /// Set the web search mode (`-c web_search="<value>"`).
142    ///
143    /// `codex-cli` 0.145.0 removed `--search` from the exec family; the config
144    /// key is the supported equivalent, and it is an enum rather than the
145    /// flag's boolean.
146    #[must_use]
147    pub fn search_mode(mut self, mode: WebSearchMode) -> Self {
148        self.web_search = Some(mode);
149        self
150    }
151
152    #[must_use]
153    pub fn enable(mut self, feature: impl Into<String>) -> Self {
154        self.enabled_features.push(feature.into());
155        self
156    }
157
158    #[must_use]
159    pub fn disable(mut self, feature: impl Into<String>) -> Self {
160        self.disabled_features.push(feature.into());
161        self
162    }
163
164    #[must_use]
165    pub fn uncommitted(mut self) -> Self {
166        self.uncommitted = true;
167        self
168    }
169
170    #[must_use]
171    pub fn base(mut self, branch: impl Into<String>) -> Self {
172        self.base = Some(branch.into());
173        self
174    }
175
176    #[must_use]
177    pub fn commit(mut self, sha: impl Into<String>) -> Self {
178        self.commit = Some(sha.into());
179        self
180    }
181
182    #[must_use]
183    pub fn model(mut self, model: impl Into<String>) -> Self {
184        self.model = Some(model.into());
185        self
186    }
187
188    #[must_use]
189    pub fn title(mut self, title: impl Into<String>) -> Self {
190        self.title = Some(title.into());
191        self
192    }
193
194    /// Error on unrecognized config keys (`--strict-config`).
195    #[must_use]
196    pub fn strict_config(mut self) -> Self {
197        self.strict_config = true;
198        self
199    }
200
201    /// Bypass the hook trust prompt (`--dangerously-bypass-hook-trust`).
202    ///
203    /// Allows configured hooks to run without confirmation. Use with caution.
204    #[must_use]
205    pub(crate) fn set_bypass_hook_trust(mut self) -> Self {
206        self.dangerously_bypass_hook_trust = true;
207        self
208    }
209
210    /// Run in full-auto mode, emitted as `-c sandbox_mode="workspace-write"`.
211    ///
212    /// `--full-auto` is deprecated upstream; `codex-cli` 0.145.0 hides it and
213    /// warns to use `--sandbox workspace-write` instead. `codex exec review`
214    /// has no `--sandbox` flag, so this sets the equivalent config key.
215    #[must_use]
216    pub fn full_auto(mut self) -> Self {
217        self.full_auto = true;
218        self
219    }
220
221    #[must_use]
222    pub(crate) fn set_bypass_approvals_and_sandbox(mut self) -> Self {
223        self.dangerously_bypass_approvals_and_sandbox = true;
224        self
225    }
226
227    #[must_use]
228    pub fn skip_git_repo_check(mut self) -> Self {
229        self.skip_git_repo_check = true;
230        self
231    }
232
233    #[must_use]
234    pub fn ephemeral(mut self) -> Self {
235        self.ephemeral = true;
236        self
237    }
238
239    /// Ignore the user-level config file (`--ignore-user-config`).
240    #[must_use]
241    pub fn ignore_user_config(mut self) -> Self {
242        self.ignore_user_config = true;
243        self
244    }
245
246    /// Ignore project rules files (`--ignore-rules`).
247    #[must_use]
248    pub fn ignore_rules(mut self) -> Self {
249        self.ignore_rules = true;
250        self
251    }
252
253    /// Require output to conform to a JSON schema (`--output-schema <path>`).
254    #[must_use]
255    pub fn output_schema(mut self, path: impl Into<String>) -> Self {
256        self.output_schema = Some(path.into());
257        self
258    }
259
260    #[must_use]
261    pub fn json(mut self) -> Self {
262        self.json = true;
263        self
264    }
265
266    #[must_use]
267    pub fn output_last_message(mut self, path: impl Into<String>) -> Self {
268        self.output_last_message = Some(path.into());
269        self
270    }
271
272    #[must_use]
273    pub fn retry(mut self, policy: crate::retry::RetryPolicy) -> Self {
274        self.retry_policy = Some(policy);
275        self
276    }
277
278    #[cfg(feature = "json")]
279    pub async fn execute_json_lines(&self, codex: &Codex) -> Result<Vec<JsonLineEvent>> {
280        let mut args = self.args();
281        if !self.json {
282            args.push("--json".into());
283        }
284
285        let output = exec::run_codex_with_retry(codex, args, self.retry_policy.as_ref()).await?;
286        output
287            .stdout
288            .lines()
289            .filter(|line| line.trim_start().starts_with('{'))
290            .map(|line| {
291                serde_json::from_str(line).map_err(|source| Error::Json {
292                    message: format!("failed to parse JSONL event: {line}"),
293                    source,
294                })
295            })
296            .collect()
297    }
298
299    /// Execute the review and return a typed
300    /// [`QueryResult`](crate::types::QueryResult).
301    ///
302    /// Review emits the same event vocabulary as `codex exec`, so the review
303    /// comments arrive as the `agent_message` item that
304    /// [`result`](crate::types::QueryResult::result) is assembled from. Use
305    /// [`execute_json_lines`](Self::execute_json_lines) for the raw stream.
306    /// Requires the `json` feature.
307    ///
308    /// One difference from exec, observed on `codex-cli` 0.145.0: the
309    /// `turn.completed` event of a review reports a usage object of all
310    /// zeros, so [`usage`](crate::types::QueryResult::usage) is present but
311    /// carries no counts.
312    #[cfg(feature = "json")]
313    pub async fn execute_json(&self, codex: &Codex) -> Result<crate::types::QueryResult> {
314        let events = self.execute_json_lines(codex).await?;
315        Ok(crate::types::QueryResult::from_events(events))
316    }
317}
318
319impl Default for ReviewCommand {
320    fn default() -> Self {
321        Self::new()
322    }
323}
324
325impl CodexCommand for ReviewCommand {
326    type Output = CommandOutput;
327
328    fn args(&self) -> Vec<String> {
329        let mut args = vec!["exec".into(), "review".into()];
330        push_typed_config(&mut args, self.approval_policy, self.web_search);
331        // `exec review` has no `--sandbox` flag, so the `--full-auto`
332        // replacement has to go through the config key.
333        if self.full_auto {
334            args.push("-c".into());
335            args.push(format!(
336                "sandbox_mode=\"{}\"",
337                SandboxMode::WorkspaceWrite.as_arg()
338            ));
339        }
340        for value in &self.config_overrides {
341            args.push("-c".into());
342            args.push(value.clone());
343        }
344        for value in &self.enabled_features {
345            args.push("--enable".into());
346            args.push(value.clone());
347        }
348        for value in &self.disabled_features {
349            args.push("--disable".into());
350            args.push(value.clone());
351        }
352        if self.uncommitted {
353            args.push("--uncommitted".into());
354        }
355        if let Some(base) = &self.base {
356            args.push("--base".into());
357            args.push(base.clone());
358        }
359        if let Some(commit) = &self.commit {
360            args.push("--commit".into());
361            args.push(commit.clone());
362        }
363        if let Some(model) = &self.model {
364            args.push("--model".into());
365            args.push(model.clone());
366        }
367        if let Some(title) = &self.title {
368            args.push("--title".into());
369            args.push(title.clone());
370        }
371        if self.strict_config {
372            args.push("--strict-config".into());
373        }
374        if self.dangerously_bypass_approvals_and_sandbox {
375            args.push("--dangerously-bypass-approvals-and-sandbox".into());
376        }
377        if self.dangerously_bypass_hook_trust {
378            args.push("--dangerously-bypass-hook-trust".into());
379        }
380        if self.skip_git_repo_check {
381            args.push("--skip-git-repo-check".into());
382        }
383        if self.ephemeral {
384            args.push("--ephemeral".into());
385        }
386        if self.ignore_user_config {
387            args.push("--ignore-user-config".into());
388        }
389        if self.ignore_rules {
390            args.push("--ignore-rules".into());
391        }
392        if let Some(output_schema) = &self.output_schema {
393            args.push("--output-schema".into());
394            args.push(output_schema.clone());
395        }
396        if self.json {
397            args.push("--json".into());
398        }
399        if let Some(path) = &self.output_last_message {
400            args.push("--output-last-message".into());
401            args.push(path.clone());
402        }
403        if let Some(prompt) = &self.prompt {
404            args.push(prompt.clone());
405        }
406        args
407    }
408
409    async fn execute(&self, codex: &Codex) -> Result<CommandOutput> {
410        exec::run_codex_with_retry(codex, self.args(), self.retry_policy.as_ref()).await
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use crate::types::ApprovalPolicy;
418
419    #[test]
420    fn review_args() {
421        let args = ReviewCommand::new()
422            .uncommitted()
423            .model("gpt-5")
424            .json()
425            .prompt("focus on correctness")
426            .args();
427
428        assert_eq!(
429            args,
430            vec![
431                "exec",
432                "review",
433                "--uncommitted",
434                "--model",
435                "gpt-5",
436                "--json",
437                "focus on correctness",
438            ]
439        );
440    }
441
442    #[test]
443    fn review_new_flags() {
444        let args = ReviewCommand::new()
445            .uncommitted()
446            .strict_config()
447            .set_bypass_hook_trust()
448            .args();
449
450        assert_eq!(
451            args,
452            vec![
453                "exec",
454                "review",
455                "--uncommitted",
456                "--strict-config",
457                "--dangerously-bypass-hook-trust",
458            ]
459        );
460    }
461
462    #[test]
463    fn review_approval_and_search_emit_config_keys() {
464        let args = ReviewCommand::new()
465            .uncommitted()
466            .approval_policy(ApprovalPolicy::Untrusted)
467            .search()
468            .args();
469        assert_eq!(
470            args,
471            vec![
472                "exec",
473                "review",
474                "-c",
475                "approval_policy=\"untrusted\"",
476                "-c",
477                "web_search=\"live\"",
478                "--uncommitted"
479            ]
480        );
481    }
482
483    /// `codex exec review` has no `--sandbox` flag, so the `--full-auto`
484    /// replacement goes through the config key. See #55.
485    #[test]
486    fn review_full_auto_emits_sandbox_config_key() {
487        let args = ReviewCommand::new().uncommitted().full_auto().args();
488        assert_eq!(
489            args,
490            vec![
491                "exec",
492                "review",
493                "-c",
494                "sandbox_mode=\"workspace-write\"",
495                "--uncommitted"
496            ]
497        );
498        assert!(!args.iter().any(|a| a == "--full-auto"));
499    }
500
501    /// #65: these three were listed in #41 P1 but never landed on
502    /// `ReviewCommand`.
503    #[test]
504    fn review_ignore_and_output_schema_args() {
505        let args = ReviewCommand::new()
506            .uncommitted()
507            .ignore_user_config()
508            .ignore_rules()
509            .output_schema("/tmp/schema.json")
510            .args();
511        assert_eq!(
512            args,
513            vec![
514                "exec",
515                "review",
516                "--uncommitted",
517                "--ignore-user-config",
518                "--ignore-rules",
519                "--output-schema",
520                "/tmp/schema.json"
521            ]
522        );
523    }
524
525    /// #70 asked whether `QueryResult::from_events` holds up on review output.
526    /// It does: the fixture is a transcript of a real review run, and the
527    /// review comments land in `result` the same way an exec answer does.
528    #[cfg(all(unix, feature = "json"))]
529    #[tokio::test]
530    async fn review_execute_json_assembles_a_query_result() {
531        let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
532            .join("tests")
533            .join("fake-codex-review.sh");
534        let codex = Codex::builder()
535            .binary("/bin/bash")
536            .arg(script.to_str().unwrap())
537            .build()
538            .expect("bash must exist");
539
540        let result = ReviewCommand::new()
541            .uncommitted()
542            .execute_json(&codex)
543            .await
544            .unwrap();
545
546        assert_eq!(result.result, "- [P1] Keep add performing addition");
547        assert_eq!(
548            result.thread_id.as_deref(),
549            Some("019fd952-7ce9-7662-8a20-9c33c1718dca")
550        );
551        // The command_execution items the reviewer ran are in the stream but
552        // must not leak into the result text.
553        assert!(!result.result.contains("git diff"));
554        assert_eq!(result.events.len(), 6);
555        // Review reports usage, but a real run reports it as all zeros.
556        assert_eq!(result.usage.and_then(|u| u.total()), Some(0));
557    }
558}