codeswarm_adapters/
launcher.rs1use std::path::Path;
10
11use serde::Deserialize;
12
13#[derive(Debug, Deserialize)]
14struct SettingsFile {
15 launcher: Option<LauncherSettings>,
16}
17
18#[derive(Debug, Deserialize)]
19struct LauncherSettings {
20 roster: Option<String>,
21}
22
23#[derive(Clone, Debug, Eq, PartialEq)]
25pub enum LaunchDecision {
26 Restore { identities: Vec<String> },
28 OpenStore,
30}
31
32impl LaunchDecision {
33 pub fn should_restore(&self) -> bool {
35 matches!(self, Self::Restore { identities } if !identities.is_empty())
36 }
37
38 pub fn identities(&self) -> &[String] {
40 match self {
41 Self::Restore { identities } => identities,
42 Self::OpenStore => &[],
43 }
44 }
45}
46
47pub fn parse_saved_roster(settings_json: &str) -> Vec<String> {
53 let Ok(settings) = serde_json::from_str::<SettingsFile>(settings_json) else {
54 return Vec::new();
55 };
56 settings
57 .launcher
58 .and_then(|launcher| launcher.roster)
59 .map(|roster| {
60 roster
61 .lines()
62 .map(str::trim)
63 .filter(|identity| !identity.is_empty())
64 .map(ToOwned::to_owned)
65 .collect()
66 })
67 .unwrap_or_default()
68}
69
70pub fn read_saved_roster(path: impl AsRef<Path>) -> Vec<String> {
76 std::fs::read_to_string(path)
77 .ok()
78 .map_or_else(Vec::new, |settings| parse_saved_roster(&settings))
79}
80
81pub fn resolve_saved_roster(settings_json: &str, available_identities: &[String]) -> Vec<String> {
88 parse_saved_roster(settings_json)
89 .into_iter()
90 .filter_map(|saved| {
91 available_identities
92 .iter()
93 .find(|available| available.eq_ignore_ascii_case(&saved))
94 .cloned()
95 })
96 .collect()
97}
98
99pub fn launch_decision(settings_json: &str, available_identities: &[String]) -> LaunchDecision {
105 let identities = resolve_saved_roster(settings_json, available_identities);
106 if identities.is_empty() {
107 LaunchDecision::OpenStore
108 } else {
109 LaunchDecision::Restore { identities }
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use std::time::{SystemTime, UNIX_EPOCH};
116
117 use super::{
118 LaunchDecision, launch_decision, parse_saved_roster, read_saved_roster,
119 resolve_saved_roster,
120 };
121
122 fn catalog() -> Vec<String> {
123 vec![
124 "claude.ai".into(),
125 "openai.com".into(),
126 "gemini.google.com".into(),
127 ]
128 }
129
130 #[test]
131 fn parses_multiline_roster_and_ignores_blank_lines() {
132 let settings = r#"{"launcher":{"roster":" claude.ai\n\nopenai.com \n"}}"#;
133 assert_eq!(parse_saved_roster(settings), ["claude.ai", "openai.com"]);
134 }
135
136 #[test]
137 fn malformed_or_wrongly_shaped_settings_are_empty() {
138 assert!(parse_saved_roster("not json").is_empty());
139 assert!(parse_saved_roster(r#"{"launcher":{"roster":42}}"#).is_empty());
140 assert!(parse_saved_roster(r#"{"launcher":[]}"#).is_empty());
141 assert!(parse_saved_roster(r#"{"other":{"roster":"claude.ai"}}"#).is_empty());
142 }
143
144 #[test]
145 fn filters_removed_identities_and_preserves_saved_order() {
146 let settings = r#"{"launcher":{"roster":"OPENAI.COM\nremoved.ai\nclaude.ai"}}"#;
147 assert_eq!(
148 resolve_saved_roster(settings, &catalog()),
149 ["openai.com", "claude.ai"]
150 );
151 }
152
153 #[test]
154 fn empty_or_fully_stale_roster_opens_store() {
155 let available = catalog();
156 assert_eq!(launch_decision("{}", &available), LaunchDecision::OpenStore);
157 assert_eq!(
158 launch_decision(r#"{"launcher":{"roster":"gone.ai"}}"#, &available),
159 LaunchDecision::OpenStore
160 );
161 }
162
163 #[test]
164 fn partial_roster_restores_only_current_identities() {
165 let available = catalog();
166 let decision = launch_decision(
167 r#"{"launcher":{"roster":"gone.ai\nclaude.ai\nopenai.com"}}"#,
168 &available,
169 );
170 assert_eq!(
171 decision,
172 LaunchDecision::Restore {
173 identities: vec!["claude.ai".into(), "openai.com".into()]
174 }
175 );
176 assert!(decision.should_restore());
177 assert_eq!(decision.identities(), ["claude.ai", "openai.com"]);
178 }
179
180 #[test]
181 fn reads_saved_roster_from_disk() {
182 let unique = SystemTime::now()
183 .duration_since(UNIX_EPOCH)
184 .expect("clock")
185 .as_nanos();
186 let path = std::env::temp_dir().join(format!("codeswarm-launcher-{unique}.json"));
187 std::fs::write(&path, r#"{"launcher":{"roster":"claude.ai"}}"#).expect("write");
188 assert_eq!(read_saved_roster(&path), ["claude.ai"]);
189 std::fs::remove_file(path).expect("cleanup");
190 }
191}