use std::path::Path;
use serde::{Deserialize, Serialize};
#[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),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum LaunchDecision {
Restore { identities: Vec<String> },
OpenStore,
}
impl LaunchDecision {
pub fn should_restore(&self) -> bool {
matches!(self, Self::Restore { identities } if !identities.is_empty())
}
pub fn identities(&self) -> &[String] {
match self {
Self::Restore { identities } => identities,
Self::OpenStore => &[],
}
}
}
pub fn parse_saved_roster(settings_json: &str) -> Vec<String> {
parse_saved_slots(settings_json)
.into_iter()
.map(|slot| slot.agent)
.collect()
}
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()
}
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))
}
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()
}
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()
}
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");
}
}