use serde::{Deserialize, Serialize};
pub const VALID_APPROVAL_MODES: &[&str] = &["suggest", "auto-edit", "full-auto"];
pub const VALID_FULL_AUTO_ERROR_MODES: &[&str] = &["ask-user", "ignore-and-continue"];
pub const VALID_SANDBOX_WORKSPACE_WRITE_MODES: &[&str] = &["allowlist", "denylist", "all"];
pub const VALID_MODEL_REASONING_SUMMARIES: &[&str] =
&["auto", "always", "none", "concise", "detailed"];
pub const VALID_MCP_OAUTH_STORES: &[&str] = &["file", "keyring", "auto", "ephemeral"];
pub const AGENTS_MD_MAX_SIZE: usize = 100_000;
pub const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
"model",
"personality",
"review_model",
"model_provider",
"oss_provider",
"model_context_window",
"model_auto_compact_token_limit",
"tool_output_token_limit",
"log_dir",
"model_reasoning_effort",
"model_reasoning_summary",
"model_verbosity",
"model_supports_reasoning_summaries",
"developer_instructions",
"instructions",
"compact_prompt",
"model_instructions_file",
"experimental_compact_prompt_file",
"include_apply_patch_tool",
"notify",
"approval_policy",
"sandbox_mode",
"cli_auth_credentials_store",
"chatgpt_base_url",
"forced_chatgpt_workspace_id",
"forced_login_method",
"mcp_oauth_credentials_store",
"mcp_oauth_callback_port",
"project_doc_max_bytes",
"project_doc_fallback_filenames",
"project_root_markers",
"file_opener",
"hide_agent_reasoning",
"show_raw_agent_reasoning",
"disable_paste_burst",
"windows_wsl_setup_acknowledged",
"check_for_update_on_startup",
"web_search",
"profile",
"experimental_use_unified_exec_tool",
"experimental_use_freeform_apply_patch",
"approvalMode",
"fullAutoErrorMode",
];
pub const KNOWN_TABLE_KEYS: &[&str] = &[
"sandbox_workspace_write",
"shell_environment_policy",
"history",
"tui",
"features",
"mcp_servers",
"model_providers",
"profiles",
"projects",
"otel",
"skills",
"feedback",
"notice",
];
#[derive(Debug, Clone)]
pub struct UnknownKey {
pub key: String,
pub line: usize,
pub column: usize,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CodexConfigSchema {
#[serde(default, rename = "approvalMode")]
pub approval_mode: Option<String>,
#[serde(default, rename = "fullAutoErrorMode")]
pub full_auto_error_mode: Option<String>,
#[serde(default)]
pub project_doc_max_bytes: Option<i64>,
#[serde(default)]
pub project_doc_fallback_filenames: Option<Vec<String>>,
}
#[derive(Debug, Clone)]
pub struct ParsedCodexConfig {
pub schema: Option<CodexConfigSchema>,
pub parse_error: Option<ParseError>,
pub approval_mode_wrong_type: bool,
pub full_auto_error_mode_wrong_type: bool,
pub project_doc_max_bytes_wrong_type: bool,
pub project_doc_fallback_filenames_wrong_type: bool,
pub project_doc_fallback_filename_non_string_indices: Vec<usize>,
pub project_doc_fallback_filename_empty_indices: Vec<usize>,
pub unknown_keys: Vec<UnknownKey>,
}
#[derive(Debug, Clone)]
pub struct ParseError {
pub message: String,
pub line: usize,
pub column: usize,
}
pub fn parse_codex_toml(content: &str) -> ParsedCodexConfig {
let parsed_table: toml::Table = match toml::from_str::<toml::Table>(content) {
Ok(v) => v,
Err(e) => {
let (line, column) = e
.span()
.map(|span| {
let mut l = 1usize;
let mut c = 1usize;
for (i, ch) in content.char_indices() {
if i >= span.start {
break;
}
if ch == '\n' {
l += 1;
c = 1;
} else {
c += 1;
}
}
(l, c)
})
.unwrap_or((1, 0));
return ParsedCodexConfig {
schema: None,
parse_error: Some(ParseError {
message: e.message().to_string(),
line,
column,
}),
approval_mode_wrong_type: false,
full_auto_error_mode_wrong_type: false,
project_doc_max_bytes_wrong_type: false,
project_doc_fallback_filenames_wrong_type: false,
project_doc_fallback_filename_non_string_indices: Vec::new(),
project_doc_fallback_filename_empty_indices: Vec::new(),
unknown_keys: Vec::new(),
};
}
};
let table = Some(&parsed_table);
let approval_mode_value = table.and_then(|t| t.get("approvalMode"));
let approval_mode_wrong_type = approval_mode_value.is_some_and(|v| !v.is_str());
let approval_mode = approval_mode_value
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let full_auto_error_mode_value = table.and_then(|t| t.get("fullAutoErrorMode"));
let full_auto_error_mode_wrong_type = full_auto_error_mode_value.is_some_and(|v| !v.is_str());
let full_auto_error_mode = full_auto_error_mode_value
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let project_doc_max_bytes_value = table.and_then(|t| t.get("project_doc_max_bytes"));
let project_doc_max_bytes_wrong_type =
project_doc_max_bytes_value.is_some_and(|v| !v.is_integer());
let project_doc_max_bytes = project_doc_max_bytes_value.and_then(|v| v.as_integer());
let project_doc_fallback_filenames_value =
table.and_then(|t| t.get("project_doc_fallback_filenames"));
let project_doc_fallback_filenames_wrong_type =
project_doc_fallback_filenames_value.is_some_and(|v| !v.is_array());
let (
project_doc_fallback_filenames,
project_doc_fallback_filename_non_string_indices,
project_doc_fallback_filename_empty_indices,
) = if let Some(values) = project_doc_fallback_filenames_value.and_then(|v| v.as_array()) {
let mut filenames = Vec::new();
let mut non_string_indices = Vec::new();
let mut empty_indices = Vec::new();
for (idx, value) in values.iter().enumerate() {
if let Some(filename) = value.as_str() {
if filename.trim().is_empty() {
empty_indices.push(idx);
}
filenames.push(filename.to_string());
} else {
non_string_indices.push(idx);
}
}
(Some(filenames), non_string_indices, empty_indices)
} else {
(None, Vec::new(), Vec::new())
};
let unknown_keys = detect_unknown_keys(table, content);
ParsedCodexConfig {
schema: Some(CodexConfigSchema {
approval_mode,
full_auto_error_mode,
project_doc_max_bytes,
project_doc_fallback_filenames,
}),
parse_error: None,
approval_mode_wrong_type,
full_auto_error_mode_wrong_type,
project_doc_max_bytes_wrong_type,
project_doc_fallback_filenames_wrong_type,
project_doc_fallback_filename_non_string_indices,
project_doc_fallback_filename_empty_indices,
unknown_keys,
}
}
fn detect_unknown_keys(
table: Option<&toml::map::Map<String, toml::Value>>,
content: &str,
) -> Vec<UnknownKey> {
let Some(table) = table else {
return Vec::new();
};
let mut unknown = Vec::new();
for key in table.keys() {
if !KNOWN_TOP_LEVEL_KEYS.contains(&key.as_str())
&& !KNOWN_TABLE_KEYS.contains(&key.as_str())
{
unknown.push(UnknownKey {
key: key.clone(),
line: find_toml_key_line(content, key).unwrap_or(1),
column: 0,
});
}
}
unknown
}
fn find_toml_key_line(content: &str, key: &str) -> Option<usize> {
for (i, line) in content.lines().enumerate() {
let trimmed = line.trim_start();
if trimmed.starts_with('[') {
continue;
}
if let Some(after) = trimmed.strip_prefix(key) {
if after.trim_start().starts_with('=') {
return Some(i + 1);
}
}
let quoted = format!("\"{}\"", key);
if let Some(after) = trimmed.strip_prefix("ed) {
if after.trim_start().starts_with('=') {
return Some(i + 1);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_valid_config() {
let content = r#"
model = "o4-mini"
approvalMode = "suggest"
fullAutoErrorMode = "ask-user"
notify = true
"#;
let result = parse_codex_toml(content);
assert!(result.schema.is_some());
assert!(result.parse_error.is_none());
let schema = result.schema.unwrap();
assert_eq!(schema.approval_mode, Some("suggest".to_string()));
assert_eq!(schema.full_auto_error_mode, Some("ask-user".to_string()));
}
#[test]
fn test_parse_minimal_config() {
let content = "";
let result = parse_codex_toml(content);
assert!(result.schema.is_some());
assert!(result.parse_error.is_none());
let schema = result.schema.unwrap();
assert!(schema.approval_mode.is_none());
assert!(schema.full_auto_error_mode.is_none());
assert!(schema.project_doc_fallback_filenames.is_none());
}
#[test]
fn test_parse_invalid_toml() {
let content = "invalid = [unclosed";
let result = parse_codex_toml(content);
assert!(result.schema.is_none());
assert!(result.parse_error.is_some());
}
#[test]
fn test_valid_approval_modes() {
for mode in VALID_APPROVAL_MODES {
let content = format!("approvalMode = \"{}\"", mode);
let result = parse_codex_toml(&content);
assert!(result.schema.is_some());
assert_eq!(result.schema.unwrap().approval_mode, Some(mode.to_string()));
}
}
#[test]
fn test_valid_full_auto_error_modes() {
for mode in VALID_FULL_AUTO_ERROR_MODES {
let content = format!("fullAutoErrorMode = \"{}\"", mode);
let result = parse_codex_toml(&content);
assert!(result.schema.is_some());
assert_eq!(
result.schema.unwrap().full_auto_error_mode,
Some(mode.to_string())
);
}
}
#[test]
fn test_parse_extra_fields_ignored() {
let content = r#"
model = "o4-mini"
approvalMode = "suggest"
fullAutoErrorMode = "ask-user"
notify = true
provider = "openai"
"#;
let result = parse_codex_toml(content);
assert!(result.schema.is_some());
assert!(result.parse_error.is_none());
}
#[test]
fn test_approval_mode_wrong_type() {
let content = "approvalMode = true";
let result = parse_codex_toml(content);
assert!(result.approval_mode_wrong_type);
assert!(!result.full_auto_error_mode_wrong_type);
assert!(result.schema.is_some());
assert!(result.schema.unwrap().approval_mode.is_none());
}
#[test]
fn test_full_auto_error_mode_wrong_type() {
let content = "fullAutoErrorMode = 123";
let result = parse_codex_toml(content);
assert!(!result.approval_mode_wrong_type);
assert!(result.full_auto_error_mode_wrong_type);
assert!(result.schema.is_some());
assert!(result.schema.unwrap().full_auto_error_mode.is_none());
}
#[test]
fn test_parse_error_location() {
let content = "approvalMode = [unclosed";
let result = parse_codex_toml(content);
assert!(result.parse_error.is_some());
let err = result.parse_error.unwrap();
assert!(err.line > 0);
}
#[test]
fn test_parse_error_fallback_line() {
let content = "= value_without_key";
let result = parse_codex_toml(content);
assert!(result.parse_error.is_some());
let err = result.parse_error.unwrap();
assert!(
err.line >= 1,
"Parse error line should be at least 1 (fallback or span-derived)"
);
}
#[test]
fn test_unknown_keys_detected() {
let content = "completely_unknown_key = true\nmodel = \"o4-mini\"";
let result = parse_codex_toml(content);
assert!(result.parse_error.is_none());
assert_eq!(result.unknown_keys.len(), 1);
assert_eq!(result.unknown_keys[0].key, "completely_unknown_key");
assert_eq!(result.unknown_keys[0].line, 1);
}
#[test]
fn test_known_keys_not_flagged() {
let content = r#"
model = "o4-mini"
approvalMode = "suggest"
fullAutoErrorMode = "ask-user"
notify = true
project_doc_max_bytes = 32768
project_doc_fallback_filenames = ["AGENTS.md", "README.md"]
"#;
let result = parse_codex_toml(content);
assert!(result.unknown_keys.is_empty(), "All keys are known");
}
#[test]
fn test_known_table_keys_not_flagged() {
let content = r#"
model = "o4-mini"
[mcp_servers]
name = "test"
"#;
let result = parse_codex_toml(content);
assert!(
result.unknown_keys.is_empty(),
"Known table keys should not be flagged"
);
}
#[test]
fn test_unknown_keys_empty_on_parse_error() {
let content = "invalid = [unclosed";
let result = parse_codex_toml(content);
assert!(result.parse_error.is_some());
assert!(result.unknown_keys.is_empty());
}
#[test]
fn test_project_doc_max_bytes_parsed() {
let content = "project_doc_max_bytes = 32768";
let result = parse_codex_toml(content);
assert!(result.schema.is_some());
assert_eq!(result.schema.unwrap().project_doc_max_bytes, Some(32768));
assert!(!result.project_doc_max_bytes_wrong_type);
}
#[test]
fn test_project_doc_max_bytes_wrong_type() {
let content = "project_doc_max_bytes = \"not a number\"";
let result = parse_codex_toml(content);
assert!(result.project_doc_max_bytes_wrong_type);
}
#[test]
fn test_project_doc_max_bytes_absent() {
let content = "model = \"o4-mini\"";
let result = parse_codex_toml(content);
assert!(result.schema.is_some());
assert!(result.schema.unwrap().project_doc_max_bytes.is_none());
assert!(!result.project_doc_max_bytes_wrong_type);
}
#[test]
fn test_project_doc_fallback_filenames_parsed() {
let content = "project_doc_fallback_filenames = [\"AGENTS.md\", \"README.md\"]";
let result = parse_codex_toml(content);
assert!(result.schema.is_some());
assert_eq!(
result.schema.unwrap().project_doc_fallback_filenames,
Some(vec!["AGENTS.md".to_string(), "README.md".to_string()])
);
assert!(!result.project_doc_fallback_filenames_wrong_type);
assert!(
result
.project_doc_fallback_filename_non_string_indices
.is_empty()
);
assert!(
result
.project_doc_fallback_filename_empty_indices
.is_empty()
);
}
#[test]
fn test_project_doc_fallback_filenames_wrong_type() {
let content = "project_doc_fallback_filenames = \"AGENTS.md\"";
let result = parse_codex_toml(content);
assert!(result.project_doc_fallback_filenames_wrong_type);
assert!(
result
.project_doc_fallback_filename_non_string_indices
.is_empty()
);
assert!(
result
.project_doc_fallback_filename_empty_indices
.is_empty()
);
}
#[test]
fn test_project_doc_fallback_filenames_non_string_items() {
let content = "project_doc_fallback_filenames = [\"AGENTS.md\", 42, true]";
let result = parse_codex_toml(content);
assert!(!result.project_doc_fallback_filenames_wrong_type);
assert_eq!(
result.project_doc_fallback_filename_non_string_indices,
vec![1, 2]
);
}
#[test]
fn test_project_doc_fallback_filenames_empty_items() {
let content = "project_doc_fallback_filenames = [\"\", \" \", \"AGENTS.md\"]";
let result = parse_codex_toml(content);
assert!(!result.project_doc_fallback_filenames_wrong_type);
assert_eq!(
result.project_doc_fallback_filename_empty_indices,
vec![0, 1]
);
}
#[test]
fn test_find_toml_key_line_basic() {
let content = "model = \"o4-mini\"\nunknown_key = true";
assert_eq!(find_toml_key_line(content, "model"), Some(1));
assert_eq!(find_toml_key_line(content, "unknown_key"), Some(2));
assert_eq!(find_toml_key_line(content, "nonexistent"), None);
}
#[test]
fn test_find_toml_key_line_skips_table_headers() {
let content = "[mcp_servers]\nname = \"test\"";
assert_eq!(find_toml_key_line(content, "name"), Some(2));
}
}