codeswarm-adapters 0.8.10

Reusable ACP and native coding-agent adapters for CodeSwarm
Documentation
//! Startup configuration and saved-roster restoration.
//!
//! The launcher runs before a session or adapter exists.  This module keeps
//! that decision independent from the terminal UI: a saved roster is only a
//! request to restore agents that still exist in the current catalog.  The
//! catalog is authoritative, so stale identities are discarded and an empty
//! result always opens the store instead of auto-starting detected agents.

use std::path::Path;

use serde::{Deserialize, Serialize};

/// One persisted roster position. Slots are independent: the same agent may
/// occupy several positions and each position may select a different model.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct RosterSlot {
    #[serde(alias = "identity")]
    pub agent: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
}

#[derive(Debug, Deserialize)]
struct SettingsFile {
    launcher: Option<LauncherSettings>,
}

#[derive(Debug, Deserialize)]
struct LauncherSettings {
    roster: Option<SavedRoster>,
}

#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum SavedRoster {
    Slots(Vec<RosterSlot>),
    Legacy(String),
}

/// The action the bare launcher should take after reading persisted state.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum LaunchDecision {
    /// Restore this roster in its persisted order.
    Restore { identities: Vec<String> },
    /// Show the agent store so the user can choose a roster.
    OpenStore,
}

impl LaunchDecision {
    /// Whether this decision contains a usable saved roster.
    pub fn should_restore(&self) -> bool {
        matches!(self, Self::Restore { identities } if !identities.is_empty())
    }

    /// Return the identities to restore, or an empty slice for the store.
    pub fn identities(&self) -> &[String] {
        match self {
            Self::Restore { identities } => identities,
            Self::OpenStore => &[],
        }
    }
}

/// Parse `launcher.roster` from a persisted CodeSwarm settings document.
///
/// Each non-empty line is one identity.  Parsing failures, missing settings,
/// and values of the wrong type are treated as an empty saved roster; startup
/// must remain safe when a user has a truncated or hand-edited settings file.
pub fn parse_saved_roster(settings_json: &str) -> Vec<String> {
    parse_saved_slots(settings_json)
        .into_iter()
        .map(|slot| slot.agent)
        .collect()
}

/// Parse the slot-based roster format. The former newline-separated identity
/// string is accepted as a lossless model-free migration input.
pub fn parse_saved_slots(settings_json: &str) -> Vec<RosterSlot> {
    let Ok(settings) = serde_json::from_str::<SettingsFile>(settings_json) else {
        return Vec::new();
    };
    settings
        .launcher
        .and_then(|launcher| launcher.roster)
        .map(|roster| match roster {
            SavedRoster::Slots(slots) => slots
                .into_iter()
                .filter_map(|mut slot| {
                    slot.agent = slot.agent.trim().to_owned();
                    (!slot.agent.is_empty()).then_some(slot)
                })
                .collect(),
            SavedRoster::Legacy(roster) => roster
                .lines()
                .map(str::trim)
                .filter(|identity| !identity.is_empty())
                .map(|agent| RosterSlot {
                    agent: agent.to_owned(),
                    model: None,
                })
                .collect(),
        })
        .unwrap_or_default()
}

/// Read and parse a persisted settings file.
///
/// A missing or unreadable file has the same safe startup behavior as a
/// malformed file: no roster is restored.  The launcher can then open the
/// store without guessing which detected agent should be started.
pub fn read_saved_roster(path: impl AsRef<Path>) -> Vec<String> {
    std::fs::read_to_string(path)
        .ok()
        .map_or_else(Vec::new, |settings| parse_saved_roster(&settings))
}

/// Resolve a saved roster against the currently known canonical identities.
///
/// Resolution is case-insensitive and returns the catalog's spelling.
/// Persisted order is retained, including
/// repeated identities; the launcher does not silently reorder a user's
/// roster.  Unknown or removed identities are filtered out.
pub fn resolve_saved_roster(settings_json: &str, available_identities: &[String]) -> Vec<String> {
    resolve_saved_slots(settings_json, available_identities)
        .into_iter()
        .map(|slot| slot.agent)
        .collect()
}

/// Resolve saved slots while retaining their per-slot model choices.
pub fn resolve_saved_slots(
    settings_json: &str,
    available_identities: &[String],
) -> Vec<RosterSlot> {
    parse_saved_slots(settings_json)
        .into_iter()
        .filter_map(|mut saved| {
            available_identities
                .iter()
                .find(|available| available.eq_ignore_ascii_case(&saved.agent))
                .map(|available| {
                    saved.agent.clone_from(available);
                    saved
                })
        })
        .collect()
}

/// Decide whether bare launch restores the saved roster or opens the store.
///
/// This intentionally never falls back to preferred/detected agents.  Agent
/// detection may preselect entries once the store is visible, but it must not
/// turn a missing or stale saved roster into an unexpected session.
pub fn launch_decision(settings_json: &str, available_identities: &[String]) -> LaunchDecision {
    let identities = resolve_saved_roster(settings_json, available_identities);
    if identities.is_empty() {
        LaunchDecision::OpenStore
    } else {
        LaunchDecision::Restore { identities }
    }
}

#[cfg(test)]
mod tests {
    use std::time::{SystemTime, UNIX_EPOCH};

    use super::{
        LaunchDecision, RosterSlot, launch_decision, parse_saved_roster, parse_saved_slots,
        read_saved_roster, resolve_saved_roster,
    };

    fn catalog() -> Vec<String> {
        vec![
            "claude.ai".into(),
            "openai.com".into(),
            "gemini.google.com".into(),
        ]
    }

    #[test]
    fn parses_multiline_roster_and_ignores_blank_lines() {
        let settings = r#"{"launcher":{"roster":" claude.ai\n\nopenai.com \n"}}"#;
        assert_eq!(parse_saved_roster(settings), ["claude.ai", "openai.com"]);
    }

    #[test]
    fn parses_duplicate_slot_agents_with_independent_models() {
        let settings = r#"{"launcher":{"roster":[{"agent":"claude.ai","model":"opus"},{"agent":"claude.ai","model":"sonnet"}]}}"#;
        assert_eq!(
            parse_saved_slots(settings),
            [
                RosterSlot {
                    agent: "claude.ai".into(),
                    model: Some("opus".into())
                },
                RosterSlot {
                    agent: "claude.ai".into(),
                    model: Some("sonnet".into())
                },
            ]
        );
    }

    #[test]
    fn malformed_or_wrongly_shaped_settings_are_empty() {
        assert!(parse_saved_roster("not json").is_empty());
        assert!(parse_saved_roster(r#"{"launcher":{"roster":42}}"#).is_empty());
        assert!(parse_saved_roster(r#"{"launcher":[]}"#).is_empty());
        assert!(parse_saved_roster(r#"{"other":{"roster":"claude.ai"}}"#).is_empty());
    }

    #[test]
    fn filters_removed_identities_and_preserves_saved_order() {
        let settings = r#"{"launcher":{"roster":"OPENAI.COM\nremoved.ai\nclaude.ai"}}"#;
        assert_eq!(
            resolve_saved_roster(settings, &catalog()),
            ["openai.com", "claude.ai"]
        );
    }

    #[test]
    fn empty_or_fully_stale_roster_opens_store() {
        let available = catalog();
        assert_eq!(launch_decision("{}", &available), LaunchDecision::OpenStore);
        assert_eq!(
            launch_decision(r#"{"launcher":{"roster":"gone.ai"}}"#, &available),
            LaunchDecision::OpenStore
        );
    }

    #[test]
    fn partial_roster_restores_only_current_identities() {
        let available = catalog();
        let decision = launch_decision(
            r#"{"launcher":{"roster":"gone.ai\nclaude.ai\nopenai.com"}}"#,
            &available,
        );
        assert_eq!(
            decision,
            LaunchDecision::Restore {
                identities: vec!["claude.ai".into(), "openai.com".into()]
            }
        );
        assert!(decision.should_restore());
        assert_eq!(decision.identities(), ["claude.ai", "openai.com"]);
    }

    #[test]
    fn reads_saved_roster_from_disk() {
        let unique = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("clock")
            .as_nanos();
        let path = std::env::temp_dir().join(format!("codeswarm-launcher-{unique}.json"));
        std::fs::write(&path, r#"{"launcher":{"roster":"claude.ai"}}"#).expect("write");
        assert_eq!(read_saved_roster(&path), ["claude.ai"]);
        std::fs::remove_file(path).expect("cleanup");
    }
}