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}