use std::path::Path;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct SettingsFile {
launcher: Option<LauncherSettings>,
}
#[derive(Debug, Deserialize)]
struct LauncherSettings {
roster: Option<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> {
let Ok(settings) = serde_json::from_str::<SettingsFile>(settings_json) else {
return Vec::new();
};
settings
.launcher
.and_then(|launcher| launcher.roster)
.map(|roster| {
roster
.lines()
.map(str::trim)
.filter(|identity| !identity.is_empty())
.map(ToOwned::to_owned)
.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> {
parse_saved_roster(settings_json)
.into_iter()
.filter_map(|saved| {
available_identities
.iter()
.find(|available| available.eq_ignore_ascii_case(&saved))
.cloned()
})
.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, launch_decision, parse_saved_roster, 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 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");
}
}