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