Skip to main content

lean_ctx_fs/
home.rs

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