crosslink 0.8.0

A synced issue tracker CLI for multi-agent AI development
Documentation
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;

/// Tracks the one-time bootstrap phase of a hub branch.
///
/// New hubs require inherently unsigned commits (init, key publication)
/// before signing can be configured. This state lets enforcement
/// distinguish the bootstrap phase from normal operation.
///
/// Lifecycle:
/// 1. `init_cache()` writes `status: "pending"` in the first commit.
/// 2. `crosslink trust approve` sets `status: "complete"`.
/// 3. Enforcement blocks with actionable guidance when pending; filters
///    bootstrap commits (identified by message prefix) when complete.
///
/// Stored at `meta/bootstrap.json` on the hub branch.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BootstrapState {
    /// `"pending"` during setup, `"complete"` after first `trust approve`.
    pub status: String,
    /// ISO 8601 timestamp when bootstrap was completed.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub completed_at: Option<String>,
}

/// Commit message prefixes that identify inherently-unsigned bootstrap commits.
/// These are generated by `init_cache()` and `ensure_agent_key_published()`.
const BOOTSTRAP_MESSAGE_PREFIXES: &[&str] = &[
    "Initialize crosslink/hub branch",
    "trust: publish key for agent",
    "sync: auto-stage dirty hub state",
    "bootstrap: register agent",
];

/// Check whether a commit message identifies a bootstrap commit.
pub fn is_bootstrap_message(message: &str) -> bool {
    BOOTSTRAP_MESSAGE_PREFIXES
        .iter()
        .any(|prefix| message.starts_with(prefix))
}

/// Read bootstrap state from `meta/bootstrap.json` in the cache directory.
///
/// Returns `None` if the file does not exist (backwards compat with old repos)
/// or cannot be parsed.
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
        }
    }
}

/// Write bootstrap state to `meta/bootstrap.json`.
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(())
}

/// Mark bootstrap as complete. Called by `trust approve` on first approval.
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"));
    }
}