gsm_core/
default_packs.rs

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