tsk/storage/
xdg.rs

1use std::env;
2use std::path::{Path, PathBuf};
3use thiserror::Error;
4
5#[derive(Debug, Error)]
6pub enum XdgError {
7    #[error("Failed to determine home directory")]
8    NoHomeDirectory,
9    #[error("IO error: {0}")]
10    Io(#[from] std::io::Error),
11}
12
13/// Provides access to XDG Base Directory compliant paths for TSK
14#[derive(Debug, Clone)]
15pub struct XdgDirectories {
16    data_dir: PathBuf,
17    runtime_dir: PathBuf,
18    config_dir: PathBuf,
19}
20
21impl XdgDirectories {
22    /// Create new XDG directories instance with standard paths
23    pub fn new() -> Result<Self, XdgError> {
24        let data_dir = Self::resolve_data_dir()?;
25        let runtime_dir = Self::resolve_runtime_dir()?;
26        let config_dir = Self::resolve_config_dir()?;
27
28        Ok(Self {
29            data_dir,
30            runtime_dir,
31            config_dir,
32        })
33    }
34
35    /// Create new XDG directories with custom paths (for testing)
36    #[allow(dead_code)]
37    pub fn new_with_paths(
38        data_dir: PathBuf,
39        runtime_dir: PathBuf,
40        config_dir: PathBuf,
41        _cache_dir: PathBuf,
42    ) -> Self {
43        Self {
44            data_dir,
45            runtime_dir,
46            config_dir,
47        }
48    }
49
50    /// Get the data directory path (for persistent storage)
51    #[allow(dead_code)]
52    pub fn data_dir(&self) -> &Path {
53        &self.data_dir
54    }
55
56    /// Get the runtime directory path (for sockets, pid files)
57    #[allow(dead_code)]
58    pub fn runtime_dir(&self) -> &Path {
59        &self.runtime_dir
60    }
61
62    /// Get the config directory path (for configuration files)
63    pub fn config_dir(&self) -> &Path {
64        &self.config_dir
65    }
66
67    /// Get the templates directory path
68    #[allow(dead_code)]
69    pub fn templates_dir(&self) -> PathBuf {
70        self.config_dir.join("templates")
71    }
72
73    /// Get the path to the tasks.json file
74    pub fn tasks_file(&self) -> PathBuf {
75        self.data_dir.join("tasks.json")
76    }
77
78    /// Get the path to a task's directory
79    pub fn task_dir(&self, task_id: &str, repo_hash: &str) -> PathBuf {
80        self.data_dir
81            .join("tasks")
82            .join(format!("{repo_hash}-{task_id}"))
83    }
84
85    /// Get the server socket path
86    pub fn socket_path(&self) -> PathBuf {
87        self.runtime_dir.join("tsk.sock")
88    }
89
90    /// Get the server PID file path
91    pub fn pid_file(&self) -> PathBuf {
92        self.runtime_dir.join("tsk.pid")
93    }
94
95    /// Ensure all required directories exist
96    pub fn ensure_directories(&self) -> Result<(), XdgError> {
97        std::fs::create_dir_all(&self.data_dir)?;
98        std::fs::create_dir_all(self.data_dir.join("tasks"))?;
99        std::fs::create_dir_all(&self.runtime_dir)?;
100        std::fs::create_dir_all(&self.config_dir)?;
101        Ok(())
102    }
103
104    fn resolve_data_dir() -> Result<PathBuf, XdgError> {
105        // Check XDG_DATA_HOME first
106        if let Ok(xdg_data) = env::var("XDG_DATA_HOME") {
107            return Ok(PathBuf::from(xdg_data).join("tsk"));
108        }
109
110        // Fall back to ~/.local/share/tsk
111        let home = env::var("HOME")
112            .or_else(|_| env::var("USERPROFILE"))
113            .map_err(|_| XdgError::NoHomeDirectory)?;
114
115        Ok(PathBuf::from(home).join(".local").join("share").join("tsk"))
116    }
117
118    fn resolve_runtime_dir() -> Result<PathBuf, XdgError> {
119        // Check XDG_RUNTIME_DIR first
120        if let Ok(xdg_runtime) = env::var("XDG_RUNTIME_DIR") {
121            return Ok(PathBuf::from(xdg_runtime).join("tsk"));
122        }
123
124        // Fall back to /tmp/tsk-$UID
125        let uid = env::var("UID").unwrap_or_else(|_| {
126            // On systems without UID env var, use current user ID
127            #[cfg(unix)]
128            {
129                unsafe { libc::getuid().to_string() }
130            }
131            #[cfg(not(unix))]
132            {
133                "0".to_string()
134            }
135        });
136
137        Ok(PathBuf::from("/tmp").join(format!("tsk-{uid}")))
138    }
139
140    fn resolve_config_dir() -> Result<PathBuf, XdgError> {
141        // Check XDG_CONFIG_HOME first
142        if let Ok(xdg_config) = env::var("XDG_CONFIG_HOME") {
143            return Ok(PathBuf::from(xdg_config).join("tsk"));
144        }
145
146        // Fall back to ~/.config/tsk
147        let home = env::var("HOME")
148            .or_else(|_| env::var("USERPROFILE"))
149            .map_err(|_| XdgError::NoHomeDirectory)?;
150
151        Ok(PathBuf::from(home).join(".config").join("tsk"))
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use std::env;
159
160    #[test]
161    fn test_xdg_directories_with_env_vars() {
162        let original_data = env::var("XDG_DATA_HOME").ok();
163        let original_runtime = env::var("XDG_RUNTIME_DIR").ok();
164        let original_config = env::var("XDG_CONFIG_HOME").ok();
165
166        unsafe {
167            env::set_var("XDG_DATA_HOME", "/custom/data");
168        }
169        unsafe {
170            env::set_var("XDG_RUNTIME_DIR", "/custom/runtime");
171        }
172        unsafe {
173            env::set_var("XDG_CONFIG_HOME", "/custom/config");
174        }
175
176        let dirs = XdgDirectories::new().expect("Failed to create XDG directories");
177
178        assert_eq!(dirs.data_dir(), Path::new("/custom/data/tsk"));
179        assert_eq!(dirs.runtime_dir(), Path::new("/custom/runtime/tsk"));
180        assert_eq!(dirs.config_dir(), Path::new("/custom/config/tsk"));
181        assert_eq!(dirs.tasks_file(), Path::new("/custom/data/tsk/tasks.json"));
182        assert_eq!(
183            dirs.socket_path(),
184            Path::new("/custom/runtime/tsk/tsk.sock")
185        );
186        assert_eq!(dirs.pid_file(), Path::new("/custom/runtime/tsk/tsk.pid"));
187        assert_eq!(
188            dirs.templates_dir(),
189            Path::new("/custom/config/tsk/templates")
190        );
191
192        // Restore original environment
193        match original_data {
194            Some(val) => unsafe { env::set_var("XDG_DATA_HOME", val) },
195            None => unsafe { env::remove_var("XDG_DATA_HOME") },
196        }
197        match original_runtime {
198            Some(val) => unsafe { env::set_var("XDG_RUNTIME_DIR", val) },
199            None => unsafe { env::remove_var("XDG_RUNTIME_DIR") },
200        }
201        match original_config {
202            Some(val) => unsafe { env::set_var("XDG_CONFIG_HOME", val) },
203            None => unsafe { env::remove_var("XDG_CONFIG_HOME") },
204        }
205    }
206
207    #[test]
208    fn test_xdg_directories_fallback() {
209        let original_data = env::var("XDG_DATA_HOME").ok();
210        let original_runtime = env::var("XDG_RUNTIME_DIR").ok();
211        let original_config = env::var("XDG_CONFIG_HOME").ok();
212
213        unsafe {
214            env::remove_var("XDG_DATA_HOME");
215        }
216        unsafe {
217            env::remove_var("XDG_RUNTIME_DIR");
218        }
219        unsafe {
220            env::remove_var("XDG_CONFIG_HOME");
221        }
222
223        let dirs = XdgDirectories::new().expect("Failed to create XDG directories");
224
225        let home = env::var("HOME")
226            .or_else(|_| env::var("USERPROFILE"))
227            .unwrap();
228        let expected_data = PathBuf::from(&home)
229            .join(".local")
230            .join("share")
231            .join("tsk");
232        assert_eq!(dirs.data_dir(), expected_data);
233
234        let expected_config = PathBuf::from(&home).join(".config").join("tsk");
235        assert_eq!(dirs.config_dir(), expected_config);
236
237        // Restore original environment
238        match original_data {
239            Some(val) => unsafe { env::set_var("XDG_DATA_HOME", val) },
240            None => unsafe { env::remove_var("XDG_DATA_HOME") },
241        }
242        match original_runtime {
243            Some(val) => unsafe { env::set_var("XDG_RUNTIME_DIR", val) },
244            None => unsafe { env::remove_var("XDG_RUNTIME_DIR") },
245        }
246        match original_config {
247            Some(val) => unsafe { env::set_var("XDG_CONFIG_HOME", val) },
248            None => unsafe { env::remove_var("XDG_CONFIG_HOME") },
249        }
250    }
251
252    #[test]
253    fn test_config_dir_resolution() {
254        let original_config = env::var("XDG_CONFIG_HOME").ok();
255
256        // Test with XDG_CONFIG_HOME set
257        unsafe {
258            env::set_var("XDG_CONFIG_HOME", "/test/config");
259        }
260        let config_dir = XdgDirectories::resolve_config_dir().unwrap();
261        assert_eq!(config_dir, PathBuf::from("/test/config/tsk"));
262
263        // Test fallback
264        unsafe {
265            env::remove_var("XDG_CONFIG_HOME");
266        }
267        let config_dir = XdgDirectories::resolve_config_dir().unwrap();
268        let home = env::var("HOME")
269            .or_else(|_| env::var("USERPROFILE"))
270            .unwrap();
271        assert_eq!(config_dir, PathBuf::from(home).join(".config").join("tsk"));
272
273        // Restore original environment
274        match original_config {
275            Some(val) => unsafe { env::set_var("XDG_CONFIG_HOME", val) },
276            None => unsafe { env::remove_var("XDG_CONFIG_HOME") },
277        }
278    }
279
280    #[test]
281    fn test_task_dir_generation() {
282        let dirs = XdgDirectories::new().expect("Failed to create XDG directories");
283        let task_dir = dirs.task_dir("task-123", "repo-abc");
284
285        assert!(task_dir.to_string_lossy().contains("repo-abc-task-123"));
286    }
287}