Skip to main content

nika_engine/core/
paths.rs

1//! Nika home directory and path utilities.
2//!
3//! This module provides canonical path resolution for the Nika ecosystem.
4//! All paths are relative to `~/.nika/` (the Nika home directory).
5//!
6//! ## Directory Structure
7//!
8//! ```text
9//! ~/.nika/
10//! ├── config.toml          # Global configuration
11//! ├── mcp.yaml             # MCP server definitions
12//! ├── daemon/              # Reserved for future nika daemon
13//! │   ├── nika.sock        # Unix socket for IPC
14//! │   └── nika.pid         # PID file
15//! ├── models/              # GGUF models for native inference
16//! │   └── cache/           # HuggingFace cache
17//! ├── packages/            # Installed packages
18//! │   ├── @scope/name/     # Scoped packages
19//! │   └── registry.yaml    # Package index
20//! ├── backups/             # Backup archives
21//! └── cache/               # General cache
22//! ```
23//!
24//! ## Project-Level Structure
25//!
26//! ```text
27//! project/
28//! ├── .nika/               # Project-specific Nika config
29//! │   ├── config.toml      # Project settings
30//! │   ├── mcp.yaml         # Project MCP servers
31//! │   └── sessions/        # Editor sessions
32//! ├── nika.yaml            # Package manifest (dependencies)
33//! └── nika.lock            # Lockfile (exact versions)
34//! ```
35//!
36
37use std::path::PathBuf;
38
39/// Environment variable to override the Nika home directory.
40///
41/// If set, this takes precedence over the default `~/.nika/` location.
42/// Useful for testing and custom installations.
43pub const NIKA_HOME_ENV: &str = "NIKA_HOME";
44
45/// Default directory name for Nika home.
46pub const NIKA_DIR_NAME: &str = ".nika";
47
48/// Project-level directory name.
49pub const NIKA_PROJECT_DIR: &str = ".nika";
50
51/// Package manifest filename.
52pub const NIKA_MANIFEST: &str = "nika.yaml";
53
54/// Lockfile filename.
55pub const NIKA_LOCKFILE: &str = "nika.lock";
56
57/// MCP config filename.
58pub const MCP_CONFIG: &str = "mcp.yaml";
59
60/// Global config filename.
61pub const GLOBAL_CONFIG: &str = "config.toml";
62
63/// Registry index filename.
64pub const REGISTRY_INDEX: &str = "registry.yaml";
65
66/// Daemon socket filename.
67pub const DAEMON_SOCKET: &str = "nika.sock";
68
69/// Daemon PID filename.
70pub const DAEMON_PID: &str = "nika.pid";
71
72// ═══════════════════════════════════════════════════════════════════════════
73// NIKA HOME RESOLUTION
74// ═══════════════════════════════════════════════════════════════════════════
75
76/// Returns the Nika home directory path.
77///
78/// Resolution order:
79/// 1. `NIKA_HOME` environment variable (if set)
80/// 2. `~/.nika/` (default)
81///
82/// # Panics
83///
84/// Panics if the home directory cannot be determined and `NIKA_HOME` is not set.
85///
86/// # Examples
87///
88/// ```rust,ignore
89/// use nika::core::paths::nika_home;
90///
91/// let home = nika_home();
92/// // Returns PathBuf like "/Users/thibaut/.nika"
93/// ```
94pub fn nika_home() -> PathBuf {
95    // Check for environment override first
96    if let Ok(custom_home) = std::env::var(NIKA_HOME_ENV) {
97        return PathBuf::from(custom_home);
98    }
99
100    // Default to ~/.nika/
101    match dirs::home_dir() {
102        Some(h) => h.join(NIKA_DIR_NAME),
103        None => {
104            // Security: use std::env::temp_dir() (respects TMPDIR) with
105            // a unique-per-user suffix to prevent symlink attacks on /tmp.
106            let fallback = std::env::temp_dir().join(NIKA_DIR_NAME);
107            tracing::error!(
108                path = %fallback.display(),
109                "Could not determine home directory. Using temporary fallback.                  Set NIKA_HOME environment variable for a secure persistent location."
110            );
111            fallback
112        }
113    }
114}
115
116/// Returns the Nika home directory, or None if it cannot be determined.
117///
118/// This is the fallible version of [`nika_home`].
119pub fn nika_home_opt() -> Option<PathBuf> {
120    if let Ok(custom_home) = std::env::var(NIKA_HOME_ENV) {
121        return Some(PathBuf::from(custom_home));
122    }
123    dirs::home_dir().map(|h| h.join(NIKA_DIR_NAME))
124}
125
126/// Returns the Nika home directory, or an error if it cannot be determined.
127///
128/// This is the Result-returning version of [`nika_home`] for use in fallible contexts.
129///
130/// # Errors
131///
132/// Returns [`NikaError::HomeDirectoryNotFound`] if the home directory cannot be determined
133/// and `NIKA_HOME` is not set.
134pub fn nika_home_result() -> Result<PathBuf, crate::NikaError> {
135    nika_home_opt().ok_or(crate::NikaError::HomeDirectoryNotFound)
136}
137
138/// Returns the user's home directory, or an error if it cannot be determined.
139///
140/// # Errors
141///
142/// Returns [`NikaError::HomeDirectoryNotFound`] if the home directory cannot be determined.
143pub fn user_home_result() -> Result<PathBuf, crate::NikaError> {
144    dirs::home_dir().ok_or(crate::NikaError::HomeDirectoryNotFound)
145}
146
147// ═══════════════════════════════════════════════════════════════════════════
148// DIRECTORY PATHS
149// ═══════════════════════════════════════════════════════════════════════════
150
151/// Returns the packages directory (`~/.nika/packages/`).
152pub fn packages_dir() -> PathBuf {
153    nika_home().join("packages")
154}
155
156/// Returns the models directory (`~/.nika/models/`).
157pub fn models_dir() -> PathBuf {
158    nika_home().join("models")
159}
160
161/// Returns the backups directory (`~/.nika/backups/`).
162pub fn backups_dir() -> PathBuf {
163    nika_home().join("backups")
164}
165
166/// Returns the cache directory (`~/.nika/cache/`).
167pub fn cache_dir() -> PathBuf {
168    nika_home().join("cache")
169}
170
171/// Returns the daemon directory (`~/.nika/daemon/`).
172/// Reserved for future native nika daemon.
173pub fn daemon_dir() -> PathBuf {
174    nika_home().join("daemon")
175}
176
177// ═══════════════════════════════════════════════════════════════════════════
178// FILE PATHS
179// ═══════════════════════════════════════════════════════════════════════════
180
181/// Returns the global config path (`~/.nika/config.toml`).
182pub fn global_config_path() -> PathBuf {
183    nika_home().join(GLOBAL_CONFIG)
184}
185
186/// Returns the global MCP config path (`~/.nika/mcp.yaml`).
187pub fn global_mcp_config_path() -> PathBuf {
188    nika_home().join(MCP_CONFIG)
189}
190
191/// Returns the registry index path (`~/.nika/packages/registry.yaml`).
192pub fn registry_index_path() -> PathBuf {
193    packages_dir().join(REGISTRY_INDEX)
194}
195
196/// Returns the daemon socket path (`~/.nika/daemon/nika.sock`).
197/// Reserved for future native nika daemon.
198pub fn daemon_socket_path() -> PathBuf {
199    daemon_dir().join(DAEMON_SOCKET)
200}
201
202/// Returns the daemon PID file path (`~/.nika/daemon/nika.pid`).
203/// Reserved for future native nika daemon.
204pub fn daemon_pid_path() -> PathBuf {
205    daemon_dir().join(DAEMON_PID)
206}
207
208// ═══════════════════════════════════════════════════════════════════════════
209// PROJECT PATHS
210// ═══════════════════════════════════════════════════════════════════════════
211
212/// Returns the project Nika directory (`.nika/`).
213pub fn project_nika_dir(project_root: &std::path::Path) -> PathBuf {
214    project_root.join(NIKA_PROJECT_DIR)
215}
216
217/// Returns the project MCP config path (`.nika/mcp.yaml`).
218pub fn project_mcp_config_path(project_root: &std::path::Path) -> PathBuf {
219    project_nika_dir(project_root).join(MCP_CONFIG)
220}
221
222/// Returns the project manifest path (`nika.yaml`).
223pub fn project_manifest_path(project_root: &std::path::Path) -> PathBuf {
224    project_root.join(NIKA_MANIFEST)
225}
226
227/// Returns the project lockfile path (`nika.lock`).
228pub fn project_lockfile_path(project_root: &std::path::Path) -> PathBuf {
229    project_root.join(NIKA_LOCKFILE)
230}
231
232/// Returns the project sessions directory (`.nika/sessions/`).
233pub fn project_sessions_dir(project_root: &std::path::Path) -> PathBuf {
234    project_nika_dir(project_root).join("sessions")
235}
236
237// ═══════════════════════════════════════════════════════════════════════════
238// PACKAGE PATHS
239// ═══════════════════════════════════════════════════════════════════════════
240
241/// Returns the directory for a specific package.
242///
243/// # Examples
244///
245/// ```rust,ignore
246/// use nika::core::paths::package_dir;
247///
248/// let dir = package_dir("@nika", "seo-audit", "1.0.0");
249/// // Returns: ~/.nika/packages/@nika/seo-audit/1.0.0/
250/// ```
251pub fn package_dir(scope: &str, name: &str, version: &str) -> PathBuf {
252    packages_dir().join(scope).join(name).join(version)
253}
254
255/// Returns the manifest path for a specific package.
256pub fn package_manifest_path(scope: &str, name: &str, version: &str) -> PathBuf {
257    package_dir(scope, name, version).join("manifest.yaml")
258}
259
260// ═══════════════════════════════════════════════════════════════════════════
261// DIRECTORY CREATION
262// ═══════════════════════════════════════════════════════════════════════════
263
264/// Ensures the Nika home directory and its subdirectories exist.
265///
266/// Creates:
267/// - `~/.nika/`
268/// - `~/.nika/packages/`
269/// - `~/.nika/models/`
270/// - `~/.nika/backups/`
271/// - `~/.nika/cache/`
272/// - `~/.nika/daemon/`
273///
274/// # Errors
275///
276/// Returns an error if directory creation fails.
277pub fn ensure_nika_home() -> std::io::Result<()> {
278    let home = nika_home();
279    std::fs::create_dir_all(&home)?;
280    std::fs::create_dir_all(home.join("packages"))?;
281    std::fs::create_dir_all(home.join("models"))?;
282    std::fs::create_dir_all(home.join("backups"))?;
283    std::fs::create_dir_all(home.join("cache"))?;
284    std::fs::create_dir_all(home.join("daemon"))?;
285    Ok(())
286}
287
288/// Ensures the project Nika directory exists.
289///
290/// Creates:
291/// - `.nika/`
292/// - `.nika/sessions/`
293pub fn ensure_project_nika_dir(project_root: &std::path::Path) -> std::io::Result<()> {
294    let nika_dir = project_nika_dir(project_root);
295    std::fs::create_dir_all(&nika_dir)?;
296    std::fs::create_dir_all(nika_dir.join("sessions"))?;
297    Ok(())
298}
299
300// ═══════════════════════════════════════════════════════════════════════════
301// TESTS
302// ═══════════════════════════════════════════════════════════════════════════
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use serial_test::serial;
308    use std::env;
309
310    /// Helper to run tests with a temporary NIKA_HOME
311    fn with_temp_nika_home<F, R>(f: F) -> R
312    where
313        F: FnOnce(&std::path::Path) -> R,
314    {
315        let temp_dir = tempfile::tempdir().unwrap();
316        let old_val = env::var(NIKA_HOME_ENV).ok();
317
318        env::set_var(NIKA_HOME_ENV, temp_dir.path());
319        let result = f(temp_dir.path());
320
321        // Restore original value
322        if let Some(val) = old_val {
323            env::set_var(NIKA_HOME_ENV, val);
324        } else {
325            env::remove_var(NIKA_HOME_ENV);
326        }
327
328        result
329    }
330
331    #[test]
332    #[serial]
333    fn test_nika_home_default() {
334        // Clear env var to test default behavior
335        let old_val = env::var(NIKA_HOME_ENV).ok();
336        env::remove_var(NIKA_HOME_ENV);
337
338        let home = nika_home();
339        assert!(home.ends_with(".nika"));
340
341        // Restore
342        if let Some(val) = old_val {
343            env::set_var(NIKA_HOME_ENV, val);
344        }
345    }
346
347    #[test]
348    #[serial]
349    fn test_nika_home_env_override() {
350        with_temp_nika_home(|temp_path| {
351            let home = nika_home();
352            assert_eq!(home, temp_path);
353        });
354    }
355
356    #[test]
357    #[serial]
358    fn test_nika_home_opt_returns_some() {
359        with_temp_nika_home(|temp_path| {
360            let home = nika_home_opt();
361            assert_eq!(home, Some(temp_path.to_path_buf()));
362        });
363    }
364
365    #[test]
366    #[serial]
367    fn test_packages_dir() {
368        with_temp_nika_home(|temp_path| {
369            let packages = packages_dir();
370            assert_eq!(packages, temp_path.join("packages"));
371        });
372    }
373
374    #[test]
375    #[serial]
376    fn test_models_dir() {
377        with_temp_nika_home(|temp_path| {
378            let models = models_dir();
379            assert_eq!(models, temp_path.join("models"));
380        });
381    }
382
383    #[test]
384    #[serial]
385    fn test_backups_dir() {
386        with_temp_nika_home(|temp_path| {
387            let backups = backups_dir();
388            assert_eq!(backups, temp_path.join("backups"));
389        });
390    }
391
392    #[test]
393    #[serial]
394    fn test_cache_dir() {
395        with_temp_nika_home(|temp_path| {
396            let cache = cache_dir();
397            assert_eq!(cache, temp_path.join("cache"));
398        });
399    }
400
401    #[test]
402    #[serial]
403    fn test_daemon_dir() {
404        with_temp_nika_home(|temp_path| {
405            let daemon = daemon_dir();
406            assert_eq!(daemon, temp_path.join("daemon"));
407        });
408    }
409
410    #[test]
411    #[serial]
412    fn test_global_config_path() {
413        with_temp_nika_home(|temp_path| {
414            let config = global_config_path();
415            assert_eq!(config, temp_path.join("config.toml"));
416        });
417    }
418
419    #[test]
420    #[serial]
421    fn test_global_mcp_config_path() {
422        with_temp_nika_home(|temp_path| {
423            let mcp = global_mcp_config_path();
424            assert_eq!(mcp, temp_path.join("mcp.yaml"));
425        });
426    }
427
428    #[test]
429    #[serial]
430    fn test_registry_index_path() {
431        with_temp_nika_home(|temp_path| {
432            let registry = registry_index_path();
433            assert_eq!(registry, temp_path.join("packages").join("registry.yaml"));
434        });
435    }
436
437    #[test]
438    #[serial]
439    fn test_daemon_socket_path() {
440        with_temp_nika_home(|temp_path| {
441            let socket = daemon_socket_path();
442            assert_eq!(socket, temp_path.join("daemon").join("nika.sock"));
443        });
444    }
445
446    #[test]
447    #[serial]
448    fn test_daemon_pid_path() {
449        with_temp_nika_home(|temp_path| {
450            let pid = daemon_pid_path();
451            assert_eq!(pid, temp_path.join("daemon").join("nika.pid"));
452        });
453    }
454
455    #[test]
456    fn test_project_paths() {
457        let project_root = std::path::Path::new("/tmp/my-project");
458
459        assert_eq!(
460            project_nika_dir(project_root),
461            PathBuf::from("/tmp/my-project/.nika")
462        );
463
464        assert_eq!(
465            project_mcp_config_path(project_root),
466            PathBuf::from("/tmp/my-project/.nika/mcp.yaml")
467        );
468
469        assert_eq!(
470            project_manifest_path(project_root),
471            PathBuf::from("/tmp/my-project/nika.yaml")
472        );
473
474        assert_eq!(
475            project_lockfile_path(project_root),
476            PathBuf::from("/tmp/my-project/nika.lock")
477        );
478
479        assert_eq!(
480            project_sessions_dir(project_root),
481            PathBuf::from("/tmp/my-project/.nika/sessions")
482        );
483    }
484
485    #[test]
486    #[serial]
487    fn test_package_dir() {
488        with_temp_nika_home(|temp_path| {
489            let pkg = package_dir("@nika", "seo-audit", "1.0.0");
490            assert_eq!(pkg, temp_path.join("packages/@nika/seo-audit/1.0.0"));
491        });
492    }
493
494    #[test]
495    #[serial]
496    fn test_package_manifest_path() {
497        with_temp_nika_home(|temp_path| {
498            let manifest = package_manifest_path("@workflows", "code-review", "2.1.0");
499            assert_eq!(
500                manifest,
501                temp_path.join("packages/@workflows/code-review/2.1.0/manifest.yaml")
502            );
503        });
504    }
505
506    #[test]
507    #[serial]
508    fn test_ensure_nika_home_creates_directories() {
509        with_temp_nika_home(|temp_path| {
510            // Remove everything first
511            let _ = std::fs::remove_dir_all(temp_path);
512
513            // Ensure creates all directories
514            ensure_nika_home().unwrap();
515
516            assert!(temp_path.exists());
517            assert!(temp_path.join("packages").exists());
518            assert!(temp_path.join("models").exists());
519            assert!(temp_path.join("backups").exists());
520            assert!(temp_path.join("cache").exists());
521            assert!(temp_path.join("daemon").exists());
522        });
523    }
524
525    #[test]
526    fn test_ensure_project_nika_dir() {
527        let temp_dir = tempfile::tempdir().unwrap();
528        let project_root = temp_dir.path();
529
530        ensure_project_nika_dir(project_root).unwrap();
531
532        assert!(project_root.join(".nika").exists());
533        assert!(project_root.join(".nika/sessions").exists());
534    }
535
536    #[test]
537    fn test_constants() {
538        assert_eq!(NIKA_HOME_ENV, "NIKA_HOME");
539        assert_eq!(NIKA_DIR_NAME, ".nika");
540        assert_eq!(NIKA_PROJECT_DIR, ".nika");
541        assert_eq!(NIKA_MANIFEST, "nika.yaml");
542        assert_eq!(NIKA_LOCKFILE, "nika.lock");
543        assert_eq!(MCP_CONFIG, "mcp.yaml");
544        assert_eq!(GLOBAL_CONFIG, "config.toml");
545        assert_eq!(REGISTRY_INDEX, "registry.yaml");
546        assert_eq!(DAEMON_SOCKET, "nika.sock");
547        assert_eq!(DAEMON_PID, "nika.pid");
548    }
549}