Skip to main content

context_harness/
ctx_dirs.rs

1//! Directory policy for Context Harness CLI state.
2//!
3//! Workspace-local files live under `.ctx/`. User-global files use XDG base
4//! directories with an app directory named `ctx` (without a leading dot).
5
6use std::env;
7use std::path::{Path, PathBuf};
8
9const APP_DIR: &str = "ctx";
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ConfigSourceKind {
13    Explicit,
14    Env,
15    Workspace,
16    LegacyWorkspace,
17    Global,
18    BuiltIn,
19}
20
21#[derive(Debug, Clone)]
22pub struct ConfigSource {
23    pub kind: ConfigSourceKind,
24    pub path: Option<PathBuf>,
25}
26
27#[derive(Debug, Clone)]
28pub struct ConfigPaths {
29    pub explicit: Option<PathBuf>,
30    pub env_config: Option<PathBuf>,
31    pub workspace: PathBuf,
32    pub legacy_workspace: PathBuf,
33    pub global: PathBuf,
34}
35
36impl ConfigPaths {
37    pub fn resolve(&self) -> ConfigSource {
38        if let Some(path) = &self.explicit {
39            return ConfigSource {
40                kind: ConfigSourceKind::Explicit,
41                path: Some(path.clone()),
42            };
43        }
44        if let Some(path) = &self.env_config {
45            return ConfigSource {
46                kind: ConfigSourceKind::Env,
47                path: Some(path.clone()),
48            };
49        }
50        if self.workspace.exists() {
51            return ConfigSource {
52                kind: ConfigSourceKind::Workspace,
53                path: Some(self.workspace.clone()),
54            };
55        }
56        if self.legacy_workspace.exists() {
57            return ConfigSource {
58                kind: ConfigSourceKind::LegacyWorkspace,
59                path: Some(self.legacy_workspace.clone()),
60            };
61        }
62        if self.global.exists() {
63            return ConfigSource {
64                kind: ConfigSourceKind::Global,
65                path: Some(self.global.clone()),
66            };
67        }
68        ConfigSource {
69            kind: ConfigSourceKind::BuiltIn,
70            path: None,
71        }
72    }
73
74    pub fn has_explicit_source(&self) -> bool {
75        self.explicit.is_some() || self.env_config.is_some()
76    }
77
78    pub fn has_workspace_source(&self) -> bool {
79        self.workspace.exists() || self.legacy_workspace.exists()
80    }
81}
82
83pub fn config_paths(explicit: Option<PathBuf>) -> ConfigPaths {
84    ConfigPaths {
85        explicit,
86        env_config: env::var_os("CTX_CONFIG").map(PathBuf::from),
87        workspace: workspace_config_path(),
88        legacy_workspace: legacy_workspace_config_path(),
89        global: config_dir().join("config.toml"),
90    }
91}
92
93pub fn workspace_dir() -> PathBuf {
94    PathBuf::from(".ctx")
95}
96
97pub fn workspace_config_path() -> PathBuf {
98    workspace_dir().join("config.toml")
99}
100
101pub fn legacy_workspace_config_path() -> PathBuf {
102    PathBuf::from("config").join("ctx.toml")
103}
104
105pub fn workspace_data_dir() -> PathBuf {
106    workspace_dir().join("data")
107}
108
109pub fn workspace_db_path() -> PathBuf {
110    workspace_data_dir().join("ctx.sqlite")
111}
112
113pub fn workspace_vector_index_dir() -> PathBuf {
114    workspace_data_dir().join("vector-index").join("zvec")
115}
116
117pub fn workspace_cache_dir() -> PathBuf {
118    workspace_dir().join("cache")
119}
120
121pub fn workspace_git_cache_dir() -> PathBuf {
122    workspace_cache_dir().join("git")
123}
124
125pub fn config_dir() -> PathBuf {
126    xdg_app_dir("CTX_CONFIG_DIR", "XDG_CONFIG_HOME", ".config")
127}
128
129pub fn data_dir() -> PathBuf {
130    xdg_app_dir("CTX_DATA_DIR", "XDG_DATA_HOME", ".local/share")
131}
132
133#[allow(dead_code)]
134pub fn cache_dir() -> PathBuf {
135    xdg_app_dir("CTX_CACHE_DIR", "XDG_CACHE_HOME", ".cache")
136}
137
138#[allow(dead_code)]
139pub fn state_dir() -> PathBuf {
140    xdg_app_dir("CTX_STATE_DIR", "XDG_STATE_HOME", ".local/state")
141}
142
143#[allow(dead_code)]
144pub fn models_dir() -> PathBuf {
145    cache_dir().join("models")
146}
147
148pub fn registries_dir() -> PathBuf {
149    data_dir().join("registries")
150}
151
152#[allow(dead_code)]
153pub fn legacy_registries_dir() -> PathBuf {
154    home_dir()
155        .unwrap_or_else(|| PathBuf::from("."))
156        .join(".ctx")
157        .join("registries")
158}
159
160fn xdg_app_dir(override_var: &str, xdg_var: &str, default_suffix: &str) -> PathBuf {
161    if let Some(path) = absolute_env_path(override_var) {
162        return path;
163    }
164    if let Some(base) = absolute_env_path(xdg_var) {
165        return base.join(APP_DIR);
166    }
167    home_dir()
168        .unwrap_or_else(|| PathBuf::from("."))
169        .join(default_suffix)
170        .join(APP_DIR)
171}
172
173fn absolute_env_path(var: &str) -> Option<PathBuf> {
174    let value = env::var_os(var)?;
175    if value.is_empty() {
176        return None;
177    }
178    let path = PathBuf::from(value);
179    if path.is_absolute() {
180        Some(path)
181    } else {
182        eprintln!(
183            "Warning: ignoring relative path in {}: {}",
184            var,
185            path.display()
186        );
187        None
188    }
189}
190
191fn home_dir() -> Option<PathBuf> {
192    env::var_os("HOME").map(PathBuf::from)
193}
194
195pub fn is_default_workspace_db_path(path: &Path) -> bool {
196    path == workspace_db_path()
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use std::ffi::OsString;
203    use std::sync::{Mutex, OnceLock};
204    use tempfile::TempDir;
205
206    static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
207
208    fn with_env<F: FnOnce(&Path)>(vars: &[(&str, Option<&str>)], f: F) {
209        let _guard = ENV_LOCK.get_or_init(|| Mutex::new(())).lock().unwrap();
210        let old: Vec<(&str, Option<OsString>)> =
211            vars.iter().map(|(k, _)| (*k, env::var_os(k))).collect();
212        for (key, value) in vars {
213            match value {
214                Some(value) => env::set_var(key, value),
215                None => env::remove_var(key),
216            }
217        }
218        let tmp = TempDir::new().unwrap();
219        let old_cwd = env::current_dir().unwrap();
220        env::set_current_dir(tmp.path()).unwrap();
221        f(tmp.path());
222        env::set_current_dir(old_cwd).unwrap();
223        for (key, value) in old {
224            match value {
225                Some(value) => env::set_var(key, value),
226                None => env::remove_var(key),
227            }
228        }
229    }
230
231    #[test]
232    fn xdg_defaults_use_ctx_subdirectories() {
233        with_env(
234            &[
235                ("HOME", Some("/tmp/ctx-home")),
236                ("XDG_CONFIG_HOME", None),
237                ("XDG_DATA_HOME", None),
238                ("XDG_CACHE_HOME", None),
239                ("XDG_STATE_HOME", None),
240                ("CTX_CONFIG_DIR", None),
241                ("CTX_DATA_DIR", None),
242                ("CTX_CACHE_DIR", None),
243                ("CTX_STATE_DIR", None),
244            ],
245            |_| {
246                assert_eq!(config_dir(), PathBuf::from("/tmp/ctx-home/.config/ctx"));
247                assert_eq!(data_dir(), PathBuf::from("/tmp/ctx-home/.local/share/ctx"));
248                assert_eq!(cache_dir(), PathBuf::from("/tmp/ctx-home/.cache/ctx"));
249                assert_eq!(state_dir(), PathBuf::from("/tmp/ctx-home/.local/state/ctx"));
250            },
251        );
252    }
253
254    #[test]
255    fn ctx_dir_overrides_win_over_xdg() {
256        with_env(
257            &[
258                ("CTX_CACHE_DIR", Some("/tmp/ctx-cache")),
259                ("XDG_CACHE_HOME", Some("/tmp/xdg-cache")),
260            ],
261            |_| {
262                assert_eq!(cache_dir(), PathBuf::from("/tmp/ctx-cache"));
263            },
264        );
265    }
266
267    #[test]
268    fn relative_env_overrides_are_ignored() {
269        with_env(
270            &[
271                ("HOME", Some("/tmp/ctx-home")),
272                ("CTX_DATA_DIR", Some("relative-data")),
273                ("XDG_DATA_HOME", Some("relative-xdg")),
274            ],
275            |_| {
276                assert_eq!(data_dir(), PathBuf::from("/tmp/ctx-home/.local/share/ctx"));
277            },
278        );
279    }
280
281    #[test]
282    fn explicit_and_env_config_bypass_discovery() {
283        with_env(&[("CTX_CONFIG", Some("/tmp/from-env.toml"))], |_| {
284            let source = config_paths(Some(PathBuf::from("/tmp/explicit.toml"))).resolve();
285            assert_eq!(source.kind, ConfigSourceKind::Explicit);
286            assert_eq!(source.path, Some(PathBuf::from("/tmp/explicit.toml")));
287
288            let source = config_paths(None).resolve();
289            assert_eq!(source.kind, ConfigSourceKind::Env);
290            assert_eq!(source.path, Some(PathBuf::from("/tmp/from-env.toml")));
291        });
292    }
293}