spool/bootstrap/
orchestrator.rs1use anyhow::{Context, Result};
7use std::path::Path;
8
9use super::auto_configure::{self, AutoConfigureReport};
10use super::layout::SpoolLayout;
11use super::path_config::{self, PathConfigReport};
12use super::release::{ReleaseReport, release_binaries};
13use super::state::{BootstrapState, ServiceVersion};
14
15#[derive(Debug, Clone, Default)]
17pub struct BootstrapReport {
18 pub layout_created: bool,
19 pub release: Option<ReleaseReport>,
20 pub auto_configure: Option<AutoConfigureReport>,
21 pub path_config: Option<PathConfigReport>,
22 pub state_after: BootstrapState,
23 pub messages: Vec<String>,
24}
25
26pub fn run_bootstrap(bundled_binaries_dir: &Path) -> Result<BootstrapReport> {
32 let layout = SpoolLayout::resolve()?;
33 run_bootstrap_with_layout(&layout, bundled_binaries_dir)
34}
35
36pub fn run_bootstrap_with_layout(
38 layout: &SpoolLayout,
39 bundled_binaries_dir: &Path,
40) -> Result<BootstrapReport> {
41 let mut report = BootstrapReport::default();
42
43 layout
45 .ensure_dirs()
46 .context("creating spool directory layout")?;
47 report.layout_created = true;
48 report
49 .messages
50 .push(format!("layout ready at {}", layout.root().display()));
51
52 let mut state =
54 BootstrapState::load(&layout.version_file()).context("loading bootstrap state")?;
55
56 let current_version = ServiceVersion::current();
57 let needs_release = match &state.service {
58 Some(installed) => installed.version != current_version.version,
59 None => true,
60 };
61
62 let binaries_ready;
64 if needs_release && bundled_binaries_dir.is_dir() {
65 let release = release_binaries(bundled_binaries_dir, &layout.bin_dir(), needs_release)
66 .context("releasing bundled binaries")?;
67 report.messages.push(format!(
68 "released {} binaries (skipped {})",
69 release.copied.len(),
70 release.skipped.len()
71 ));
72 binaries_ready = !release.copied.is_empty() || layout.binary_path("spool-mcp").exists();
73 report.release = Some(release);
74 state.service = Some(current_version);
75 } else if !bundled_binaries_dir.is_dir() {
76 report.messages.push(format!(
77 "bundled binaries directory missing — skipping release: {}",
78 bundled_binaries_dir.display()
79 ));
80 binaries_ready = layout.binary_path("spool-mcp").exists();
81 } else {
82 report
83 .messages
84 .push("binaries already up to date".to_string());
85 binaries_ready = layout.binary_path("spool-mcp").exists();
86 }
87
88 if binaries_ready {
90 let cfg_report = auto_configure::auto_configure_clients(layout, false);
91 if cfg_report.any_registered {
92 state.mcp_registered = true;
93 }
94 if cfg_report.hooks_installed {
95 state.hooks_installed = true;
96 }
97 report.messages.push(format!(
98 "auto-configured {} client(s); MCP registered: {}",
99 cfg_report.clients.iter().filter(|c| c.installed).count(),
100 cfg_report.any_registered,
101 ));
102 report.auto_configure = Some(cfg_report);
103 } else {
104 report
105 .messages
106 .push("skipping auto-configure: spool-mcp binary not available".to_string());
107 }
108
109 if binaries_ready {
111 match path_config::configure_path(&layout.bin_dir()) {
112 Ok(path_report) => {
113 if path_report.configured {
114 state.path_configured = true;
115 }
116 report.messages.push(format!(
117 "PATH configured: {} new file(s), {} already present",
118 path_report.modified_files.len(),
119 path_report.already_configured_files.len(),
120 ));
121 report.path_config = Some(path_report);
122 }
123 Err(err) => {
124 report
125 .messages
126 .push(format!("PATH configuration failed: {err:#}"));
127 }
128 }
129 }
130
131 state.gui_version = Some(env!("CARGO_PKG_VERSION").to_string());
133 state
134 .save(&layout.version_file())
135 .context("persisting bootstrap state")?;
136 report.state_after = state;
137
138 Ok(report)
139}
140
141pub fn is_first_run() -> bool {
144 let Ok(layout) = SpoolLayout::resolve() else {
145 return true;
146 };
147 let Ok(state) = BootstrapState::load(&layout.version_file()) else {
148 return true;
149 };
150 !state.is_bootstrapped()
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use std::fs;
157 use tempfile::tempdir;
158
159 fn write_fake_binary(dir: &Path, name: &str) {
160 let exe_name = if cfg!(windows) {
161 format!("{name}.exe")
162 } else {
163 name.to_string()
164 };
165 fs::write(dir.join(&exe_name), format!("fake {}", name)).unwrap();
166 }
167
168 #[test]
169 fn bootstrap_should_create_layout_and_release_binaries() {
170 let temp = tempdir().unwrap();
171 let layout = SpoolLayout::from_root(temp.path().join(".spool"));
172
173 let bundled = temp.path().join("bundled");
174 fs::create_dir_all(&bundled).unwrap();
175 write_fake_binary(&bundled, "spool");
176 write_fake_binary(&bundled, "spool-mcp");
177 write_fake_binary(&bundled, "spool-daemon");
178
179 let report = run_bootstrap_with_layout(&layout, &bundled).unwrap();
180
181 assert!(report.layout_created);
182 assert!(layout.bin_dir().is_dir());
183 assert!(layout.data_dir().is_dir());
184 assert!(layout.plugins_dir().is_dir());
185
186 let release = report.release.expect("should have released");
187 assert_eq!(release.copied.len(), 3);
188 assert!(report.state_after.is_bootstrapped());
189 }
190
191 #[test]
192 fn bootstrap_should_be_idempotent() {
193 let temp = tempdir().unwrap();
194 let layout = SpoolLayout::from_root(temp.path().join(".spool"));
195 let bundled = temp.path().join("bundled");
196 fs::create_dir_all(&bundled).unwrap();
197 write_fake_binary(&bundled, "spool");
198
199 run_bootstrap_with_layout(&layout, &bundled).unwrap();
200 let report2 = run_bootstrap_with_layout(&layout, &bundled).unwrap();
201
202 let release2 = report2.release;
203 assert!(release2.is_none() || release2.unwrap().copied.is_empty());
205 }
206
207 #[test]
208 fn bootstrap_should_skip_release_when_bundled_dir_missing() {
209 let temp = tempdir().unwrap();
210 let layout = SpoolLayout::from_root(temp.path().join(".spool"));
211 let bundled = temp.path().join("nonexistent");
212
213 let report = run_bootstrap_with_layout(&layout, &bundled).unwrap();
214 assert!(report.release.is_none());
215 assert!(
216 report
217 .messages
218 .iter()
219 .any(|m| m.contains("bundled binaries directory missing"))
220 );
221 }
222}