Skip to main content

codeswarm_adapters/
launcher.rs

1//! Startup configuration and saved-roster restoration.
2//!
3//! The launcher runs before a session or adapter exists.  This module keeps
4//! that decision independent from the terminal UI: a saved roster is only a
5//! request to restore agents that still exist in the current catalog.  The
6//! catalog is authoritative, so stale identities are discarded and an empty
7//! result always opens the store instead of auto-starting detected agents.
8
9use std::path::Path;
10
11use serde::{Deserialize, Serialize};
12
13/// One persisted roster position. Slots are independent: the same agent may
14/// occupy several positions and each position may select a different model.
15#[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/// The action the bare launcher should take after reading persisted state.
41#[derive(Clone, Debug, Eq, PartialEq)]
42pub enum LaunchDecision {
43    /// Restore this roster in its persisted order.
44    Restore { identities: Vec<String> },
45    /// Show the agent store so the user can choose a roster.
46    OpenStore,
47}
48
49impl LaunchDecision {
50    /// Whether this decision contains a usable saved roster.
51    pub fn should_restore(&self) -> bool {
52        matches!(self, Self::Restore { identities } if !identities.is_empty())
53    }
54
55    /// Return the identities to restore, or an empty slice for the store.
56    pub fn identities(&self) -> &[String] {
57        match self {
58            Self::Restore { identities } => identities,
59            Self::OpenStore => &[],
60        }
61    }
62}
63
64/// Parse `launcher.roster` from a persisted CodeSwarm settings document.
65///
66/// Each non-empty line is one identity.  Parsing failures, missing settings,
67/// and values of the wrong type are treated as an empty saved roster; startup
68/// must remain safe when a user has a truncated or hand-edited settings file.
69pub 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
76/// Parse the slot-based roster format. The former newline-separated identity
77/// string is accepted as a lossless model-free migration input.
78pub 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
106/// Read and parse a persisted settings file.
107///
108/// A missing or unreadable file has the same safe startup behavior as a
109/// malformed file: no roster is restored.  The launcher can then open the
110/// store without guessing which detected agent should be started.
111pub 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
117/// Resolve a saved roster against the currently known canonical identities.
118///
119/// Resolution is case-insensitive and returns the catalog's spelling.
120/// Persisted order is retained, including
121/// repeated identities; the launcher does not silently reorder a user's
122/// roster.  Unknown or removed identities are filtered out.
123pub 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
130/// Resolve saved slots while retaining their per-slot model choices.
131pub 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
149/// Decide whether bare launch restores the saved roster or opens the store.
150///
151/// This intentionally never falls back to preferred/detected agents.  Agent
152/// detection may preselect entries once the store is visible, but it must not
153/// turn a missing or stale saved roster into an unexpected session.
154pub 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}