Skip to main content

lean_ctx/core/
home.rs

1use std::path::{Path, PathBuf};
2
3/// Explicit profile override understood by lean-ctx when it is run outside
4/// the Codex process that received `--profile`.
5pub const LEAN_CTX_CODEX_PROFILE_ENV: &str = "LEAN_CTX_CODEX_PROFILE";
6/// Compatibility with launchers that export the Codex profile name.
7pub 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    /// Path lean-ctx should write for the active Codex configuration.
30    pub fn effective(&self) -> &Path {
31        self.profile().unwrap_or_else(|| self.base())
32    }
33
34    /// Both layers in Codex's effective configuration, in load order.
35    pub fn layers(&self) -> impl Iterator<Item = &Path> {
36        std::iter::once(self.base()).chain(self.profile())
37    }
38}
39
40/// Resolve the user's home directory in a way that is:
41/// - Override-friendly for CI/tests (HOME/USERPROFILE)
42/// - Still correct in normal interactive installs (fallback to `dirs::home_dir()`)
43pub 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
70/// Resolve the Codex config directory.
71/// Respects `CODEX_HOME` env var (official Codex CLI feature).
72/// Falls back to `~/.codex` when unset or empty.
73pub 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
83/// Resolve the Codex profile name from an explicit env override.
84fn 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
105/// Infer a profile only when the Codex home contains exactly one named
106/// profile. Multiple overlays require an explicit env selection.
107fn 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
133/// Resolve both layers of the effective Codex configuration.
134pub 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
140/// Resolve the path lean-ctx should write for the active Codex profile.
141pub 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}