codeswarm_adapters/
launcher.rs1use std::path::Path;
10
11use serde::{Deserialize, Serialize};
12
13#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
16pub struct RosterSlot {
17 #[serde(alias = "identity")]
18 pub agent: String,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub model: Option<String>,
21}
22
23#[derive(Debug, Deserialize)]
24struct SettingsFile {
25 launcher: Option<LauncherSettings>,
26}
27
28#[derive(Debug, Deserialize)]
29struct LauncherSettings {
30 roster: Option<SavedRoster>,
31}
32
33#[derive(Debug, Deserialize)]
34#[serde(untagged)]
35enum SavedRoster {
36 Slots(Vec<RosterSlot>),
37 Legacy(String),
38}
39
40#[derive(Clone, Debug, Eq, PartialEq)]
42pub enum LaunchDecision {
43 Restore { identities: Vec<String> },
45 OpenStore,
47}
48
49impl LaunchDecision {
50 pub fn should_restore(&self) -> bool {
52 matches!(self, Self::Restore { identities } if !identities.is_empty())
53 }
54
55 pub fn identities(&self) -> &[String] {
57 match self {
58 Self::Restore { identities } => identities,
59 Self::OpenStore => &[],
60 }
61 }
62}
63
64pub fn parse_saved_roster(settings_json: &str) -> Vec<String> {
70 parse_saved_slots(settings_json)
71 .into_iter()
72 .map(|slot| slot.agent)
73 .collect()
74}
75
76pub fn parse_saved_slots(settings_json: &str) -> Vec<RosterSlot> {
79 let Ok(settings) = serde_json::from_str::<SettingsFile>(settings_json) else {
80 return Vec::new();
81 };
82 settings
83 .launcher
84 .and_then(|launcher| launcher.roster)
85 .map(|roster| match roster {
86 SavedRoster::Slots(slots) => slots
87 .into_iter()
88 .filter_map(|mut slot| {
89 slot.agent = slot.agent.trim().to_owned();
90 (!slot.agent.is_empty()).then_some(slot)
91 })
92 .collect(),
93 SavedRoster::Legacy(roster) => roster
94 .lines()
95 .map(str::trim)
96 .filter(|identity| !identity.is_empty())
97 .map(|agent| RosterSlot {
98 agent: agent.to_owned(),
99 model: None,
100 })
101 .collect(),
102 })
103 .unwrap_or_default()
104}
105
106pub fn read_saved_roster(path: impl AsRef<Path>) -> Vec<String> {
112 std::fs::read_to_string(path)
113 .ok()
114 .map_or_else(Vec::new, |settings| parse_saved_roster(&settings))
115}
116
117pub fn resolve_saved_roster(settings_json: &str, available_identities: &[String]) -> Vec<String> {
124 resolve_saved_slots(settings_json, available_identities)
125 .into_iter()
126 .map(|slot| slot.agent)
127 .collect()
128}
129
130pub fn resolve_saved_slots(
132 settings_json: &str,
133 available_identities: &[String],
134) -> Vec<RosterSlot> {
135 parse_saved_slots(settings_json)
136 .into_iter()
137 .filter_map(|mut saved| {
138 available_identities
139 .iter()
140 .find(|available| available.eq_ignore_ascii_case(&saved.agent))
141 .map(|available| {
142 saved.agent.clone_from(available);
143 saved
144 })
145 })
146 .collect()
147}
148
149pub fn launch_decision(settings_json: &str, available_identities: &[String]) -> LaunchDecision {
155 let identities = resolve_saved_roster(settings_json, available_identities);
156 if identities.is_empty() {
157 LaunchDecision::OpenStore
158 } else {
159 LaunchDecision::Restore { identities }
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use std::time::{SystemTime, UNIX_EPOCH};
166
167 use super::{
168 LaunchDecision, RosterSlot, launch_decision, parse_saved_roster, parse_saved_slots,
169 read_saved_roster, resolve_saved_roster,
170 };
171
172 fn catalog() -> Vec<String> {
173 vec![
174 "claude.ai".into(),
175 "openai.com".into(),
176 "gemini.google.com".into(),
177 ]
178 }
179
180 #[test]
181 fn parses_multiline_roster_and_ignores_blank_lines() {
182 let settings = r#"{"launcher":{"roster":" claude.ai\n\nopenai.com \n"}}"#;
183 assert_eq!(parse_saved_roster(settings), ["claude.ai", "openai.com"]);
184 }
185
186 #[test]
187 fn parses_duplicate_slot_agents_with_independent_models() {
188 let settings = r#"{"launcher":{"roster":[{"agent":"claude.ai","model":"opus"},{"agent":"claude.ai","model":"sonnet"}]}}"#;
189 assert_eq!(
190 parse_saved_slots(settings),
191 [
192 RosterSlot {
193 agent: "claude.ai".into(),
194 model: Some("opus".into())
195 },
196 RosterSlot {
197 agent: "claude.ai".into(),
198 model: Some("sonnet".into())
199 },
200 ]
201 );
202 }
203
204 #[test]
205 fn malformed_or_wrongly_shaped_settings_are_empty() {
206 assert!(parse_saved_roster("not json").is_empty());
207 assert!(parse_saved_roster(r#"{"launcher":{"roster":42}}"#).is_empty());
208 assert!(parse_saved_roster(r#"{"launcher":[]}"#).is_empty());
209 assert!(parse_saved_roster(r#"{"other":{"roster":"claude.ai"}}"#).is_empty());
210 }
211
212 #[test]
213 fn filters_removed_identities_and_preserves_saved_order() {
214 let settings = r#"{"launcher":{"roster":"OPENAI.COM\nremoved.ai\nclaude.ai"}}"#;
215 assert_eq!(
216 resolve_saved_roster(settings, &catalog()),
217 ["openai.com", "claude.ai"]
218 );
219 }
220
221 #[test]
222 fn empty_or_fully_stale_roster_opens_store() {
223 let available = catalog();
224 assert_eq!(launch_decision("{}", &available), LaunchDecision::OpenStore);
225 assert_eq!(
226 launch_decision(r#"{"launcher":{"roster":"gone.ai"}}"#, &available),
227 LaunchDecision::OpenStore
228 );
229 }
230
231 #[test]
232 fn partial_roster_restores_only_current_identities() {
233 let available = catalog();
234 let decision = launch_decision(
235 r#"{"launcher":{"roster":"gone.ai\nclaude.ai\nopenai.com"}}"#,
236 &available,
237 );
238 assert_eq!(
239 decision,
240 LaunchDecision::Restore {
241 identities: vec!["claude.ai".into(), "openai.com".into()]
242 }
243 );
244 assert!(decision.should_restore());
245 assert_eq!(decision.identities(), ["claude.ai", "openai.com"]);
246 }
247
248 #[test]
249 fn reads_saved_roster_from_disk() {
250 let unique = SystemTime::now()
251 .duration_since(UNIX_EPOCH)
252 .expect("clock")
253 .as_nanos();
254 let path = std::env::temp_dir().join(format!("codeswarm-launcher-{unique}.json"));
255 std::fs::write(&path, r#"{"launcher":{"roster":"claude.ai"}}"#).expect("write");
256 assert_eq!(read_saved_roster(&path), ["claude.ai"]);
257 std::fs::remove_file(path).expect("cleanup");
258 }
259}