spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
//! Standard `~/.spool/` directory layout.

use anyhow::{Context, Result};
use std::path::{Path, PathBuf};

/// Resolves all standard paths under `~/.spool/`. Use [`SpoolLayout::resolve`]
/// to construct from `$HOME`, or [`SpoolLayout::from_root`] for testing with
/// a custom root.
#[derive(Debug, Clone)]
pub struct SpoolLayout {
    root: PathBuf,
}

impl SpoolLayout {
    /// Construct from the default `~/.spool/` location.
    pub fn resolve() -> Result<Self> {
        let home = crate::support::home_dir()
            .context("could not resolve home directory for spool layout")?;
        Ok(Self::from_root(home.join(".spool")))
    }

    /// Construct from an explicit root directory. Useful for tests.
    pub fn from_root(root: PathBuf) -> Self {
        Self { root }
    }

    /// `~/.spool/`
    pub fn root(&self) -> &Path {
        &self.root
    }

    /// `~/.spool/bin/`
    pub fn bin_dir(&self) -> PathBuf {
        self.root.join("bin")
    }

    /// `~/.spool/data/`
    pub fn data_dir(&self) -> PathBuf {
        self.root.join("data")
    }

    /// `~/.spool/plugins/` — empty in OSS, populated by Pro installer.
    pub fn plugins_dir(&self) -> PathBuf {
        self.root.join("plugins")
    }

    /// `~/.spool/version.json` — bootstrap state marker.
    pub fn version_file(&self) -> PathBuf {
        self.root.join("version.json")
    }

    /// `~/.spool/data/config.toml` — primary config file.
    pub fn config_file(&self) -> PathBuf {
        self.data_dir().join("config.toml")
    }

    /// `~/.spool/data/memory-ledger.jsonl` — append-only ledger.
    pub fn ledger_file(&self) -> PathBuf {
        self.data_dir().join("memory-ledger.jsonl")
    }

    /// Path of a binary inside `bin/`. Adds `.exe` on Windows automatically.
    pub fn binary_path(&self, name: &str) -> PathBuf {
        let exe_name = if cfg!(windows) {
            format!("{name}.exe")
        } else {
            name.to_string()
        };
        self.bin_dir().join(exe_name)
    }

    /// Create all standard directories. Idempotent.
    pub fn ensure_dirs(&self) -> Result<()> {
        for dir in [
            self.root(),
            &self.bin_dir(),
            &self.data_dir(),
            &self.plugins_dir(),
        ] {
            std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    #[test]
    fn layout_should_compute_standard_paths() {
        let temp = tempdir().unwrap();
        let layout = SpoolLayout::from_root(temp.path().join(".spool"));

        assert_eq!(layout.bin_dir(), temp.path().join(".spool/bin"));
        assert_eq!(layout.data_dir(), temp.path().join(".spool/data"));
        assert_eq!(layout.plugins_dir(), temp.path().join(".spool/plugins"));
        assert_eq!(
            layout.version_file(),
            temp.path().join(".spool/version.json")
        );
        assert_eq!(
            layout.ledger_file(),
            temp.path().join(".spool/data/memory-ledger.jsonl")
        );
    }

    #[test]
    fn binary_path_should_add_exe_on_windows() {
        let temp = tempdir().unwrap();
        let layout = SpoolLayout::from_root(temp.path().to_path_buf());
        let p = layout.binary_path("spool");
        if cfg!(windows) {
            assert!(p.to_string_lossy().ends_with("spool.exe"));
        } else {
            assert!(p.to_string_lossy().ends_with("/bin/spool"));
        }
    }

    #[test]
    fn ensure_dirs_should_create_all_directories() {
        let temp = tempdir().unwrap();
        let layout = SpoolLayout::from_root(temp.path().join(".spool"));
        layout.ensure_dirs().unwrap();

        assert!(layout.root().is_dir());
        assert!(layout.bin_dir().is_dir());
        assert!(layout.data_dir().is_dir());
        assert!(layout.plugins_dir().is_dir());
    }

    #[test]
    fn ensure_dirs_should_be_idempotent() {
        let temp = tempdir().unwrap();
        let layout = SpoolLayout::from_root(temp.path().join(".spool"));
        layout.ensure_dirs().unwrap();
        layout.ensure_dirs().unwrap();
        assert!(layout.bin_dir().is_dir());
    }
}