1use std::collections::BTreeMap;
42use std::path::{Path, PathBuf};
43
44use crate::error::{Error, Result};
45
46#[derive(Debug, Clone, PartialEq)]
48#[non_exhaustive]
49pub struct CodexConfig {
50 pub path: PathBuf,
52
53 pub model: Option<String>,
55 pub approval_policy: Option<String>,
57 pub sandbox_mode: Option<String>,
59 pub web_search: Option<String>,
61
62 pub features: BTreeMap<String, bool>,
64
65 pub project_trust: BTreeMap<String, String>,
70
71 pub profiles: Vec<String>,
73
74 pub legacy_profiles: Vec<String>,
79
80 pub raw: toml::Table,
82}
83
84pub fn load() -> Result<Option<CodexConfig>> {
89 let home = crate::codex_home::resolve(&|key| std::env::var(key).ok());
90 load_from_home(home)
91}
92
93pub fn load_from_home(codex_home: impl AsRef<Path>) -> Result<Option<CodexConfig>> {
95 let home = codex_home.as_ref();
96 let path = home.join("config.toml");
97
98 let contents = match std::fs::read_to_string(&path) {
99 Ok(contents) => contents,
100 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
101 Err(e) => {
102 return Err(Error::Io {
103 message: format!("failed to read {}: {e}", path.display()),
104 source: e,
105 working_dir: Some(home.to_path_buf()),
106 });
107 }
108 };
109
110 let raw: toml::Table = contents
111 .parse::<toml::Table>()
112 .map_err(|e| Error::ConfigParse {
113 path: path.clone(),
114 message: e.to_string(),
115 })?;
116
117 Ok(Some(CodexConfig {
118 model: string_at(&raw, "model"),
119 approval_policy: string_at(&raw, "approval_policy"),
120 sandbox_mode: string_at(&raw, "sandbox_mode"),
121 web_search: string_at(&raw, "web_search"),
122 features: bool_table(&raw, "features"),
123 project_trust: project_trust(&raw),
124 profiles: profile_files(home),
125 legacy_profiles: table_keys(&raw, "profiles"),
126 raw,
127 path,
128 }))
129}
130
131fn string_at(table: &toml::Table, key: &str) -> Option<String> {
132 table.get(key)?.as_str().map(str::to_string)
133}
134
135fn bool_table(table: &toml::Table, key: &str) -> BTreeMap<String, bool> {
136 table
137 .get(key)
138 .and_then(toml::Value::as_table)
139 .map(|features| {
140 features
141 .iter()
142 .filter_map(|(name, value)| Some((name.clone(), value.as_bool()?)))
143 .collect()
144 })
145 .unwrap_or_default()
146}
147
148fn table_keys(table: &toml::Table, key: &str) -> Vec<String> {
149 table
150 .get(key)
151 .and_then(toml::Value::as_table)
152 .map(|inner| inner.keys().cloned().collect())
153 .unwrap_or_default()
154}
155
156fn project_trust(table: &toml::Table) -> BTreeMap<String, String> {
158 table
159 .get("projects")
160 .and_then(toml::Value::as_table)
161 .map(|projects| {
162 projects
163 .iter()
164 .filter_map(|(path, value)| {
165 let level = value.as_table()?.get("trust_level")?.as_str()?;
166 Some((path.clone(), level.to_string()))
167 })
168 .collect()
169 })
170 .unwrap_or_default()
171}
172
173fn profile_files(home: &Path) -> Vec<String> {
178 let Ok(entries) = std::fs::read_dir(home) else {
179 return Vec::new();
180 };
181 let mut names: Vec<String> = entries
182 .filter_map(std::result::Result::ok)
183 .filter_map(|entry| {
184 let name = entry.file_name().into_string().ok()?;
185 let stem = name.strip_suffix(".config.toml")?;
187 (!stem.is_empty()).then(|| stem.to_string())
188 })
189 .collect();
190 names.sort();
191 names
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 fn temp_home(label: &str) -> PathBuf {
199 let dir = std::env::temp_dir().join(format!(
200 "codex-wrapper-config-{}-{label}",
201 std::process::id()
202 ));
203 let _ = std::fs::remove_dir_all(&dir);
204 std::fs::create_dir_all(&dir).unwrap();
205 dir
206 }
207
208 fn write(home: &Path, name: &str, contents: &str) {
209 std::fs::write(home.join(name), contents).unwrap();
210 }
211
212 #[test]
213 fn a_missing_config_is_not_an_error() {
214 let home = temp_home("missing");
215 assert_eq!(load_from_home(&home).unwrap(), None);
216 }
217
218 #[test]
220 fn reads_the_typed_keys() {
221 let home = temp_home("typed");
222 write(
223 &home,
224 "config.toml",
225 r#"
226model = "gpt-5.6-sol"
227model_reasoning_effort = "high"
228approval_policy = "on-request"
229sandbox_mode = "workspace-write"
230web_search = "live"
231
232[features]
233web-search = true
234disabled-thing = false
235
236[projects."/Users/someone/a-repo"]
237trust_level = "trusted"
238"#,
239 );
240
241 let config = load_from_home(&home).unwrap().unwrap();
242 assert_eq!(config.model.as_deref(), Some("gpt-5.6-sol"));
243 assert_eq!(config.approval_policy.as_deref(), Some("on-request"));
244 assert_eq!(config.sandbox_mode.as_deref(), Some("workspace-write"));
245 assert_eq!(config.web_search.as_deref(), Some("live"));
246 assert_eq!(config.features.get("web-search"), Some(&true));
247 assert_eq!(config.features.get("disabled-thing"), Some(&false));
248 assert_eq!(
249 config
250 .project_trust
251 .get("/Users/someone/a-repo")
252 .map(String::as_str),
253 Some("trusted")
254 );
255 }
256
257 #[test]
260 fn untyped_keys_stay_in_raw() {
261 let home = temp_home("raw");
262 write(
263 &home,
264 "config.toml",
265 "model = \"m\"\npersonality = \"terse\"\nservice_tier = \"priority\"\n",
266 );
267
268 let config = load_from_home(&home).unwrap().unwrap();
269 assert_eq!(
270 config.raw.get("personality").and_then(toml::Value::as_str),
271 Some("terse")
272 );
273 assert!(config.raw.contains_key("model"));
275 }
276
277 #[test]
280 fn profiles_come_from_the_files_beside_the_config() {
281 let home = temp_home("profiles");
282 write(&home, "config.toml", "model = \"base\"\n");
283 write(&home, "work.config.toml", "model = \"work-model\"\n");
284 write(
285 &home,
286 "personal.config.toml",
287 "model = \"personal-model\"\n",
288 );
289 write(&home, "notes.toml", "x = 1\n");
291
292 let config = load_from_home(&home).unwrap().unwrap();
293 assert_eq!(config.profiles, vec!["personal", "work"]);
294 assert!(config.legacy_profiles.is_empty());
295 }
296
297 #[test]
300 fn a_legacy_profiles_table_is_reported_separately() {
301 let home = temp_home("legacy");
302 write(
303 &home,
304 "config.toml",
305 "[profiles.old]\nmodel = \"legacy-model\"\n",
306 );
307
308 let config = load_from_home(&home).unwrap().unwrap();
309 assert_eq!(config.legacy_profiles, vec!["old"]);
310 assert!(
311 config.profiles.is_empty(),
312 "a legacy table is not a usable profile"
313 );
314 }
315
316 #[test]
317 fn a_malformed_config_is_an_error_not_a_silent_default() {
318 let home = temp_home("malformed");
319 write(&home, "config.toml", "this is not = = toml");
320
321 let err = load_from_home(&home).unwrap_err();
322 assert!(
323 matches!(err, Error::ConfigParse { .. }),
324 "expected a parse error, got: {err:?}"
325 );
326 assert_eq!(err.failure_kind(), None);
328 assert_eq!(err.exit_code(), None);
329 }
330}