1use 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}