spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
//! Bootstrap state management — `~/.spool/version.json`.

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;

/// Service version (the spool/spool-mcp/spool-daemon binaries). Tracked
/// independently of the GUI version so the service can be updated without
/// reinstalling the desktop app.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ServiceVersion {
    pub version: String,
    /// ISO 8601 timestamp of when the binaries were released.
    pub released_at: String,
}

impl ServiceVersion {
    pub fn current() -> Self {
        Self {
            version: env!("CARGO_PKG_VERSION").to_string(),
            released_at: chrono_now(),
        }
    }
}

/// State persisted to `~/.spool/version.json` after first-run bootstrap.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BootstrapState {
    /// Currently installed service version. `None` means bootstrap has not
    /// completed yet.
    pub service: Option<ServiceVersion>,
    /// GUI/desktop app version that ran the most recent bootstrap.
    pub gui_version: Option<String>,
    /// Whether MCP integration was registered for at least one AI tool.
    pub mcp_registered: bool,
    /// Whether Claude Code hooks were installed.
    pub hooks_installed: bool,
    /// Whether `~/.spool/bin` was added to the user's shell PATH.
    pub path_configured: bool,
}

impl BootstrapState {
    /// Load state from `version.json`. Returns default state if the file
    /// does not exist.
    pub fn load(version_file: &Path) -> Result<Self> {
        if !version_file.exists() {
            return Ok(Self::default());
        }
        let content = std::fs::read_to_string(version_file)
            .with_context(|| format!("reading {}", version_file.display()))?;
        let state: Self = serde_json::from_str(&content)
            .with_context(|| format!("parsing {}", version_file.display()))?;
        Ok(state)
    }

    /// Persist state to `version.json`.
    pub fn save(&self, version_file: &Path) -> Result<()> {
        let json = serde_json::to_string_pretty(self).context("serializing bootstrap state")?;
        if let Some(parent) = version_file.parent() {
            std::fs::create_dir_all(parent)
                .with_context(|| format!("creating {}", parent.display()))?;
        }
        std::fs::write(version_file, json)
            .with_context(|| format!("writing {}", version_file.display()))?;
        Ok(())
    }

    /// Whether bootstrap has run successfully at least once.
    pub fn is_bootstrapped(&self) -> bool {
        self.service.is_some()
    }
}

/// Best-effort RFC3339 timestamp without dragging in `chrono`. Falls back to
/// Unix epoch seconds as a string if system time is unavailable.
fn chrono_now() -> String {
    use std::time::{SystemTime, UNIX_EPOCH};
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs().to_string())
        .unwrap_or_else(|_| "0".to_string())
}

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

    #[test]
    fn load_returns_default_when_file_missing() {
        let temp = tempdir().unwrap();
        let path = temp.path().join("version.json");
        let state = BootstrapState::load(&path).unwrap();
        assert!(!state.is_bootstrapped());
        assert!(!state.mcp_registered);
        assert!(!state.hooks_installed);
        assert!(!state.path_configured);
    }

    #[test]
    fn save_then_load_round_trips() {
        let temp = tempdir().unwrap();
        let path = temp.path().join("version.json");

        let state = BootstrapState {
            service: Some(ServiceVersion {
                version: "0.1.2".to_string(),
                released_at: "1731878400".to_string(),
            }),
            gui_version: Some("0.1.0".to_string()),
            mcp_registered: true,
            hooks_installed: true,
            path_configured: false,
        };

        state.save(&path).unwrap();
        let loaded = BootstrapState::load(&path).unwrap();
        assert!(loaded.is_bootstrapped());
        assert_eq!(loaded.service.unwrap().version, "0.1.2");
        assert!(loaded.mcp_registered);
        assert!(loaded.hooks_installed);
        assert!(!loaded.path_configured);
    }

    #[test]
    fn save_creates_parent_directory() {
        let temp = tempdir().unwrap();
        let path = temp.path().join("nested/.spool/version.json");
        let state = BootstrapState::default();
        state.save(&path).unwrap();
        assert!(path.exists());
    }
}