Skip to main content

spn_client/
paths.rs

1//! Centralized path management for the ~/.spn directory structure.
2//!
3//! This module provides a single source of truth for all paths used by the
4//! SuperNovae ecosystem, eliminating scattered `dirs::home_dir().join(".spn")`
5//! calls throughout the codebase.
6//!
7//! # Example
8//!
9//! ```rust,no_run
10//! use spn_client::SpnPaths;
11//!
12//! // Create paths rooted at ~/.spn
13//! let paths = SpnPaths::new().expect("HOME directory must be set");
14//!
15//! // Access specific paths
16//! println!("Config: {:?}", paths.config_file());
17//! println!("Socket: {:?}", paths.socket_file());
18//! println!("Packages: {:?}", paths.packages_dir());
19//!
20//! // For testing, use a custom root
21//! let test_paths = SpnPaths::with_root("/tmp/spn-test".into());
22//! ```
23//!
24//! # Directory Structure
25//!
26//! ```text
27//! ~/.spn/
28//! ├── config.toml          # Global user configuration
29//! ├── daemon.sock          # Unix socket for IPC
30//! ├── daemon.pid           # PID file with flock
31//! ├── secrets.env          # API keys (fallback to keychain)
32//! ├── state.json           # Package installation state
33//! ├── bin/                  # Binary stubs (nika, novanet)
34//! ├── packages/             # Installed packages
35//! │   └── @scope/name/version/
36//! ├── cache/                # Download cache
37//! │   └── tarballs/
38//! └── registry/             # Registry index cache
39//! ```
40
41use std::path::{Path, PathBuf};
42use thiserror::Error;
43
44/// Error type for path operations.
45#[derive(Debug, Error)]
46pub enum PathError {
47    /// HOME directory is not set or unavailable.
48    #[error("HOME directory not found. Set HOME environment variable.")]
49    HomeNotFound,
50
51    /// Failed to create a required directory.
52    #[error("Failed to create directory {path}: {source}")]
53    CreateDirFailed {
54        /// The path that could not be created.
55        path: PathBuf,
56        /// The underlying IO error.
57        #[source]
58        source: std::io::Error,
59    },
60}
61
62/// Centralized path management for the ~/.spn directory structure.
63///
64/// Provides type-safe access to all paths used by spn-cli, spn-daemon,
65/// and other tools in the SuperNovae ecosystem.
66#[derive(Debug, Clone)]
67pub struct SpnPaths {
68    root: PathBuf,
69}
70
71impl SpnPaths {
72    /// Create paths rooted at the default location (~/.spn).
73    ///
74    /// Returns an error if the HOME directory is not available.
75    ///
76    /// # Example
77    ///
78    /// ```rust,no_run
79    /// use spn_client::SpnPaths;
80    ///
81    /// let paths = SpnPaths::new()?;
82    /// println!("Root: {:?}", paths.root());
83    /// # Ok::<(), spn_client::PathError>(())
84    /// ```
85    pub fn new() -> Result<Self, PathError> {
86        let home = dirs::home_dir().ok_or(PathError::HomeNotFound)?;
87        Ok(Self {
88            root: home.join(".spn"),
89        })
90    }
91
92    /// Create paths with a custom root directory.
93    ///
94    /// Useful for testing or custom installations.
95    ///
96    /// # Example
97    ///
98    /// ```rust
99    /// use spn_client::SpnPaths;
100    /// use std::path::PathBuf;
101    ///
102    /// let paths = SpnPaths::with_root(PathBuf::from("/tmp/spn-test"));
103    /// assert_eq!(paths.root().to_str().unwrap(), "/tmp/spn-test");
104    /// ```
105    pub fn with_root(root: PathBuf) -> Self {
106        Self { root }
107    }
108
109    // =========================================================================
110    // Directory Paths
111    // =========================================================================
112
113    /// Root directory (~/.spn).
114    pub fn root(&self) -> &Path {
115        &self.root
116    }
117
118    /// Binary directory (~/.spn/bin).
119    ///
120    /// Contains symlinks or stubs for nika, novanet, etc.
121    pub fn bin_dir(&self) -> PathBuf {
122        self.root.join("bin")
123    }
124
125    /// Packages directory (~/.spn/packages).
126    ///
127    /// Structure: packages/@scope/name/version/
128    pub fn packages_dir(&self) -> PathBuf {
129        self.root.join("packages")
130    }
131
132    /// Cache directory (~/.spn/cache).
133    ///
134    /// Contains downloaded tarballs and temporary files.
135    pub fn cache_dir(&self) -> PathBuf {
136        self.root.join("cache")
137    }
138
139    /// Tarballs cache directory (~/.spn/cache/tarballs).
140    pub fn tarballs_dir(&self) -> PathBuf {
141        self.cache_dir().join("tarballs")
142    }
143
144    /// Registry cache directory (~/.spn/registry).
145    ///
146    /// Contains cached package index data.
147    pub fn registry_dir(&self) -> PathBuf {
148        self.root.join("registry")
149    }
150
151    // =========================================================================
152    // File Paths
153    // =========================================================================
154
155    /// Global configuration file (~/.spn/config.toml).
156    pub fn config_file(&self) -> PathBuf {
157        self.root.join("config.toml")
158    }
159
160    /// Secrets file (~/.spn/secrets.env).
161    ///
162    /// Alternative to OS keychain for storing API keys.
163    pub fn secrets_file(&self) -> PathBuf {
164        self.root.join("secrets.env")
165    }
166
167    /// Daemon socket file (~/.spn/daemon.sock).
168    pub fn socket_file(&self) -> PathBuf {
169        self.root.join("daemon.sock")
170    }
171
172    /// Daemon PID file (~/.spn/daemon.pid).
173    pub fn pid_file(&self) -> PathBuf {
174        self.root.join("daemon.pid")
175    }
176
177    /// State file (~/.spn/state.json).
178    ///
179    /// Tracks installed packages and their versions.
180    pub fn state_file(&self) -> PathBuf {
181        self.root.join("state.json")
182    }
183
184    // =========================================================================
185    // Package Paths
186    // =========================================================================
187
188    /// Get the path for a specific package version.
189    ///
190    /// # Arguments
191    ///
192    /// * `name` - Package name (e.g., "@workflows/code-review")
193    /// * `version` - Package version (e.g., "1.0.0")
194    ///
195    /// # Example
196    ///
197    /// ```rust
198    /// use spn_client::SpnPaths;
199    /// use std::path::PathBuf;
200    ///
201    /// let paths = SpnPaths::with_root(PathBuf::from("/home/user/.spn"));
202    /// let pkg_path = paths.package_dir("@workflows/code-review", "1.0.0");
203    /// assert!(pkg_path.to_string_lossy().contains("@workflows"));
204    /// ```
205    pub fn package_dir(&self, name: &str, version: &str) -> PathBuf {
206        self.packages_dir().join(name).join(version)
207    }
208
209    /// Get the path for a binary stub.
210    ///
211    /// # Arguments
212    ///
213    /// * `name` - Binary name (e.g., "nika", "novanet")
214    pub fn binary(&self, name: &str) -> PathBuf {
215        self.bin_dir().join(name)
216    }
217
218    // =========================================================================
219    // Directory Management
220    // =========================================================================
221
222    /// Ensure all required directories exist.
223    ///
224    /// Creates the following directories if they don't exist:
225    /// - ~/.spn/
226    /// - ~/.spn/bin/
227    /// - ~/.spn/packages/
228    /// - ~/.spn/cache/
229    /// - ~/.spn/cache/tarballs/
230    /// - ~/.spn/registry/
231    ///
232    /// # Example
233    ///
234    /// ```rust,no_run
235    /// use spn_client::SpnPaths;
236    ///
237    /// let paths = SpnPaths::new()?;
238    /// paths.ensure_dirs()?;
239    /// # Ok::<(), Box<dyn std::error::Error>>(())
240    /// ```
241    pub fn ensure_dirs(&self) -> Result<(), PathError> {
242        let dirs = [
243            self.root.clone(),
244            self.bin_dir(),
245            self.packages_dir(),
246            self.cache_dir(),
247            self.tarballs_dir(),
248            self.registry_dir(),
249        ];
250
251        for dir in dirs {
252            std::fs::create_dir_all(&dir).map_err(|e| PathError::CreateDirFailed {
253                path: dir,
254                source: e,
255            })?;
256        }
257
258        Ok(())
259    }
260
261    /// Check if the root directory exists.
262    pub fn exists(&self) -> bool {
263        self.root.exists()
264    }
265}
266
267impl Default for SpnPaths {
268    /// Creates SpnPaths with the default root, panicking if HOME is unavailable.
269    ///
270    /// **Note:** Prefer `SpnPaths::new()` which returns a Result.
271    fn default() -> Self {
272        Self::new().expect("HOME directory must be set for SpnPaths::default()")
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use tempfile::TempDir;
280
281    #[test]
282    fn test_with_root() {
283        let paths = SpnPaths::with_root(PathBuf::from("/custom/root"));
284        assert_eq!(paths.root(), Path::new("/custom/root"));
285    }
286
287    #[test]
288    fn test_directory_paths() {
289        let paths = SpnPaths::with_root(PathBuf::from("/home/user/.spn"));
290
291        assert_eq!(paths.bin_dir(), PathBuf::from("/home/user/.spn/bin"));
292        assert_eq!(
293            paths.packages_dir(),
294            PathBuf::from("/home/user/.spn/packages")
295        );
296        assert_eq!(paths.cache_dir(), PathBuf::from("/home/user/.spn/cache"));
297        assert_eq!(
298            paths.tarballs_dir(),
299            PathBuf::from("/home/user/.spn/cache/tarballs")
300        );
301        assert_eq!(
302            paths.registry_dir(),
303            PathBuf::from("/home/user/.spn/registry")
304        );
305    }
306
307    #[test]
308    fn test_file_paths() {
309        let paths = SpnPaths::with_root(PathBuf::from("/home/user/.spn"));
310
311        assert_eq!(
312            paths.config_file(),
313            PathBuf::from("/home/user/.spn/config.toml")
314        );
315        assert_eq!(
316            paths.secrets_file(),
317            PathBuf::from("/home/user/.spn/secrets.env")
318        );
319        assert_eq!(
320            paths.socket_file(),
321            PathBuf::from("/home/user/.spn/daemon.sock")
322        );
323        assert_eq!(
324            paths.pid_file(),
325            PathBuf::from("/home/user/.spn/daemon.pid")
326        );
327        assert_eq!(
328            paths.state_file(),
329            PathBuf::from("/home/user/.spn/state.json")
330        );
331    }
332
333    #[test]
334    fn test_package_dir() {
335        let paths = SpnPaths::with_root(PathBuf::from("/home/user/.spn"));
336
337        let pkg = paths.package_dir("@workflows/code-review", "1.0.0");
338        assert_eq!(
339            pkg,
340            PathBuf::from("/home/user/.spn/packages/@workflows/code-review/1.0.0")
341        );
342    }
343
344    #[test]
345    fn test_binary_path() {
346        let paths = SpnPaths::with_root(PathBuf::from("/home/user/.spn"));
347
348        assert_eq!(
349            paths.binary("nika"),
350            PathBuf::from("/home/user/.spn/bin/nika")
351        );
352        assert_eq!(
353            paths.binary("novanet"),
354            PathBuf::from("/home/user/.spn/bin/novanet")
355        );
356    }
357
358    #[test]
359    fn test_ensure_dirs() {
360        let temp = TempDir::new().unwrap();
361        let paths = SpnPaths::with_root(temp.path().to_path_buf());
362
363        // Directories should not exist initially
364        assert!(!paths.bin_dir().exists());
365        assert!(!paths.packages_dir().exists());
366
367        // Create them
368        paths.ensure_dirs().unwrap();
369
370        // Now they should exist
371        assert!(paths.bin_dir().exists());
372        assert!(paths.packages_dir().exists());
373        assert!(paths.cache_dir().exists());
374        assert!(paths.tarballs_dir().exists());
375        assert!(paths.registry_dir().exists());
376    }
377
378    #[test]
379    fn test_exists() {
380        let temp = TempDir::new().unwrap();
381        let paths = SpnPaths::with_root(temp.path().join("nonexistent"));
382
383        assert!(!paths.exists());
384
385        std::fs::create_dir_all(paths.root()).unwrap();
386        assert!(paths.exists());
387    }
388
389    #[test]
390    fn test_new_returns_home_based_path() {
391        // This test only works if HOME is set
392        if let Ok(paths) = SpnPaths::new() {
393            let root_str = paths.root().to_string_lossy();
394            assert!(root_str.ends_with(".spn"));
395        }
396    }
397
398    #[test]
399    fn test_clone() {
400        let paths = SpnPaths::with_root(PathBuf::from("/test"));
401        let cloned = paths.clone();
402        assert_eq!(paths.root(), cloned.root());
403    }
404
405    #[test]
406    fn test_debug() {
407        let paths = SpnPaths::with_root(PathBuf::from("/test"));
408        let debug_str = format!("{:?}", paths);
409        assert!(debug_str.contains("SpnPaths"));
410        assert!(debug_str.contains("/test"));
411    }
412}