use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ServiceVersion {
pub version: String,
pub released_at: String,
}
impl ServiceVersion {
pub fn current() -> Self {
Self {
version: env!("CARGO_PKG_VERSION").to_string(),
released_at: chrono_now(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BootstrapState {
pub service: Option<ServiceVersion>,
pub gui_version: Option<String>,
pub mcp_registered: bool,
pub hooks_installed: bool,
pub path_configured: bool,
}
impl BootstrapState {
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)
}
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(())
}
pub fn is_bootstrapped(&self) -> bool {
self.service.is_some()
}
}
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());
}
}