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#[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 #[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 #[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 #[must_use]
137 pub fn search(self) -> Self {
138 self.search_mode(WebSearchMode::Live)
139 }
140
141 #[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 #[must_use]
196 pub fn strict_config(mut self) -> Self {
197 self.strict_config = true;
198 self
199 }
200
201 #[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 #[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 #[must_use]
241 pub fn ignore_user_config(mut self) -> Self {
242 self.ignore_user_config = true;
243 self
244 }
245
246 #[must_use]
248 pub fn ignore_rules(mut self) -> Self {
249 self.ignore_rules = true;
250 self
251 }
252
253 #[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 #[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 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 #[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 #[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 #[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 assert!(!result.result.contains("git diff"));
554 assert_eq!(result.events.len(), 6);
555 assert_eq!(result.usage.and_then(|u| u.total()), Some(0));
557 }
558}