use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct CodexConfig {
pub path: PathBuf,
pub model: Option<String>,
pub approval_policy: Option<String>,
pub sandbox_mode: Option<String>,
pub web_search: Option<String>,
pub features: BTreeMap<String, bool>,
pub project_trust: BTreeMap<String, String>,
pub profiles: Vec<String>,
pub legacy_profiles: Vec<String>,
pub raw: toml::Table,
}
pub fn load() -> Result<Option<CodexConfig>> {
let home = crate::codex_home::resolve(&|key| std::env::var(key).ok());
load_from_home(home)
}
pub fn load_from_home(codex_home: impl AsRef<Path>) -> Result<Option<CodexConfig>> {
let home = codex_home.as_ref();
let path = home.join("config.toml");
let contents = match std::fs::read_to_string(&path) {
Ok(contents) => contents,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(Error::Io {
message: format!("failed to read {}: {e}", path.display()),
source: e,
working_dir: Some(home.to_path_buf()),
});
}
};
let raw: toml::Table = contents
.parse::<toml::Table>()
.map_err(|e| Error::ConfigParse {
path: path.clone(),
message: e.to_string(),
})?;
Ok(Some(CodexConfig {
model: string_at(&raw, "model"),
approval_policy: string_at(&raw, "approval_policy"),
sandbox_mode: string_at(&raw, "sandbox_mode"),
web_search: string_at(&raw, "web_search"),
features: bool_table(&raw, "features"),
project_trust: project_trust(&raw),
profiles: profile_files(home),
legacy_profiles: table_keys(&raw, "profiles"),
raw,
path,
}))
}
fn string_at(table: &toml::Table, key: &str) -> Option<String> {
table.get(key)?.as_str().map(str::to_string)
}
fn bool_table(table: &toml::Table, key: &str) -> BTreeMap<String, bool> {
table
.get(key)
.and_then(toml::Value::as_table)
.map(|features| {
features
.iter()
.filter_map(|(name, value)| Some((name.clone(), value.as_bool()?)))
.collect()
})
.unwrap_or_default()
}
fn table_keys(table: &toml::Table, key: &str) -> Vec<String> {
table
.get(key)
.and_then(toml::Value::as_table)
.map(|inner| inner.keys().cloned().collect())
.unwrap_or_default()
}
fn project_trust(table: &toml::Table) -> BTreeMap<String, String> {
table
.get("projects")
.and_then(toml::Value::as_table)
.map(|projects| {
projects
.iter()
.filter_map(|(path, value)| {
let level = value.as_table()?.get("trust_level")?.as_str()?;
Some((path.clone(), level.to_string()))
})
.collect()
})
.unwrap_or_default()
}
fn profile_files(home: &Path) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(home) else {
return Vec::new();
};
let mut names: Vec<String> = entries
.filter_map(std::result::Result::ok)
.filter_map(|entry| {
let name = entry.file_name().into_string().ok()?;
let stem = name.strip_suffix(".config.toml")?;
(!stem.is_empty()).then(|| stem.to_string())
})
.collect();
names.sort();
names
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_home(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"codex-wrapper-config-{}-{label}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn write(home: &Path, name: &str, contents: &str) {
std::fs::write(home.join(name), contents).unwrap();
}
#[test]
fn a_missing_config_is_not_an_error() {
let home = temp_home("missing");
assert_eq!(load_from_home(&home).unwrap(), None);
}
#[test]
fn reads_the_typed_keys() {
let home = temp_home("typed");
write(
&home,
"config.toml",
r#"
model = "gpt-5.6-sol"
model_reasoning_effort = "high"
approval_policy = "on-request"
sandbox_mode = "workspace-write"
web_search = "live"
[features]
web-search = true
disabled-thing = false
[projects."/Users/someone/a-repo"]
trust_level = "trusted"
"#,
);
let config = load_from_home(&home).unwrap().unwrap();
assert_eq!(config.model.as_deref(), Some("gpt-5.6-sol"));
assert_eq!(config.approval_policy.as_deref(), Some("on-request"));
assert_eq!(config.sandbox_mode.as_deref(), Some("workspace-write"));
assert_eq!(config.web_search.as_deref(), Some("live"));
assert_eq!(config.features.get("web-search"), Some(&true));
assert_eq!(config.features.get("disabled-thing"), Some(&false));
assert_eq!(
config
.project_trust
.get("/Users/someone/a-repo")
.map(String::as_str),
Some("trusted")
);
}
#[test]
fn untyped_keys_stay_in_raw() {
let home = temp_home("raw");
write(
&home,
"config.toml",
"model = \"m\"\npersonality = \"terse\"\nservice_tier = \"priority\"\n",
);
let config = load_from_home(&home).unwrap().unwrap();
assert_eq!(
config.raw.get("personality").and_then(toml::Value::as_str),
Some("terse")
);
assert!(config.raw.contains_key("model"));
}
#[test]
fn profiles_come_from_the_files_beside_the_config() {
let home = temp_home("profiles");
write(&home, "config.toml", "model = \"base\"\n");
write(&home, "work.config.toml", "model = \"work-model\"\n");
write(
&home,
"personal.config.toml",
"model = \"personal-model\"\n",
);
write(&home, "notes.toml", "x = 1\n");
let config = load_from_home(&home).unwrap().unwrap();
assert_eq!(config.profiles, vec!["personal", "work"]);
assert!(config.legacy_profiles.is_empty());
}
#[test]
fn a_legacy_profiles_table_is_reported_separately() {
let home = temp_home("legacy");
write(
&home,
"config.toml",
"[profiles.old]\nmodel = \"legacy-model\"\n",
);
let config = load_from_home(&home).unwrap().unwrap();
assert_eq!(config.legacy_profiles, vec!["old"]);
assert!(
config.profiles.is_empty(),
"a legacy table is not a usable profile"
);
}
#[test]
fn a_malformed_config_is_an_error_not_a_silent_default() {
let home = temp_home("malformed");
write(&home, "config.toml", "this is not = = toml");
let err = load_from_home(&home).unwrap_err();
assert!(
matches!(err, Error::ConfigParse { .. }),
"expected a parse error, got: {err:?}"
);
assert_eq!(err.failure_kind(), None);
assert_eq!(err.exit_code(), None);
}
}