1use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::path::Path;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11pub struct ServiceVersion {
12 pub version: String,
13 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
28pub struct BootstrapState {
29 pub service: Option<ServiceVersion>,
32 pub gui_version: Option<String>,
34 pub mcp_registered: bool,
36 pub hooks_installed: bool,
38 pub path_configured: bool,
40}
41
42impl BootstrapState {
43 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 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 pub fn is_bootstrapped(&self) -> bool {
70 self.service.is_some()
71 }
72}
73
74fn 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}