use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BootstrapState {
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub completed_at: Option<String>,
}
const BOOTSTRAP_MESSAGE_PREFIXES: &[&str] = &[
"Initialize crosslink/hub branch",
"trust: publish key for agent",
"sync: auto-stage dirty hub state",
"bootstrap: register agent",
];
pub fn is_bootstrap_message(message: &str) -> bool {
BOOTSTRAP_MESSAGE_PREFIXES
.iter()
.any(|prefix| message.starts_with(prefix))
}
pub fn read_bootstrap_state(cache_dir: &Path) -> Option<BootstrapState> {
let path = cache_dir.join("meta").join("bootstrap.json");
let content = std::fs::read_to_string(&path).ok()?;
match serde_json::from_str(&content) {
Ok(state) => Some(state),
Err(e) => {
tracing::warn!("failed to parse bootstrap.json: {e}");
None
}
}
}
pub fn write_bootstrap_state(cache_dir: &Path, state: &BootstrapState) -> Result<()> {
let meta_dir = cache_dir.join("meta");
std::fs::create_dir_all(&meta_dir)
.with_context(|| format!("Failed to create {}", meta_dir.display()))?;
let path = meta_dir.join("bootstrap.json");
let json =
serde_json::to_string_pretty(state).context("Failed to serialize bootstrap state")?;
std::fs::write(&path, json).with_context(|| format!("Failed to write {}", path.display()))?;
Ok(())
}
pub fn complete_bootstrap(cache_dir: &Path) -> Result<()> {
let mut state = read_bootstrap_state(cache_dir).unwrap_or_else(|| BootstrapState {
status: "pending".to_string(),
completed_at: None,
});
state.status = "complete".to_string();
state.completed_at = Some(chrono::Utc::now().to_rfc3339());
write_bootstrap_state(cache_dir, &state)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_read_missing_returns_none() {
let dir = tempdir().unwrap();
assert!(read_bootstrap_state(dir.path()).is_none());
}
#[test]
fn test_write_read_roundtrip() {
let dir = tempdir().unwrap();
let state = BootstrapState {
status: "pending".to_string(),
completed_at: None,
};
write_bootstrap_state(dir.path(), &state).unwrap();
let loaded = read_bootstrap_state(dir.path()).unwrap();
assert_eq!(loaded.status, "pending");
assert!(loaded.completed_at.is_none());
}
#[test]
fn test_complete_bootstrap() {
let dir = tempdir().unwrap();
write_bootstrap_state(
dir.path(),
&BootstrapState {
status: "pending".to_string(),
completed_at: None,
},
)
.unwrap();
complete_bootstrap(dir.path()).unwrap();
let state = read_bootstrap_state(dir.path()).unwrap();
assert_eq!(state.status, "complete");
assert!(state.completed_at.is_some());
}
#[test]
fn test_complete_without_prior_state() {
let dir = tempdir().unwrap();
complete_bootstrap(dir.path()).unwrap();
let state = read_bootstrap_state(dir.path()).unwrap();
assert_eq!(state.status, "complete");
assert!(state.completed_at.is_some());
}
#[test]
fn test_malformed_json_returns_none() {
let dir = tempdir().unwrap();
let meta = dir.path().join("meta");
std::fs::create_dir_all(&meta).unwrap();
std::fs::write(meta.join("bootstrap.json"), "not json").unwrap();
assert!(read_bootstrap_state(dir.path()).is_none());
}
#[test]
fn test_is_bootstrap_message() {
assert!(is_bootstrap_message("Initialize crosslink/hub branch"));
assert!(is_bootstrap_message("trust: publish key for agent 'foo'"));
assert!(is_bootstrap_message(
"sync: auto-stage dirty hub state (recovery)"
));
assert!(is_bootstrap_message("bootstrap: register agent 'abc'"));
assert!(!is_bootstrap_message("some random commit"));
assert!(!is_bootstrap_message("fix: a real commit"));
}
}