gsm_core/
default_packs.rs

1use std::collections::HashSet;
2use std::env;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6/// Configuration controlling which default messaging adapter packs to load.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct DefaultAdapterPacksConfig {
9    /// Load all default packs shipped in `packs/messaging`.
10    pub install_all: bool,
11    /// Specific default pack ids to load (e.g., `["teams", "slack"]`).
12    pub selected: Vec<String>,
13}
14
15impl DefaultAdapterPacksConfig {
16    pub fn from_env() -> Self {
17        let install_all = env::var("MESSAGING_INSTALL_ALL_DEFAULT_ADAPTER_PACKS")
18            .map(|v| v.eq_ignore_ascii_case("true"))
19            .unwrap_or(false);
20        let selected = env::var("MESSAGING_DEFAULT_ADAPTER_PACKS")
21            .ok()
22            .map(|raw| {
23                raw.split(',')
24                    .filter_map(|s| {
25                        let trimmed = s.trim();
26                        if trimmed.is_empty() {
27                            None
28                        } else {
29                            Some(trimmed.to_string())
30                        }
31                    })
32                    .collect()
33            })
34            .unwrap_or_default();
35        Self {
36            install_all,
37            selected,
38        }
39    }
40}
41
42/// Pack metadata mapped to its file path.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct DefaultAdapterPack {
45    pub id: &'static str,
46    pub filename: &'static str,
47}
48
49const DEFAULT_PACKS: &[DefaultAdapterPack] = &[
50    DefaultAdapterPack {
51        id: "slack",
52        filename: "slack.yaml",
53    },
54    DefaultAdapterPack {
55        id: "teams",
56        filename: "teams.yaml",
57    },
58    DefaultAdapterPack {
59        id: "webex",
60        filename: "webex.yaml",
61    },
62    DefaultAdapterPack {
63        id: "webchat",
64        filename: "webchat.yaml",
65    },
66    DefaultAdapterPack {
67        id: "whatsapp",
68        filename: "whatsapp.yaml",
69    },
70    DefaultAdapterPack {
71        id: "telegram",
72        filename: "telegram.yaml",
73    },
74    DefaultAdapterPack {
75        id: "local",
76        filename: "local.yaml",
77    },
78];
79
80/// Resolved pack path and raw contents.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct LoadedPack {
83    pub id: String,
84    pub path: PathBuf,
85    pub raw: String,
86}
87
88/// Resolve pack file system paths without reading contents.
89pub fn default_adapter_pack_paths(
90    packs_root: &Path,
91    config: &DefaultAdapterPacksConfig,
92) -> Vec<PathBuf> {
93    resolve_default_adapter_packs(config)
94        .into_iter()
95        .map(|pack| packs_root.join("messaging").join(pack.filename))
96        .collect()
97}
98
99/// Additional adapter pack paths from env (`MESSAGING_ADAPTER_PACK_PATHS`, comma-separated).
100pub fn adapter_pack_paths_from_env() -> Vec<PathBuf> {
101    env::var("MESSAGING_ADAPTER_PACK_PATHS")
102        .ok()
103        .map(|raw| {
104            raw.split(',')
105                .filter_map(|s| {
106                    let trimmed = s.trim();
107                    if trimmed.is_empty() {
108                        None
109                    } else {
110                        Some(PathBuf::from(trimmed))
111                    }
112                })
113                .collect()
114        })
115        .unwrap_or_default()
116}
117
118/// Load and parse default adapter packs into an adapter registry.
119pub fn load_default_adapter_registry(
120    packs_root: &Path,
121    config: &DefaultAdapterPacksConfig,
122) -> anyhow::Result<crate::AdapterRegistry> {
123    let paths = default_adapter_pack_paths(packs_root, config);
124    crate::adapter_registry::load_adapters_from_pack_files(&paths)
125        .map_err(|err| err.context("failed to load default messaging adapter packs"))
126}
127
128/// Resolve which default adapter packs should be loaded based on config.
129pub fn resolve_default_adapter_packs(
130    config: &DefaultAdapterPacksConfig,
131) -> Vec<&'static DefaultAdapterPack> {
132    if config.install_all {
133        return DEFAULT_PACKS.iter().collect();
134    }
135    if config.selected.is_empty() {
136        return Vec::new();
137    }
138    let selected: HashSet<String> = config
139        .selected
140        .iter()
141        .map(|s| s.to_ascii_lowercase())
142        .collect();
143    DEFAULT_PACKS
144        .iter()
145        .filter(|pack| selected.contains(pack.id))
146        .collect()
147}
148
149/// Load the default adapter pack files from a root directory.
150///
151/// `packs_root` should point at the directory containing `messaging/`.
152pub fn load_default_adapter_packs_from(
153    packs_root: &Path,
154    config: &DefaultAdapterPacksConfig,
155) -> std::io::Result<Vec<LoadedPack>> {
156    let resolved = resolve_default_adapter_packs(config);
157    let mut out = Vec::with_capacity(resolved.len());
158    for pack in resolved {
159        let path = packs_root.join("messaging").join(pack.filename);
160        let raw = fs::read_to_string(&path)?;
161        out.push(LoadedPack {
162            id: pack.id.to_string(),
163            path,
164            raw,
165        });
166    }
167    Ok(out)
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use once_cell::sync::Lazy;
174    use std::path::PathBuf;
175    use std::sync::Mutex;
176
177    static ENV_GUARD: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
178
179    struct GuardEnv;
180    impl GuardEnv {
181        fn new() -> Self {
182            Self
183        }
184    }
185    impl Drop for GuardEnv {
186        fn drop(&mut self) {
187            unsafe {
188                env::remove_var("MESSAGING_ADAPTER_PACK_PATHS");
189                env::remove_var("MESSAGING_INSTALL_ALL_DEFAULT_ADAPTER_PACKS");
190                env::remove_var("MESSAGING_DEFAULT_ADAPTER_PACKS");
191            }
192        }
193    }
194
195    #[test]
196    fn adapter_pack_paths_env_parses_list() {
197        let _lock = ENV_GUARD.lock().unwrap();
198        let _guard = GuardEnv::new();
199        unsafe {
200            env::set_var(
201                "MESSAGING_ADAPTER_PACK_PATHS",
202                "/tmp/one.yaml, /tmp/two.yaml ,",
203            );
204        }
205        let paths = adapter_pack_paths_from_env();
206        assert_eq!(
207            paths,
208            vec![
209                PathBuf::from("/tmp/one.yaml"),
210                PathBuf::from("/tmp/two.yaml")
211            ]
212        );
213    }
214
215    #[test]
216    fn config_parses_env() {
217        let _lock = ENV_GUARD.lock().unwrap();
218        let _guard = GuardEnv::new();
219        unsafe {
220            env::set_var("MESSAGING_INSTALL_ALL_DEFAULT_ADAPTER_PACKS", "true");
221            env::set_var("MESSAGING_DEFAULT_ADAPTER_PACKS", "teams, slack");
222        }
223        let cfg = DefaultAdapterPacksConfig::from_env();
224        assert!(cfg.install_all);
225        assert_eq!(cfg.selected, vec!["teams".to_string(), "slack".to_string()]);
226    }
227
228    #[test]
229    fn resolve_all_when_flag_set() {
230        let cfg = DefaultAdapterPacksConfig {
231            install_all: true,
232            selected: vec![],
233        };
234        let resolved = resolve_default_adapter_packs(&cfg);
235        assert_eq!(resolved.len(), DEFAULT_PACKS.len());
236    }
237
238    #[test]
239    fn resolve_subset_when_listed() {
240        let cfg = DefaultAdapterPacksConfig {
241            install_all: false,
242            selected: vec!["teams".into(), "slack".into(), "missing".into()],
243        };
244        let resolved = resolve_default_adapter_packs(&cfg);
245        let ids: HashSet<&str> = resolved.iter().map(|p| p.id).collect();
246        assert_eq!(ids, HashSet::from(["teams", "slack"]));
247    }
248
249    #[test]
250    fn resolve_empty_when_no_selection() {
251        let cfg = DefaultAdapterPacksConfig {
252            install_all: false,
253            selected: Vec::new(),
254        };
255        let resolved = resolve_default_adapter_packs(&cfg);
256        assert!(resolved.is_empty());
257    }
258
259    #[test]
260    fn resolve_all_when_install_all_true() {
261        let cfg = DefaultAdapterPacksConfig {
262            install_all: true,
263            selected: Vec::new(),
264        };
265        let resolved = resolve_default_adapter_packs(&cfg);
266        assert_eq!(resolved.len(), super::DEFAULT_PACKS.len());
267    }
268
269    #[test]
270    fn resolve_exact_subset() {
271        let cfg = DefaultAdapterPacksConfig {
272            install_all: false,
273            selected: vec!["slack".into(), "telegram".into()],
274        };
275        let resolved = resolve_default_adapter_packs(&cfg);
276        let ids: Vec<_> = resolved.iter().map(|p| p.id).collect();
277        assert_eq!(ids, vec!["slack", "telegram"]);
278    }
279}