1use std::path::{Path, PathBuf};
2
3use directories::BaseDirs;
4use serde_json::Value;
5use tracing::warn;
6
7#[derive(Debug, Clone)]
49pub struct AppDirectories {
50 pub extensions_dir: PathBuf,
54 pub storage_dir: PathBuf,
57 pub logs_dir: PathBuf,
59}
60
61impl AppDirectories {
62 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 static ENV_TEST_MUTEX: Mutex<()> = Mutex::new(());
223
224 #[test]
225 fn test_app_directories_resolve() {
226 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 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 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 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 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 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}