Skip to main content

spool/bootstrap/
layout.rs

1//! Standard `~/.spool/` directory layout.
2
3use anyhow::{Context, Result};
4use std::path::{Path, PathBuf};
5
6/// Resolves all standard paths under `~/.spool/`. Use [`SpoolLayout::resolve`]
7/// to construct from `$HOME`, or [`SpoolLayout::from_root`] for testing with
8/// a custom root.
9#[derive(Debug, Clone)]
10pub struct SpoolLayout {
11    root: PathBuf,
12}
13
14impl SpoolLayout {
15    /// Construct from the default `~/.spool/` location.
16    pub fn resolve() -> Result<Self> {
17        let home = crate::support::home_dir()
18            .context("could not resolve home directory for spool layout")?;
19        Ok(Self::from_root(home.join(".spool")))
20    }
21
22    /// Construct from an explicit root directory. Useful for tests.
23    pub fn from_root(root: PathBuf) -> Self {
24        Self { root }
25    }
26
27    /// `~/.spool/`
28    pub fn root(&self) -> &Path {
29        &self.root
30    }
31
32    /// `~/.spool/bin/`
33    pub fn bin_dir(&self) -> PathBuf {
34        self.root.join("bin")
35    }
36
37    /// `~/.spool/data/`
38    pub fn data_dir(&self) -> PathBuf {
39        self.root.join("data")
40    }
41
42    /// `~/.spool/plugins/` — empty in OSS, populated by Pro installer.
43    pub fn plugins_dir(&self) -> PathBuf {
44        self.root.join("plugins")
45    }
46
47    /// `~/.spool/version.json` — bootstrap state marker.
48    pub fn version_file(&self) -> PathBuf {
49        self.root.join("version.json")
50    }
51
52    /// `~/.spool/data/config.toml` — primary config file.
53    pub fn config_file(&self) -> PathBuf {
54        self.data_dir().join("config.toml")
55    }
56
57    /// `~/.spool/data/memory-ledger.jsonl` — append-only ledger.
58    pub fn ledger_file(&self) -> PathBuf {
59        self.data_dir().join("memory-ledger.jsonl")
60    }
61
62    /// Path of a binary inside `bin/`. Adds `.exe` on Windows automatically.
63    pub fn binary_path(&self, name: &str) -> PathBuf {
64        let exe_name = if cfg!(windows) {
65            format!("{name}.exe")
66        } else {
67            name.to_string()
68        };
69        self.bin_dir().join(exe_name)
70    }
71
72    /// Create all standard directories. Idempotent.
73    pub fn ensure_dirs(&self) -> Result<()> {
74        for dir in [
75            self.root(),
76            &self.bin_dir(),
77            &self.data_dir(),
78            &self.plugins_dir(),
79        ] {
80            std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
81        }
82        Ok(())
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use tempfile::tempdir;
90
91    #[test]
92    fn layout_should_compute_standard_paths() {
93        let temp = tempdir().unwrap();
94        let layout = SpoolLayout::from_root(temp.path().join(".spool"));
95
96        assert_eq!(layout.bin_dir(), temp.path().join(".spool/bin"));
97        assert_eq!(layout.data_dir(), temp.path().join(".spool/data"));
98        assert_eq!(layout.plugins_dir(), temp.path().join(".spool/plugins"));
99        assert_eq!(
100            layout.version_file(),
101            temp.path().join(".spool/version.json")
102        );
103        assert_eq!(
104            layout.ledger_file(),
105            temp.path().join(".spool/data/memory-ledger.jsonl")
106        );
107    }
108
109    #[test]
110    fn binary_path_should_add_exe_on_windows() {
111        let temp = tempdir().unwrap();
112        let layout = SpoolLayout::from_root(temp.path().to_path_buf());
113        let p = layout.binary_path("spool");
114        if cfg!(windows) {
115            assert!(p.to_string_lossy().ends_with("spool.exe"));
116        } else {
117            assert!(p.to_string_lossy().ends_with("/bin/spool"));
118        }
119    }
120
121    #[test]
122    fn ensure_dirs_should_create_all_directories() {
123        let temp = tempdir().unwrap();
124        let layout = SpoolLayout::from_root(temp.path().join(".spool"));
125        layout.ensure_dirs().unwrap();
126
127        assert!(layout.root().is_dir());
128        assert!(layout.bin_dir().is_dir());
129        assert!(layout.data_dir().is_dir());
130        assert!(layout.plugins_dir().is_dir());
131    }
132
133    #[test]
134    fn ensure_dirs_should_be_idempotent() {
135        let temp = tempdir().unwrap();
136        let layout = SpoolLayout::from_root(temp.path().join(".spool"));
137        layout.ensure_dirs().unwrap();
138        layout.ensure_dirs().unwrap();
139        assert!(layout.bin_dir().is_dir());
140    }
141}