use anyhow::{Context, Result};
use std::path::Path;
use super::auto_configure::{self, AutoConfigureReport};
use super::layout::SpoolLayout;
use super::path_config::{self, PathConfigReport};
use super::release::{ReleaseReport, release_binaries};
use super::state::{BootstrapState, ServiceVersion};
#[derive(Debug, Clone, Default)]
pub struct BootstrapReport {
pub layout_created: bool,
pub release: Option<ReleaseReport>,
pub auto_configure: Option<AutoConfigureReport>,
pub path_config: Option<PathConfigReport>,
pub state_after: BootstrapState,
pub messages: Vec<String>,
}
pub fn run_bootstrap(bundled_binaries_dir: &Path) -> Result<BootstrapReport> {
let layout = SpoolLayout::resolve()?;
run_bootstrap_with_layout(&layout, bundled_binaries_dir)
}
pub fn run_bootstrap_with_layout(
layout: &SpoolLayout,
bundled_binaries_dir: &Path,
) -> Result<BootstrapReport> {
let mut report = BootstrapReport::default();
layout
.ensure_dirs()
.context("creating spool directory layout")?;
report.layout_created = true;
report
.messages
.push(format!("layout ready at {}", layout.root().display()));
let mut state =
BootstrapState::load(&layout.version_file()).context("loading bootstrap state")?;
let current_version = ServiceVersion::current();
let needs_release = match &state.service {
Some(installed) => installed.version != current_version.version,
None => true,
};
let binaries_ready;
if needs_release && bundled_binaries_dir.is_dir() {
let release = release_binaries(bundled_binaries_dir, &layout.bin_dir(), needs_release)
.context("releasing bundled binaries")?;
report.messages.push(format!(
"released {} binaries (skipped {})",
release.copied.len(),
release.skipped.len()
));
binaries_ready = !release.copied.is_empty() || layout.binary_path("spool-mcp").exists();
report.release = Some(release);
state.service = Some(current_version);
} else if !bundled_binaries_dir.is_dir() {
report.messages.push(format!(
"bundled binaries directory missing — skipping release: {}",
bundled_binaries_dir.display()
));
binaries_ready = layout.binary_path("spool-mcp").exists();
} else {
report
.messages
.push("binaries already up to date".to_string());
binaries_ready = layout.binary_path("spool-mcp").exists();
}
if binaries_ready {
let cfg_report = auto_configure::auto_configure_clients(layout, false);
if cfg_report.any_registered {
state.mcp_registered = true;
}
if cfg_report.hooks_installed {
state.hooks_installed = true;
}
report.messages.push(format!(
"auto-configured {} client(s); MCP registered: {}",
cfg_report.clients.iter().filter(|c| c.installed).count(),
cfg_report.any_registered,
));
report.auto_configure = Some(cfg_report);
} else {
report
.messages
.push("skipping auto-configure: spool-mcp binary not available".to_string());
}
if binaries_ready {
match path_config::configure_path(&layout.bin_dir()) {
Ok(path_report) => {
if path_report.configured {
state.path_configured = true;
}
report.messages.push(format!(
"PATH configured: {} new file(s), {} already present",
path_report.modified_files.len(),
path_report.already_configured_files.len(),
));
report.path_config = Some(path_report);
}
Err(err) => {
report
.messages
.push(format!("PATH configuration failed: {err:#}"));
}
}
}
state.gui_version = Some(env!("CARGO_PKG_VERSION").to_string());
state
.save(&layout.version_file())
.context("persisting bootstrap state")?;
report.state_after = state;
Ok(report)
}
pub fn is_first_run() -> bool {
let Ok(layout) = SpoolLayout::resolve() else {
return true;
};
let Ok(state) = BootstrapState::load(&layout.version_file()) else {
return true;
};
!state.is_bootstrapped()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn write_fake_binary(dir: &Path, name: &str) {
let exe_name = if cfg!(windows) {
format!("{name}.exe")
} else {
name.to_string()
};
fs::write(dir.join(&exe_name), format!("fake {}", name)).unwrap();
}
#[test]
fn bootstrap_should_create_layout_and_release_binaries() {
let temp = tempdir().unwrap();
let layout = SpoolLayout::from_root(temp.path().join(".spool"));
let bundled = temp.path().join("bundled");
fs::create_dir_all(&bundled).unwrap();
write_fake_binary(&bundled, "spool");
write_fake_binary(&bundled, "spool-mcp");
write_fake_binary(&bundled, "spool-daemon");
let report = run_bootstrap_with_layout(&layout, &bundled).unwrap();
assert!(report.layout_created);
assert!(layout.bin_dir().is_dir());
assert!(layout.data_dir().is_dir());
assert!(layout.plugins_dir().is_dir());
let release = report.release.expect("should have released");
assert_eq!(release.copied.len(), 3);
assert!(report.state_after.is_bootstrapped());
}
#[test]
fn bootstrap_should_be_idempotent() {
let temp = tempdir().unwrap();
let layout = SpoolLayout::from_root(temp.path().join(".spool"));
let bundled = temp.path().join("bundled");
fs::create_dir_all(&bundled).unwrap();
write_fake_binary(&bundled, "spool");
run_bootstrap_with_layout(&layout, &bundled).unwrap();
let report2 = run_bootstrap_with_layout(&layout, &bundled).unwrap();
let release2 = report2.release;
assert!(release2.is_none() || release2.unwrap().copied.is_empty());
}
#[test]
fn bootstrap_should_skip_release_when_bundled_dir_missing() {
let temp = tempdir().unwrap();
let layout = SpoolLayout::from_root(temp.path().join(".spool"));
let bundled = temp.path().join("nonexistent");
let report = run_bootstrap_with_layout(&layout, &bundled).unwrap();
assert!(report.release.is_none());
assert!(
report
.messages
.iter()
.any(|m| m.contains("bundled binaries directory missing"))
);
}
}