1use std::path::{Path, PathBuf};
2
3pub const LEAN_CTX_CODEX_PROFILE_ENV: &str = "LEAN_CTX_CODEX_PROFILE";
6pub const CODEX_PROFILE_ENV: &str = "CODEX_PROFILE";
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct CodexConfigPaths {
11 base: PathBuf,
12 profile_name: Option<String>,
13 profile: Option<PathBuf>,
14}
15
16impl CodexConfigPaths {
17 pub fn base(&self) -> &Path {
18 &self.base
19 }
20
21 pub fn profile_name(&self) -> Option<&str> {
22 self.profile_name.as_deref()
23 }
24
25 pub fn profile(&self) -> Option<&Path> {
26 self.profile.as_deref()
27 }
28
29 pub fn effective(&self) -> &Path {
31 self.profile().unwrap_or_else(|| self.base())
32 }
33
34 pub fn layers(&self) -> impl Iterator<Item = &Path> {
36 std::iter::once(self.base()).chain(self.profile())
37 }
38}
39
40pub fn resolve_home_dir() -> Option<PathBuf> {
44 if let Ok(home) = std::env::var("HOME") {
45 let trimmed = home.trim();
46 if !trimmed.is_empty() {
47 return Some(PathBuf::from(trimmed));
48 }
49 }
50
51 #[cfg(windows)]
52 {
53 if let Ok(profile) = std::env::var("USERPROFILE") {
54 let trimmed = profile.trim();
55 if !trimmed.is_empty() {
56 return Some(PathBuf::from(trimmed));
57 }
58 }
59
60 if let (Ok(drive), Ok(path)) = (std::env::var("HOMEDRIVE"), std::env::var("HOMEPATH")) {
61 if !drive.trim().is_empty() && !path.trim().is_empty() {
62 return Some(PathBuf::from(format!("{}{}", drive.trim(), path.trim())));
63 }
64 }
65 }
66
67 dirs::home_dir()
68}
69
70pub fn resolve_codex_dir() -> Option<PathBuf> {
74 if let Ok(val) = std::env::var("CODEX_HOME") {
75 let trimmed = val.trim();
76 if !trimmed.is_empty() {
77 return Some(PathBuf::from(trimmed));
78 }
79 }
80 resolve_home_dir().map(|h| h.join(".codex"))
81}
82
83fn env_codex_profile() -> Option<String> {
85 [LEAN_CTX_CODEX_PROFILE_ENV, CODEX_PROFILE_ENV]
86 .into_iter()
87 .find_map(|key| {
88 std::env::var(key)
89 .ok()
90 .map(|value| value.trim().to_string())
91 .filter(|value| !value.is_empty())
92 .filter(|value| valid_codex_profile_name(value))
93 })
94}
95
96fn valid_codex_profile_name(name: &str) -> bool {
97 !name.is_empty()
98 && name != "."
99 && name != ".."
100 && name
101 .chars()
102 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
103}
104
105fn sole_codex_profile(codex_dir: &Path) -> Option<String> {
108 let mut profiles = std::fs::read_dir(codex_dir)
109 .ok()?
110 .filter_map(Result::ok)
111 .filter_map(|entry| {
112 let path = entry.path();
113 let name = path.file_name()?.to_str()?;
114 name.strip_suffix(".config.toml").map(str::to_string)
115 })
116 .filter(|stem| stem != "config" && valid_codex_profile_name(stem));
117 let profile = profiles.next()?;
118 profiles.next().is_none().then_some(profile)
119}
120
121fn codex_config_paths_at(codex_dir: &Path, profile_name: Option<String>) -> CodexConfigPaths {
122 let base = codex_dir.join("config.toml");
123 let profile = profile_name
124 .as_deref()
125 .map(|name| codex_dir.join(format!("{name}.config.toml")));
126 CodexConfigPaths {
127 base,
128 profile_name,
129 profile,
130 }
131}
132
133pub fn resolve_codex_config_paths() -> Option<CodexConfigPaths> {
135 let codex_dir = resolve_codex_dir()?;
136 let profile_name = env_codex_profile().or_else(|| sole_codex_profile(&codex_dir));
137 Some(codex_config_paths_at(&codex_dir, profile_name))
138}
139
140pub fn resolve_codex_config_path() -> Option<PathBuf> {
142 resolve_codex_config_paths().map(|paths| paths.effective().to_path_buf())
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 #[test]
150 fn resolve_codex_dir_respects_env_var() {
151 let _env_lock = crate::core::data_dir::test_env_lock();
152 let _guard = env_lock();
153 crate::test_env::set_var("CODEX_HOME", "/tmp/custom-codex");
154 crate::test_env::remove_var(LEAN_CTX_CODEX_PROFILE_ENV);
155 crate::test_env::remove_var(CODEX_PROFILE_ENV);
156 let result = resolve_codex_dir();
157 assert_eq!(result, Some(PathBuf::from("/tmp/custom-codex")));
158 crate::test_env::remove_var("CODEX_HOME");
159 }
160
161 #[test]
162 fn resolve_codex_dir_ignores_empty_env() {
163 let _env_lock = crate::core::data_dir::test_env_lock();
164 let _guard = env_lock();
165 crate::test_env::set_var("CODEX_HOME", " ");
166 crate::test_env::remove_var(LEAN_CTX_CODEX_PROFILE_ENV);
167 crate::test_env::remove_var(CODEX_PROFILE_ENV);
168 let result = resolve_codex_dir();
169 assert!(result.is_some());
170 assert!(result.unwrap().ends_with(".codex"));
171 crate::test_env::remove_var("CODEX_HOME");
172 }
173
174 #[test]
175 fn resolve_codex_dir_falls_back_to_home() {
176 let _env_lock = crate::core::data_dir::test_env_lock();
177 let _guard = env_lock();
178 crate::test_env::remove_var("CODEX_HOME");
179 crate::test_env::remove_var(LEAN_CTX_CODEX_PROFILE_ENV);
180 crate::test_env::remove_var(CODEX_PROFILE_ENV);
181 let result = resolve_codex_dir();
182 assert!(result.is_some());
183 assert!(result.unwrap().ends_with(".codex"));
184 }
185
186 #[test]
187 fn explicit_profile_selects_overlay_for_writes_and_layers() {
188 let _env_lock = crate::core::data_dir::test_env_lock();
189 let _guard = env_lock();
190 let dir = tempfile::tempdir().unwrap();
191 crate::test_env::set_var("CODEX_HOME", dir.path());
192 crate::test_env::set_var(LEAN_CTX_CODEX_PROFILE_ENV, "cat");
193 crate::test_env::remove_var(CODEX_PROFILE_ENV);
194
195 let paths = resolve_codex_config_paths().unwrap();
196 assert_eq!(paths.profile_name(), Some("cat"));
197 assert_eq!(paths.effective(), dir.path().join("cat.config.toml"));
198 assert_eq!(paths.layers().count(), 2);
199 assert_eq!(paths.base(), &dir.path().join("config.toml"));
200
201 crate::test_env::remove_var("CODEX_HOME");
202 crate::test_env::remove_var(LEAN_CTX_CODEX_PROFILE_ENV);
203 }
204
205 #[test]
206 fn legacy_profile_env_selects_overlay() {
207 let _env_lock = crate::core::data_dir::test_env_lock();
208 let _guard = env_lock();
209 let dir = tempfile::tempdir().unwrap();
210 crate::test_env::set_var("CODEX_HOME", dir.path());
211 crate::test_env::remove_var(LEAN_CTX_CODEX_PROFILE_ENV);
212 crate::test_env::set_var(CODEX_PROFILE_ENV, "work");
213
214 let paths = resolve_codex_config_paths().unwrap();
215 assert_eq!(paths.effective(), dir.path().join("work.config.toml"));
216
217 crate::test_env::remove_var("CODEX_HOME");
218 crate::test_env::remove_var(CODEX_PROFILE_ENV);
219 }
220
221 #[test]
222 fn sole_overlay_is_inferred_but_ambiguous_overlays_are_not() {
223 let _env_lock = crate::core::data_dir::test_env_lock();
224 let _guard = env_lock();
225 let dir = tempfile::tempdir().unwrap();
226 std::fs::write(dir.path().join("cat.config.toml"), "").unwrap();
227 crate::test_env::set_var("CODEX_HOME", dir.path());
228 crate::test_env::remove_var(LEAN_CTX_CODEX_PROFILE_ENV);
229 crate::test_env::remove_var(CODEX_PROFILE_ENV);
230 assert_eq!(
231 resolve_codex_config_paths().unwrap().profile_name(),
232 Some("cat")
233 );
234
235 std::fs::write(dir.path().join("work.config.toml"), "").unwrap();
236 assert_eq!(resolve_codex_config_paths().unwrap().profile_name(), None);
237
238 crate::test_env::remove_var("CODEX_HOME");
239 }
240
241 #[test]
242 fn invalid_profile_names_cannot_escape_codex_home() {
243 let _env_lock = crate::core::data_dir::test_env_lock();
244 let _guard = env_lock();
245 let dir = tempfile::tempdir().unwrap();
246 crate::test_env::set_var("CODEX_HOME", dir.path());
247 crate::test_env::set_var(LEAN_CTX_CODEX_PROFILE_ENV, "../outside");
248 crate::test_env::remove_var(CODEX_PROFILE_ENV);
249
250 let paths = resolve_codex_config_paths().unwrap();
251 assert_eq!(paths.profile_name(), None);
252 assert_eq!(paths.effective(), dir.path().join("config.toml"));
253
254 crate::test_env::remove_var("CODEX_HOME");
255 crate::test_env::remove_var(LEAN_CTX_CODEX_PROFILE_ENV);
256 }
257
258 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
259 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
260 LOCK.lock()
261 .unwrap_or_else(std::sync::PoisonError::into_inner)
262 }
263}