use anyhow::Result;
use serde::Serialize;
use std::path::Path;
use super::layout::SpoolLayout;
use crate::installers::{self, ClientId, InstallContext, InstallStatus, UpdateStatus};
#[derive(Debug, Clone, Serialize)]
pub struct ClientConfigReport {
pub client: String,
pub detected: bool,
pub installed: bool,
pub status: String,
pub notes: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct AutoConfigureReport {
pub clients: Vec<ClientConfigReport>,
pub any_registered: bool,
pub hooks_installed: bool,
}
const CLIENTS: &[ClientId] = &[
ClientId::Claude,
ClientId::Codex,
ClientId::Cursor,
ClientId::OpenCode,
];
pub fn auto_configure_clients(layout: &SpoolLayout, force: bool) -> AutoConfigureReport {
let mut report = AutoConfigureReport::default();
let mcp_binary = layout.binary_path("spool-mcp");
let config_path = layout.config_file();
if !config_path.exists()
&& let Err(err) = write_default_config(&config_path)
{
eprintln!("[spool bootstrap] failed to write default config: {err:#}");
}
for client_id in CLIENTS {
let result = configure_one_client(*client_id, &mcp_binary, &config_path, force);
match result {
Ok(client_report) => {
if client_report.installed {
report.any_registered = true;
if matches!(client_id, ClientId::Claude) {
report.hooks_installed = true;
}
}
report.clients.push(client_report);
}
Err(err) => {
report.clients.push(ClientConfigReport {
client: client_id.as_str().to_string(),
detected: false,
installed: false,
status: "error".to_string(),
notes: vec![format!("{err:#}")],
});
}
}
}
report
}
fn configure_one_client(
client_id: ClientId,
mcp_binary: &Path,
config_path: &Path,
force: bool,
) -> Result<ClientConfigReport> {
let installer = installers::installer_for(client_id);
let detected = installer.detect().unwrap_or(false);
let mut report = ClientConfigReport {
client: client_id.as_str().to_string(),
detected,
installed: false,
status: "skipped".to_string(),
notes: Vec::new(),
};
if !detected {
report
.notes
.push("client not detected on this system".to_string());
return Ok(report);
}
let mut ctx = InstallContext::new(config_path.to_path_buf());
ctx.binary_path = Some(mcp_binary.to_path_buf());
ctx.force = force;
match installer.install(&ctx) {
Ok(install_report) => {
report.notes.extend(install_report.notes);
match install_report.status {
InstallStatus::Installed => {
report.installed = true;
report.status = "installed".to_string();
}
InstallStatus::Unchanged => {
report.installed = true;
report.status = "unchanged".to_string();
}
InstallStatus::Conflict => {
report.status = "conflict".to_string();
report
.notes
.push("existing entry differs; pass force=true to overwrite".to_string());
}
InstallStatus::DryRun => {
report.status = "dry_run".to_string();
}
}
}
Err(err) => {
report.status = "error".to_string();
report.notes.push(format!("{err:#}"));
}
}
if report.status == "unchanged"
&& let Ok(update_report) = installer.update(&ctx)
&& matches!(update_report.status, UpdateStatus::Updated)
{
report.notes.push(format!(
"templates refreshed: {} files",
update_report.updated_paths.len()
));
}
Ok(report)
}
fn write_default_config(path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = r#"# Spool default configuration — generated by bootstrap.
# Edit this file or use the desktop app to customize.
[vault]
# Set to your Obsidian vault root if you want vault-backed retrieval.
# Leave commented out for ledger-only operation.
# root = "/path/to/your/obsidian/vault"
[output]
default_format = "prompt"
max_chars = 12000
max_notes = 8
max_lifecycle = 5
"#;
std::fs::write(path, body)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn auto_configure_should_skip_undetected_clients() {
let temp = tempdir().unwrap();
let layout = SpoolLayout::from_root(temp.path().join(".spool"));
layout.ensure_dirs().unwrap();
let original_home = std::env::var("HOME").ok();
unsafe {
std::env::set_var("HOME", temp.path());
}
let report = auto_configure_clients(&layout, true);
if let Some(home) = original_home {
unsafe {
std::env::set_var("HOME", home);
}
} else {
unsafe {
std::env::remove_var("HOME");
}
}
assert_eq!(report.clients.len(), 4);
for c in &report.clients {
if !c.detected {
assert_eq!(c.status, "skipped");
assert!(!c.installed);
}
}
}
#[test]
fn write_default_config_creates_parent() {
let temp = tempdir().unwrap();
let path = temp.path().join("nested/.spool/data/config.toml");
write_default_config(&path).unwrap();
assert!(path.exists());
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("[vault]"));
assert!(content.contains("[output]"));
}
}