use std::collections::HashMap;
use std::path::PathBuf;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::cli_executor::adapter::{AiCliAdapter, CodeGenRequest, CodeGenResult};
use crate::cli_executor::executor::StdinStrategy;
use crate::sandbox::ExecutionResult;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum CodexApprovalMode {
FullAuto,
Suggest,
}
impl Default for CodexApprovalMode {
fn default() -> Self {
Self::FullAuto
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodexAdapter {
pub executable_path: String,
pub model: Option<String>,
pub approval_mode: CodexApprovalMode,
pub extra_args: Vec<String>,
}
impl Default for CodexAdapter {
fn default() -> Self {
Self {
executable_path: "codex".to_string(),
model: None,
approval_mode: CodexApprovalMode::FullAuto,
extra_args: Vec::new(),
}
}
}
#[async_trait]
impl AiCliAdapter for CodexAdapter {
fn name(&self) -> &str {
"codex"
}
fn executable(&self) -> &str {
&self.executable_path
}
fn build_args(&self, request: &CodeGenRequest) -> Vec<String> {
let mut args = vec!["exec".to_string()];
match self.approval_mode {
CodexApprovalMode::FullAuto => args.push("--full-auto".to_string()),
CodexApprovalMode::Suggest => args.push("--suggest".to_string()),
}
args.push("--json".to_string());
let model = request.model.as_ref().or(self.model.as_ref());
if let Some(m) = model {
args.push("--model".to_string());
args.push(m.clone());
}
args.extend(self.extra_args.iter().cloned());
args.push(request.prompt.clone());
args
}
fn non_interactive_env(&self) -> HashMap<String, String> {
HashMap::new()
}
fn stdin_strategy(&self) -> StdinStrategy {
StdinStrategy::CloseImmediately
}
fn parse_output(&self, _request: &CodeGenRequest, result: ExecutionResult) -> CodeGenResult {
let mut events: Vec<serde_json::Value> = Vec::new();
let mut files_modified: Vec<PathBuf> = Vec::new();
for line in result.stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Ok(event) = serde_json::from_str::<serde_json::Value>(line) {
if let Some(path) = extract_file_path(&event) {
if !files_modified.contains(&path) {
files_modified.push(path);
}
}
events.push(event);
}
}
let parsed_output = if events.is_empty() {
None
} else {
Some(serde_json::Value::Array(events))
};
let success = result.success;
CodeGenResult {
success,
execution: result,
parsed_output,
files_modified,
adapter_name: self.name().to_string(),
}
}
async fn health_check(&self) -> Result<(), anyhow::Error> {
let output = tokio::process::Command::new(&self.executable_path)
.arg("--version")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.await
.map_err(|e| anyhow::anyhow!("Codex not found at '{}': {}", self.executable_path, e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"Codex health check failed (exit {}): {}",
output.status.code().unwrap_or(-1),
stderr
);
}
Ok(())
}
}
fn extract_file_path(event: &serde_json::Value) -> Option<PathBuf> {
let path_str = event.get("path").and_then(|v| v.as_str())?;
let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
let action = event.get("action").and_then(|v| v.as_str()).unwrap_or("");
if matches!(event_type, "file_write" | "patch" | "write")
|| matches!(action, "write" | "patch" | "create")
{
Some(PathBuf::from(path_str))
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_request() -> CodeGenRequest {
CodeGenRequest {
prompt: "Fix the bug in main.rs".to_string(),
working_dir: PathBuf::from("/tmp/project"),
target_files: vec![PathBuf::from("src/main.rs")],
system_context: None,
model: None,
options: HashMap::new(),
}
}
#[test]
fn test_build_args_basic() {
let adapter = CodexAdapter::default();
let request = sample_request();
let args = adapter.build_args(&request);
assert_eq!(args[0], "exec");
assert_eq!(args[1], "--full-auto");
assert_eq!(args[2], "--json");
assert_eq!(*args.last().unwrap(), "Fix the bug in main.rs");
}
#[test]
fn test_build_args_suggest_mode() {
let adapter = CodexAdapter {
approval_mode: CodexApprovalMode::Suggest,
..Default::default()
};
let request = sample_request();
let args = adapter.build_args(&request);
assert_eq!(args[0], "exec");
assert_eq!(args[1], "--suggest");
assert!(!args.contains(&"--full-auto".to_string()));
}
#[test]
fn test_build_args_model_override_from_request() {
let adapter = CodexAdapter {
model: Some("default-model".to_string()),
..Default::default()
};
let mut request = sample_request();
request.model = Some("request-model".to_string());
let args = adapter.build_args(&request);
let idx = args.iter().position(|a| a == "--model").unwrap();
assert_eq!(args[idx + 1], "request-model");
}
#[test]
fn test_build_args_model_from_adapter() {
let adapter = CodexAdapter {
model: Some("adapter-model".to_string()),
..Default::default()
};
let request = sample_request();
let args = adapter.build_args(&request);
let idx = args.iter().position(|a| a == "--model").unwrap();
assert_eq!(args[idx + 1], "adapter-model");
}
#[test]
fn test_build_args_with_extra_args() {
let adapter = CodexAdapter {
extra_args: vec!["--verbose".to_string()],
..Default::default()
};
let request = sample_request();
let args = adapter.build_args(&request);
assert!(args.contains(&"--verbose".to_string()));
}
#[test]
fn test_parse_output_valid_ndjson() {
let adapter = CodexAdapter::default();
let request = sample_request();
let stdout = [
r#"{"type":"file_write","path":"src/main.rs","content":"fn main() {}"}"#,
r#"{"type":"message","text":"Done!"}"#,
r#"{"type":"patch","path":"src/lib.rs","diff":"..."}"#,
]
.join("\n");
let result = ExecutionResult {
exit_code: 0,
stdout,
stderr: String::new(),
execution_time_ms: 3000,
success: true,
stdout_truncated: false,
stderr_truncated: false,
};
let codegen = adapter.parse_output(&request, result);
assert!(codegen.success);
assert!(codegen.parsed_output.is_some());
let events = codegen.parsed_output.unwrap();
assert_eq!(events.as_array().unwrap().len(), 3);
assert_eq!(codegen.files_modified.len(), 2);
assert_eq!(codegen.files_modified[0], PathBuf::from("src/main.rs"));
assert_eq!(codegen.files_modified[1], PathBuf::from("src/lib.rs"));
assert_eq!(codegen.adapter_name, "codex");
}
#[test]
fn test_parse_output_non_json() {
let adapter = CodexAdapter::default();
let request = sample_request();
let result = ExecutionResult {
exit_code: 0,
stdout: "Not valid JSON output from codex\nJust plain text".to_string(),
stderr: String::new(),
execution_time_ms: 500,
success: true,
stdout_truncated: false,
stderr_truncated: false,
};
let codegen = adapter.parse_output(&request, result);
assert!(codegen.success);
assert!(codegen.parsed_output.is_none());
assert!(codegen.files_modified.is_empty());
}
#[test]
fn test_parse_output_empty() {
let adapter = CodexAdapter::default();
let request = sample_request();
let result = ExecutionResult {
exit_code: 0,
stdout: String::new(),
stderr: String::new(),
execution_time_ms: 100,
success: true,
stdout_truncated: false,
stderr_truncated: false,
};
let codegen = adapter.parse_output(&request, result);
assert!(codegen.success);
assert!(codegen.parsed_output.is_none());
assert!(codegen.files_modified.is_empty());
}
#[test]
fn test_parse_output_failure() {
let adapter = CodexAdapter::default();
let request = sample_request();
let result = ExecutionResult {
exit_code: 1,
stdout: String::new(),
stderr: "Error: something went wrong".to_string(),
execution_time_ms: 200,
success: false,
stdout_truncated: false,
stderr_truncated: false,
};
let codegen = adapter.parse_output(&request, result);
assert!(!codegen.success);
}
#[test]
fn test_parse_output_deduplicates_files() {
let adapter = CodexAdapter::default();
let request = sample_request();
let stdout = [
r#"{"type":"file_write","path":"src/main.rs","content":"v1"}"#,
r#"{"type":"file_write","path":"src/main.rs","content":"v2"}"#,
]
.join("\n");
let result = ExecutionResult {
exit_code: 0,
stdout,
stderr: String::new(),
execution_time_ms: 1000,
success: true,
stdout_truncated: false,
stderr_truncated: false,
};
let codegen = adapter.parse_output(&request, result);
assert_eq!(codegen.files_modified.len(), 1);
}
#[test]
fn test_stdin_strategy_is_close_immediately() {
let adapter = CodexAdapter::default();
assert!(matches!(
adapter.stdin_strategy(),
StdinStrategy::CloseImmediately
));
}
#[test]
fn test_non_interactive_env() {
let adapter = CodexAdapter::default();
let env = adapter.non_interactive_env();
assert!(env.is_empty());
}
#[tokio::test]
#[ignore] async fn test_health_check() {
let adapter = CodexAdapter::default();
let result = adapter.health_check().await;
let _ = result;
}
}