Skip to main content

dscode_core/
config.rs

1use std::path::{Path, PathBuf};
2
3use directories::BaseDirs;
4use serde_json::Value;
5use tracing::warn;
6
7/// Application directories derived from configuration.
8///
9/// `AppDirectories` holds the resolved filesystem paths that DSCode uses to
10/// store extensions, persistent data, and log files. Paths are resolved at
11/// construction time from a priority chain of environment variables, user
12/// configuration, and platform defaults.
13///
14/// # Resolution priority (highest to lowest)
15///
16/// 1. **Environment variable override** — e.g. `DSCODE_EXTENSIONS_DIR` for the
17///    extensions directory.
18/// 2. **User config file** — `~/.dscode/config.json`, which may specify
19///    custom paths under a `"paths"` object.
20/// 3. **Platform default** — `~/.dscode/extensions`, `~/.dscode/storage`, and
21///    `~/.dscode/logs` respectively.
22///
23/// # User Configuration
24///
25/// Reads `~/.dscode/config.json` for custom paths:
26///
27/// ```json
28/// {
29///   "paths": {
30///     "extensionsDir": "/custom/extensions",
31///     "storageDir": "/custom/storage",
32///     "logsDir": "/custom/logs"
33///   }
34/// }
35/// ```
36///
37/// Both `camelCase` and `snake_case` keys are accepted.
38///
39/// # Invariants
40///
41/// - All three directory paths exist on disk after [`AppDirectories::resolve`]
42///   returns `Ok`. Directories are created automatically if they do not exist.
43/// - Tilde (`~`) prefixes in user-configured paths are expanded to the home
44///   directory.
45/// - Paths are canonicalised when possible; if canonicalisation fails (e.g.
46///   the path does not yet exist on some platforms), the original path is
47///   kept unchanged.
48#[derive(Debug, Clone)]
49pub struct AppDirectories {
50    /// Directory where installed extensions are stored.
51    ///
52    /// Can be overridden by the `DSCODE_EXTENSIONS_DIR` environment variable.
53    pub extensions_dir: PathBuf,
54    /// Directory for persistent application storage (e.g. workspace state,
55    /// user preferences).
56    pub storage_dir: PathBuf,
57    /// Directory where log files are written.
58    pub logs_dir: PathBuf,
59}
60
61impl AppDirectories {
62    /// Resolve directories from user configuration and environment overrides.
63    ///
64    /// The method applies the priority chain (environment variable > user
65    /// config > platform default) for each of the three directory paths,
66    /// expands `~` prefixes, creates directories that do not yet exist, and
67    /// canonicalises the resulting paths.
68    ///
69    /// # Errors
70    ///
71    /// Returns `Err(String)` if:
72    /// - A `~` path cannot be expanded because the home directory is
73    ///   unavailable.
74    /// - A directory cannot be created due to a filesystem permission error.
75    ///
76    /// # Example
77    ///
78    /// ```rust,no_run
79    /// use dscode_core::AppDirectories;
80    ///
81    /// let dirs = AppDirectories::resolve().expect("failed to resolve dirs");
82    /// println!("Extensions: {:?}", dirs.extensions_dir);
83    /// ```
84    pub fn resolve() -> Result<Self, String> {
85        let user_paths = resolve_user_config_dirs();
86
87        let extensions_pref = resolve_env_extensions_dir()
88            .or_else(|| user_paths.extensions.clone())
89            .unwrap_or_else(default_extensions_dir);
90
91        let storage_pref = user_paths.storage.clone().unwrap_or_else(default_storage_dir);
92
93        let logs_pref = user_paths.logs.clone().unwrap_or_else(default_logs_dir);
94
95        let extensions_dir = normalize_path(extensions_pref)?;
96        let storage_dir = normalize_path(storage_pref)?;
97        let logs_dir = normalize_path(logs_pref)?;
98
99        create_dir_if_needed(&extensions_dir, "extensions")?;
100        create_dir_if_needed(&storage_dir, "storage")?;
101        create_dir_if_needed(&logs_dir, "logs")?;
102
103        Ok(Self {
104            extensions_dir: canonicalize_or_original(extensions_dir),
105            storage_dir: canonicalize_or_original(storage_dir),
106            logs_dir: canonicalize_or_original(logs_dir),
107        })
108    }
109}
110
111fn resolve_env_extensions_dir() -> Option<PathBuf> {
112    std::env::var("DSCODE_EXTENSIONS_DIR").ok().map(PathBuf::from)
113}
114
115fn resolve_user_config_dirs() -> UserConfiguredPaths {
116    if let Some(base) = BaseDirs::new() {
117        let config_path = base.home_dir().join(".dscode").join("config.json");
118        if let Ok(contents) = std::fs::read_to_string(config_path) {
119            if let Ok(value) = serde_json::from_str::<Value>(&contents) {
120                let extensions =
121                    extract_path(&value, &["extensionsDir", "extensions_dir"], Some("paths"));
122                let storage = extract_path(&value, &["storageDir", "storage_dir"], Some("paths"));
123                let logs = extract_path(&value, &["logsDir", "logs_dir"], Some("paths"));
124                return UserConfiguredPaths { extensions, storage, logs };
125            }
126        }
127    }
128
129    UserConfiguredPaths::default()
130}
131
132fn default_extensions_dir() -> PathBuf {
133    default_base_dir().join("extensions")
134}
135
136fn default_storage_dir() -> PathBuf {
137    default_base_dir().join("storage")
138}
139
140fn default_logs_dir() -> PathBuf {
141    default_base_dir().join("logs")
142}
143
144fn default_base_dir() -> PathBuf {
145    if let Some(base) = BaseDirs::new() {
146        base.home_dir().join(".dscode")
147    } else if let Ok(home) = std::env::var("HOME") {
148        Path::new(&home).join(".dscode")
149    } else {
150        warn!("Unable to resolve home directory, using temp directory");
151        std::env::temp_dir().join("dscode")
152    }
153}
154
155fn normalize_path(path: PathBuf) -> Result<PathBuf, String> {
156    let as_str = path.to_string_lossy();
157    if as_str == "~" {
158        if let Some(base) = BaseDirs::new() {
159            return Ok(base.home_dir().to_path_buf());
160        }
161        if let Ok(home) = std::env::var("HOME") {
162            return Ok(Path::new(&home).to_path_buf());
163        }
164        return Err("Unable to resolve home directory for '~' path".to_string());
165    }
166
167    if let Some(stripped) = as_str.strip_prefix("~/") {
168        if let Some(base) = BaseDirs::new() {
169            return Ok(base.home_dir().join(stripped));
170        }
171        if let Ok(home) = std::env::var("HOME") {
172            return Ok(Path::new(&home).join(stripped));
173        }
174        return Err("Unable to resolve home directory for '~/...' path".to_string());
175    }
176
177    Ok(path)
178}
179
180fn create_dir_if_needed(path: &Path, label: &str) -> Result<(), String> {
181    std::fs::create_dir_all(path)
182        .map_err(|e| format!("Failed to create {} directory {:?}: {}", label, path, e))
183}
184
185fn canonicalize_or_original(path: PathBuf) -> PathBuf {
186    std::fs::canonicalize(&path).unwrap_or(path)
187}
188
189#[derive(Default)]
190struct UserConfiguredPaths {
191    extensions: Option<PathBuf>,
192    storage: Option<PathBuf>,
193    logs: Option<PathBuf>,
194}
195
196fn extract_path(value: &Value, keys: &[&str], scoped: Option<&str>) -> Option<PathBuf> {
197    for key in keys {
198        if let Some(path) = value.get(*key).and_then(|v| v.as_str()) {
199            return Some(PathBuf::from(path));
200        }
201    }
202
203    if let Some(scope_key) = scoped {
204        if let Some(scope) = value.get(scope_key) {
205            for key in keys {
206                if let Some(path) = scope.get(*key).and_then(|v| v.as_str()) {
207                    return Some(PathBuf::from(path));
208                }
209            }
210        }
211    }
212
213    None
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use std::sync::Mutex;
220
221    /// Mutex to serialize env-override tests since std::env::set_var is process-global.
222    static ENV_TEST_MUTEX: Mutex<()> = Mutex::new(());
223
224    #[test]
225    fn test_app_directories_resolve() {
226        // This test verifies that AppDirectories can resolve without error
227        let dirs = AppDirectories::resolve();
228        assert!(dirs.is_ok(), "AppDirectories::resolve() should succeed");
229
230        let dirs = dirs.unwrap();
231        assert!(dirs.extensions_dir.to_string_lossy().contains("dscode") || dirs.extensions_dir.to_string_lossy().contains("DSCode"));
232        assert!(dirs.storage_dir.to_string_lossy().contains("dscode") || dirs.storage_dir.to_string_lossy().contains("DSCode"));
233        assert!(dirs.logs_dir.to_string_lossy().contains("dscode") || dirs.logs_dir.to_string_lossy().contains("DSCode"));
234    }
235
236    #[test]
237    fn test_env_override_extensions_dir() {
238        let _lock = ENV_TEST_MUTEX.lock().unwrap();
239        let custom_dir = std::env::temp_dir().join(format!("dscode-test-ext-{}", std::process::id()));
240        let _ = std::fs::remove_dir_all(&custom_dir);
241
242        std::env::set_var("DSCODE_EXTENSIONS_DIR", &custom_dir);
243        let dirs = AppDirectories::resolve().expect("resolve should work with env override");
244        assert!(dirs.extensions_dir.starts_with(&custom_dir) || dirs.extensions_dir == canonicalize_or_original(custom_dir.clone()));
245
246        std::env::remove_var("DSCODE_EXTENSIONS_DIR");
247        let _ = std::fs::remove_dir_all(&custom_dir);
248    }
249
250    #[test]
251    fn test_env_override_set_resolve_clear() {
252        let _lock = ENV_TEST_MUTEX.lock().unwrap();
253        let custom_dir = std::env::temp_dir().join(format!("dscode-test-cycle-{}", std::process::id()));
254        let _ = std::fs::remove_dir_all(&custom_dir);
255
256        std::env::set_var("DSCODE_EXTENSIONS_DIR", &custom_dir);
257        let dirs_with_env = AppDirectories::resolve().expect("resolve should work with env override");
258        assert!(
259            dirs_with_env.extensions_dir.starts_with(&custom_dir)
260                || dirs_with_env.extensions_dir == canonicalize_or_original(custom_dir.clone()),
261            "Extensions dir should use env override"
262        );
263
264        std::env::remove_var("DSCODE_EXTENSIONS_DIR");
265        let dirs_without_env = AppDirectories::resolve().expect("resolve should work without env override");
266        assert!(
267            !dirs_without_env.extensions_dir.starts_with(&custom_dir),
268            "Extensions dir should revert to default after clearing env"
269        );
270
271        let _ = std::fs::remove_dir_all(&custom_dir);
272    }
273
274    #[test]
275    fn test_resolve_creates_directories() {
276        // resolve() should create the directories if they don't exist
277        let dirs = AppDirectories::resolve().unwrap();
278        assert!(dirs.extensions_dir.exists(), "extensions_dir should exist after resolve");
279        assert!(dirs.storage_dir.exists(), "storage_dir should exist after resolve");
280        assert!(dirs.logs_dir.exists(), "logs_dir should exist after resolve");
281    }
282
283    #[test]
284    fn test_normalize_path_tilde() {
285        // Tilde should expand to the home directory
286        let expanded = normalize_path(PathBuf::from("~")).unwrap();
287        assert!(!expanded.to_string_lossy().starts_with('~'), "Tilde should be expanded");
288
289        let expanded_prefix = normalize_path(PathBuf::from("~/subdir")).unwrap();
290        assert!(!expanded_prefix.to_string_lossy().starts_with('~'), "Tilde prefix should be expanded");
291        assert!(expanded_prefix.to_string_lossy().contains("subdir"));
292    }
293
294    #[test]
295    fn test_extract_path_from_nested_json() {
296        // extract_path should find keys in scoped objects
297        let value: Value = serde_json::from_str(r#"{"paths": {"extensionsDir": "/custom/ext"}}"#).unwrap();
298        let result = extract_path(&value, &["extensionsDir", "extensions_dir"], Some("paths"));
299        assert_eq!(result, Some(PathBuf::from("/custom/ext")));
300
301        // Fallback to top-level key if not in scope
302        let value2: Value = serde_json::from_str(r#"{"extensionsDir": "/top/ext"}"#).unwrap();
303        let result2 = extract_path(&value2, &["extensionsDir", "extensions_dir"], Some("paths"));
304        assert_eq!(result2, Some(PathBuf::from("/top/ext")));
305
306        // Missing key returns None
307        let value3: Value = serde_json::from_str(r#"{"paths": {}}"#).unwrap();
308        let result3 = extract_path(&value3, &["extensionsDir", "extensions_dir"], Some("paths"));
309        assert_eq!(result3, None);
310    }
311
312    #[test]
313    fn test_default_base_dir_contains_dscode() {
314        let base = default_base_dir();
315        let base_str = base.to_string_lossy();
316        assert!(base_str.contains("dscode"), "Default base dir should contain 'dscode': {:?}", base);
317    }
318}