Skip to main content

yosh_plugin_manager/
config.rs

1use std::path::Path;
2
3use serde::Deserialize;
4
5#[derive(Debug, Clone, PartialEq)]
6pub enum PluginSource {
7    GitHub { owner: String, repo: String },
8    Local { path: String },
9}
10
11#[derive(Debug, Clone)]
12pub struct PluginDecl {
13    pub name: String,
14    pub source: PluginSource,
15    pub version: Option<String>,
16    pub enabled: bool,
17    pub capabilities: Option<Vec<String>>,
18    pub asset: Option<String>,
19}
20
21#[derive(Debug, Deserialize)]
22struct RawConfig {
23    #[serde(default)]
24    plugin: Vec<RawPluginEntry>,
25}
26
27#[derive(Debug, Deserialize)]
28struct RawPluginEntry {
29    name: String,
30    source: String,
31    version: Option<String>,
32    #[serde(default = "default_true")]
33    enabled: bool,
34    capabilities: Option<Vec<String>>,
35    asset: Option<String>,
36}
37
38fn default_true() -> bool {
39    true
40}
41
42pub fn parse_source(s: &str) -> Result<PluginSource, String> {
43    if let Some(rest) = s.strip_prefix("github:") {
44        let parts: Vec<&str> = rest.splitn(2, '/').collect();
45        if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
46            return Err(format!(
47                "invalid github source '{}': expected 'github:owner/repo'",
48                s
49            ));
50        }
51        Ok(PluginSource::GitHub {
52            owner: parts[0].to_string(),
53            repo: parts[1].to_string(),
54        })
55    } else if let Some(rest) = s.strip_prefix("local:") {
56        if rest.is_empty() {
57            return Err(format!("invalid local source '{}': path is empty", s));
58        }
59        Ok(PluginSource::Local {
60            path: rest.to_string(),
61        })
62    } else {
63        Err(format!(
64            "unknown source type '{}': expected 'github:' or 'local:' prefix",
65            s
66        ))
67    }
68}
69
70fn validate_plugin_name(name: &str) -> Result<(), String> {
71    if name.is_empty() {
72        return Err("plugin name is empty".to_string());
73    }
74    if name.contains('/') || name.contains('\\') || name.contains("..") {
75        return Err(format!(
76            "plugin '{}': name must not contain '/', '\\', or '..'",
77            name
78        ));
79    }
80    if !name
81        .chars()
82        .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
83    {
84        return Err(format!(
85            "plugin '{}': name must contain only alphanumeric characters, hyphens, or underscores",
86            name
87        ));
88    }
89    Ok(())
90}
91
92pub fn load_config(path: &Path) -> Result<Vec<PluginDecl>, String> {
93    let content =
94        std::fs::read_to_string(path).map_err(|e| format!("{}: {}", path.display(), e))?;
95    let raw: RawConfig =
96        toml::from_str(&content).map_err(|e| format!("{}: {}", path.display(), e))?;
97    let decls: Vec<PluginDecl> = raw
98        .plugin
99        .into_iter()
100        .map(|entry| {
101            validate_plugin_name(&entry.name)?;
102            let source = parse_source(&entry.source)?;
103            if matches!(source, PluginSource::GitHub { .. }) && entry.version.is_none() {
104                return Err(format!(
105                    "plugin '{}': github source requires 'version' field",
106                    entry.name
107                ));
108            }
109            // Reject pre-v0.2.0 asset templates with {os}/{arch}/{ext}
110            // tokens; plugins now ship as single .wasm files.
111            if let Some(t) = &entry.asset {
112                crate::resolve::check_asset_template(t)
113                    .map_err(|e| format!("plugin '{}': {}", entry.name, e))?;
114            }
115            Ok(PluginDecl {
116                name: entry.name,
117                source,
118                version: entry.version,
119                enabled: entry.enabled,
120                capabilities: entry.capabilities,
121                asset: entry.asset,
122            })
123        })
124        .collect::<Result<Vec<_>, String>>()?;
125
126    let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
127    for decl in &decls {
128        if !seen.insert(decl.name.as_str()) {
129            return Err(format!(
130                "plugin '{}': duplicate name (already defined earlier in config)",
131                decl.name
132            ));
133        }
134    }
135
136    Ok(decls)
137}
138
139pub fn expand_tilde_path(path: &str) -> std::path::PathBuf {
140    if let Some(rest) = path.strip_prefix("~/")
141        && let Ok(home) = std::env::var("HOME")
142    {
143        return std::path::PathBuf::from(home).join(rest);
144    }
145    std::path::PathBuf::from(path)
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use std::io::Write;
152
153    #[test]
154    fn parse_github_source() {
155        let src = parse_source("github:user/repo").unwrap();
156        assert_eq!(
157            src,
158            PluginSource::GitHub {
159                owner: "user".into(),
160                repo: "repo".into()
161            }
162        );
163    }
164
165    #[test]
166    fn parse_local_source() {
167        let src = parse_source("local:~/.yosh/plugins/lib.dylib").unwrap();
168        assert_eq!(
169            src,
170            PluginSource::Local {
171                path: "~/.yosh/plugins/lib.dylib".into()
172            }
173        );
174    }
175
176    #[test]
177    fn parse_invalid_source_no_prefix() {
178        assert!(parse_source("invalid:foo").is_err());
179    }
180
181    #[test]
182    fn parse_invalid_github_missing_repo() {
183        assert!(parse_source("github:useronly").is_err());
184    }
185
186    #[test]
187    fn parse_empty_local_path() {
188        assert!(parse_source("local:").is_err());
189    }
190
191    #[test]
192    fn load_full_config() {
193        let mut f = tempfile::NamedTempFile::new().unwrap();
194        write!(
195            f,
196            r#"
197[[plugin]]
198name = "git-status"
199source = "github:user/yosh-plugin-git-status"
200version = "1.2.3"
201capabilities = ["variables:read", "io"]
202
203[[plugin]]
204name = "local-tool"
205source = "local:~/.yosh/plugins/liblocal.dylib"
206capabilities = ["io"]
207"#
208        )
209        .unwrap();
210        let decls = load_config(f.path()).unwrap();
211        assert_eq!(decls.len(), 2);
212        assert_eq!(decls[0].name, "git-status");
213        assert!(
214            matches!(&decls[0].source, PluginSource::GitHub { owner, repo } if owner == "user" && repo == "yosh-plugin-git-status")
215        );
216        assert_eq!(decls[0].version.as_deref(), Some("1.2.3"));
217        assert_eq!(decls[1].name, "local-tool");
218        assert!(matches!(&decls[1].source, PluginSource::Local { .. }));
219        assert!(decls[1].version.is_none());
220    }
221
222    #[test]
223    fn load_config_enabled_defaults_true() {
224        let mut f = tempfile::NamedTempFile::new().unwrap();
225        write!(
226            f,
227            r#"
228[[plugin]]
229name = "p"
230source = "local:/tmp/lib.dylib"
231"#
232        )
233        .unwrap();
234        let decls = load_config(f.path()).unwrap();
235        assert!(decls[0].enabled);
236    }
237
238    #[test]
239    fn load_config_disabled_plugin() {
240        let mut f = tempfile::NamedTempFile::new().unwrap();
241        write!(
242            f,
243            r#"
244[[plugin]]
245name = "p"
246source = "local:/tmp/lib.dylib"
247enabled = false
248"#
249        )
250        .unwrap();
251        let decls = load_config(f.path()).unwrap();
252        assert!(!decls[0].enabled);
253    }
254
255    #[test]
256    fn load_config_with_asset_template() {
257        let mut f = tempfile::NamedTempFile::new().unwrap();
258        write!(
259            f,
260            r#"
261[[plugin]]
262name = "custom"
263source = "github:user/repo"
264version = "1.0.0"
265asset = "myplugin-{{name}}.wasm"
266"#
267        )
268        .unwrap();
269        let decls = load_config(f.path()).unwrap();
270        assert_eq!(decls[0].asset.as_deref(), Some("myplugin-{name}.wasm"));
271    }
272
273    #[test]
274    fn load_config_rejects_legacy_asset_template() {
275        let mut f = tempfile::NamedTempFile::new().unwrap();
276        write!(
277            f,
278            r#"
279[[plugin]]
280name = "old"
281source = "github:user/repo"
282version = "1.0.0"
283asset = "lib{{name}}-{{os}}-{{arch}}.{{ext}}"
284"#
285        )
286        .unwrap();
287        let err = load_config(f.path()).unwrap_err();
288        assert!(
289            err.contains("v0.2.0"),
290            "expected migration message: {}",
291            err
292        );
293    }
294
295    #[test]
296    fn github_source_without_version_is_error() {
297        let mut f = tempfile::NamedTempFile::new().unwrap();
298        write!(
299            f,
300            r#"
301[[plugin]]
302name = "bad"
303source = "github:user/repo"
304"#
305        )
306        .unwrap();
307        assert!(load_config(f.path()).is_err());
308    }
309
310    #[test]
311    fn reject_path_traversal_in_name() {
312        let mut f = tempfile::NamedTempFile::new().unwrap();
313        write!(
314            f,
315            r#"
316[[plugin]]
317name = "../../../etc"
318source = "local:/tmp/lib.dylib"
319"#
320        )
321        .unwrap();
322        assert!(load_config(f.path()).is_err());
323    }
324
325    #[test]
326    fn reject_slash_in_name() {
327        let mut f = tempfile::NamedTempFile::new().unwrap();
328        write!(
329            f,
330            r#"
331[[plugin]]
332name = "foo/bar"
333source = "local:/tmp/lib.dylib"
334"#
335        )
336        .unwrap();
337        assert!(load_config(f.path()).is_err());
338    }
339
340    #[test]
341    fn reject_empty_name() {
342        let mut f = tempfile::NamedTempFile::new().unwrap();
343        write!(
344            f,
345            r#"
346[[plugin]]
347name = ""
348source = "local:/tmp/lib.dylib"
349"#
350        )
351        .unwrap();
352        assert!(load_config(f.path()).is_err());
353    }
354
355    #[test]
356    fn empty_config_returns_empty_vec() {
357        let mut f = tempfile::NamedTempFile::new().unwrap();
358        write!(f, "").unwrap();
359        let decls = load_config(f.path()).unwrap();
360        assert!(decls.is_empty());
361    }
362
363    #[test]
364    fn reject_duplicate_plugin_names() {
365        let mut f = tempfile::NamedTempFile::new().unwrap();
366        write!(
367            f,
368            r#"
369[[plugin]]
370name = "dup"
371source = "local:/tmp/a.wasm"
372
373[[plugin]]
374name = "dup"
375source = "local:/tmp/b.wasm"
376"#
377        )
378        .unwrap();
379        let err = load_config(f.path()).unwrap_err();
380        assert!(
381            err.contains("duplicate"),
382            "expected duplicate-name error, got: {}",
383            err
384        );
385        assert!(
386            err.contains("'dup'"),
387            "expected duplicate name in error, got: {}",
388            err
389        );
390    }
391
392    #[test]
393    fn reject_duplicate_plugin_names_different_sources() {
394        let mut f = tempfile::NamedTempFile::new().unwrap();
395        write!(
396            f,
397            r#"
398[[plugin]]
399name = "shared"
400source = "github:owner/shared"
401version = "1.0.0"
402
403[[plugin]]
404name = "shared"
405source = "local:/tmp/shared.wasm"
406"#
407        )
408        .unwrap();
409        let err = load_config(f.path()).unwrap_err();
410        assert!(
411            err.contains("duplicate"),
412            "uniqueness must be enforced regardless of source kind, got: {}",
413            err
414        );
415    }
416}