spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
//! Bootstrap orchestration — runs the full first-run / upgrade flow.
//!
//! Called from the Tauri desktop app on startup. Idempotent: safe to call
//! every launch. Skips heavy steps when already complete.

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};

/// Result of `run_bootstrap`. Tauri layer can surface progress to the user.
#[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>,
}

/// Top-level bootstrap entry point.
///
/// - `bundled_binaries_dir`: where the Tauri app bundle stores the embedded
///   binaries (resolved by the desktop layer via `app.path().resource_dir()`).
/// - Returns a [`BootstrapReport`] describing what changed.
pub fn run_bootstrap(bundled_binaries_dir: &Path) -> Result<BootstrapReport> {
    let layout = SpoolLayout::resolve()?;
    run_bootstrap_with_layout(&layout, bundled_binaries_dir)
}

/// Bootstrap with an explicit layout. Useful for tests.
pub fn run_bootstrap_with_layout(
    layout: &SpoolLayout,
    bundled_binaries_dir: &Path,
) -> Result<BootstrapReport> {
    let mut report = BootstrapReport::default();

    // Step 1: ensure directory layout
    layout
        .ensure_dirs()
        .context("creating spool directory layout")?;
    report.layout_created = true;
    report
        .messages
        .push(format!("layout ready at {}", layout.root().display()));

    // Step 2: load existing state
    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,
    };

    // Step 3: release binaries if needed
    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();
    }

    // Step 4: auto-configure detected AI tools (Claude/Codex/Cursor/OpenCode)
    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());
    }

    // Step 5: configure shell PATH (best-effort)
    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:#}"));
            }
        }
    }

    // Step 6: persist state
    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)
}

/// Quick check used by the desktop app to decide whether to show the
/// onboarding UI on startup.
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;
        // Second run sees same version → no release at all
        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"))
        );
    }
}