Skip to main content

codeswarm_core/
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;
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/// The action the bare launcher should take after reading persisted state.
24#[derive(Clone, Debug, Eq, PartialEq)]
25pub enum LaunchDecision {
26    /// Restore this roster in its persisted order.
27    Restore { identities: Vec<String> },
28    /// Show the agent store so the user can choose a roster.
29    OpenStore,
30}
31
32impl LaunchDecision {
33    /// Whether this decision contains a usable saved roster.
34    pub fn should_restore(&self) -> bool {
35        matches!(self, Self::Restore { identities } if !identities.is_empty())
36    }
37
38    /// Return the identities to restore, or an empty slice for the store.
39    pub fn identities(&self) -> &[String] {
40        match self {
41            Self::Restore { identities } => identities,
42            Self::OpenStore => &[],
43        }
44    }
45}
46
47/// Parse `launcher.roster` from a persisted CodeSwarm settings document.
48///
49/// Each non-empty line is one identity.  Parsing failures, missing settings,
50/// and values of the wrong type are treated as an empty saved roster; startup
51/// must remain safe when a user has a truncated or hand-edited settings file.
52pub 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
70/// Read and parse a persisted settings file.
71///
72/// A missing or unreadable file has the same safe startup behavior as a
73/// malformed file: no roster is restored.  The launcher can then open the
74/// store without guessing which detected agent should be started.
75pub 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
81/// Resolve a saved roster against the currently known canonical identities.
82///
83/// Resolution is case-insensitive and returns the catalog's spelling.
84/// Persisted order is retained, including
85/// repeated identities; the launcher does not silently reorder a user's
86/// roster.  Unknown or removed identities are filtered out.
87pub 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
99/// Decide whether bare launch restores the saved roster or opens the store.
100///
101/// This intentionally never falls back to preferred/detected agents.  Agent
102/// detection may preselect entries once the store is visible, but it must not
103/// turn a missing or stale saved roster into an unexpected session.
104pub 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}