Skip to main content

spool/bootstrap/
state.rs

1//! Bootstrap state management — `~/.spool/version.json`.
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::path::Path;
6
7/// Service version (the spool/spool-mcp/spool-daemon binaries). Tracked
8/// independently of the GUI version so the service can be updated without
9/// reinstalling the desktop app.
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11pub struct ServiceVersion {
12    pub version: String,
13    /// ISO 8601 timestamp of when the binaries were released.
14    pub released_at: String,
15}
16
17impl ServiceVersion {
18    pub fn current() -> Self {
19        Self {
20            version: env!("CARGO_PKG_VERSION").to_string(),
21            released_at: chrono_now(),
22        }
23    }
24}
25
26/// State persisted to `~/.spool/version.json` after first-run bootstrap.
27#[derive(Debug, Clone, Serialize, Deserialize, Default)]
28pub struct BootstrapState {
29    /// Currently installed service version. `None` means bootstrap has not
30    /// completed yet.
31    pub service: Option<ServiceVersion>,
32    /// GUI/desktop app version that ran the most recent bootstrap.
33    pub gui_version: Option<String>,
34    /// Whether MCP integration was registered for at least one AI tool.
35    pub mcp_registered: bool,
36    /// Whether Claude Code hooks were installed.
37    pub hooks_installed: bool,
38    /// Whether `~/.spool/bin` was added to the user's shell PATH.
39    pub path_configured: bool,
40}
41
42impl BootstrapState {
43    /// Load state from `version.json`. Returns default state if the file
44    /// does not exist.
45    pub fn load(version_file: &Path) -> Result<Self> {
46        if !version_file.exists() {
47            return Ok(Self::default());
48        }
49        let content = std::fs::read_to_string(version_file)
50            .with_context(|| format!("reading {}", version_file.display()))?;
51        let state: Self = serde_json::from_str(&content)
52            .with_context(|| format!("parsing {}", version_file.display()))?;
53        Ok(state)
54    }
55
56    /// Persist state to `version.json`.
57    pub fn save(&self, version_file: &Path) -> Result<()> {
58        let json = serde_json::to_string_pretty(self).context("serializing bootstrap state")?;
59        if let Some(parent) = version_file.parent() {
60            std::fs::create_dir_all(parent)
61                .with_context(|| format!("creating {}", parent.display()))?;
62        }
63        std::fs::write(version_file, json)
64            .with_context(|| format!("writing {}", version_file.display()))?;
65        Ok(())
66    }
67
68    /// Whether bootstrap has run successfully at least once.
69    pub fn is_bootstrapped(&self) -> bool {
70        self.service.is_some()
71    }
72}
73
74/// Best-effort RFC3339 timestamp without dragging in `chrono`. Falls back to
75/// Unix epoch seconds as a string if system time is unavailable.
76fn chrono_now() -> String {
77    use std::time::{SystemTime, UNIX_EPOCH};
78    SystemTime::now()
79        .duration_since(UNIX_EPOCH)
80        .map(|d| d.as_secs().to_string())
81        .unwrap_or_else(|_| "0".to_string())
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use tempfile::tempdir;
88
89    #[test]
90    fn load_returns_default_when_file_missing() {
91        let temp = tempdir().unwrap();
92        let path = temp.path().join("version.json");
93        let state = BootstrapState::load(&path).unwrap();
94        assert!(!state.is_bootstrapped());
95        assert!(!state.mcp_registered);
96        assert!(!state.hooks_installed);
97        assert!(!state.path_configured);
98    }
99
100    #[test]
101    fn save_then_load_round_trips() {
102        let temp = tempdir().unwrap();
103        let path = temp.path().join("version.json");
104
105        let state = BootstrapState {
106            service: Some(ServiceVersion {
107                version: "0.1.2".to_string(),
108                released_at: "1731878400".to_string(),
109            }),
110            gui_version: Some("0.1.0".to_string()),
111            mcp_registered: true,
112            hooks_installed: true,
113            path_configured: false,
114        };
115
116        state.save(&path).unwrap();
117        let loaded = BootstrapState::load(&path).unwrap();
118        assert!(loaded.is_bootstrapped());
119        assert_eq!(loaded.service.unwrap().version, "0.1.2");
120        assert!(loaded.mcp_registered);
121        assert!(loaded.hooks_installed);
122        assert!(!loaded.path_configured);
123    }
124
125    #[test]
126    fn save_creates_parent_directory() {
127        let temp = tempdir().unwrap();
128        let path = temp.path().join("nested/.spool/version.json");
129        let state = BootstrapState::default();
130        state.save(&path).unwrap();
131        assert!(path.exists());
132    }
133}