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    raw.plugin
98        .into_iter()
99        .map(|entry| {
100            validate_plugin_name(&entry.name)?;
101            let source = parse_source(&entry.source)?;
102            if matches!(source, PluginSource::GitHub { .. }) && entry.version.is_none() {
103                return Err(format!(
104                    "plugin '{}': github source requires 'version' field",
105                    entry.name
106                ));
107            }
108            Ok(PluginDecl {
109                name: entry.name,
110                source,
111                version: entry.version,
112                enabled: entry.enabled,
113                capabilities: entry.capabilities,
114                asset: entry.asset,
115            })
116        })
117        .collect()
118}
119
120pub fn expand_tilde_path(path: &str) -> std::path::PathBuf {
121    if let Some(rest) = path.strip_prefix("~/") {
122        if let Ok(home) = std::env::var("HOME") {
123            return std::path::PathBuf::from(home).join(rest);
124        }
125    }
126    std::path::PathBuf::from(path)
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use std::io::Write;
133
134    #[test]
135    fn parse_github_source() {
136        let src = parse_source("github:user/repo").unwrap();
137        assert_eq!(
138            src,
139            PluginSource::GitHub {
140                owner: "user".into(),
141                repo: "repo".into()
142            }
143        );
144    }
145
146    #[test]
147    fn parse_local_source() {
148        let src = parse_source("local:~/.yosh/plugins/lib.dylib").unwrap();
149        assert_eq!(
150            src,
151            PluginSource::Local {
152                path: "~/.yosh/plugins/lib.dylib".into()
153            }
154        );
155    }
156
157    #[test]
158    fn parse_invalid_source_no_prefix() {
159        assert!(parse_source("invalid:foo").is_err());
160    }
161
162    #[test]
163    fn parse_invalid_github_missing_repo() {
164        assert!(parse_source("github:useronly").is_err());
165    }
166
167    #[test]
168    fn parse_empty_local_path() {
169        assert!(parse_source("local:").is_err());
170    }
171
172    #[test]
173    fn load_full_config() {
174        let mut f = tempfile::NamedTempFile::new().unwrap();
175        write!(
176            f,
177            r#"
178[[plugin]]
179name = "git-status"
180source = "github:user/kish-plugin-git-status"
181version = "1.2.3"
182capabilities = ["variables:read", "io"]
183
184[[plugin]]
185name = "local-tool"
186source = "local:~/.yosh/plugins/liblocal.dylib"
187capabilities = ["io"]
188"#
189        )
190        .unwrap();
191        let decls = load_config(f.path()).unwrap();
192        assert_eq!(decls.len(), 2);
193        assert_eq!(decls[0].name, "git-status");
194        assert!(
195            matches!(&decls[0].source, PluginSource::GitHub { owner, repo } if owner == "user" && repo == "kish-plugin-git-status")
196        );
197        assert_eq!(decls[0].version.as_deref(), Some("1.2.3"));
198        assert_eq!(decls[1].name, "local-tool");
199        assert!(matches!(&decls[1].source, PluginSource::Local { .. }));
200        assert!(decls[1].version.is_none());
201    }
202
203    #[test]
204    fn load_config_enabled_defaults_true() {
205        let mut f = tempfile::NamedTempFile::new().unwrap();
206        write!(
207            f,
208            r#"
209[[plugin]]
210name = "p"
211source = "local:/tmp/lib.dylib"
212"#
213        )
214        .unwrap();
215        let decls = load_config(f.path()).unwrap();
216        assert!(decls[0].enabled);
217    }
218
219    #[test]
220    fn load_config_disabled_plugin() {
221        let mut f = tempfile::NamedTempFile::new().unwrap();
222        write!(
223            f,
224            r#"
225[[plugin]]
226name = "p"
227source = "local:/tmp/lib.dylib"
228enabled = false
229"#
230        )
231        .unwrap();
232        let decls = load_config(f.path()).unwrap();
233        assert!(!decls[0].enabled);
234    }
235
236    #[test]
237    fn load_config_with_asset_template() {
238        let mut f = tempfile::NamedTempFile::new().unwrap();
239        write!(
240            f,
241            r#"
242[[plugin]]
243name = "custom"
244source = "github:user/repo"
245version = "1.0.0"
246asset = "myplugin-{{os}}-{{arch}}.{{ext}}"
247"#
248        )
249        .unwrap();
250        let decls = load_config(f.path()).unwrap();
251        assert_eq!(
252            decls[0].asset.as_deref(),
253            Some("myplugin-{os}-{arch}.{ext}")
254        );
255    }
256
257    #[test]
258    fn github_source_without_version_is_error() {
259        let mut f = tempfile::NamedTempFile::new().unwrap();
260        write!(
261            f,
262            r#"
263[[plugin]]
264name = "bad"
265source = "github:user/repo"
266"#
267        )
268        .unwrap();
269        assert!(load_config(f.path()).is_err());
270    }
271
272    #[test]
273    fn reject_path_traversal_in_name() {
274        let mut f = tempfile::NamedTempFile::new().unwrap();
275        write!(
276            f,
277            r#"
278[[plugin]]
279name = "../../../etc"
280source = "local:/tmp/lib.dylib"
281"#
282        )
283        .unwrap();
284        assert!(load_config(f.path()).is_err());
285    }
286
287    #[test]
288    fn reject_slash_in_name() {
289        let mut f = tempfile::NamedTempFile::new().unwrap();
290        write!(
291            f,
292            r#"
293[[plugin]]
294name = "foo/bar"
295source = "local:/tmp/lib.dylib"
296"#
297        )
298        .unwrap();
299        assert!(load_config(f.path()).is_err());
300    }
301
302    #[test]
303    fn reject_empty_name() {
304        let mut f = tempfile::NamedTempFile::new().unwrap();
305        write!(
306            f,
307            r#"
308[[plugin]]
309name = ""
310source = "local:/tmp/lib.dylib"
311"#
312        )
313        .unwrap();
314        assert!(load_config(f.path()).is_err());
315    }
316
317    #[test]
318    fn empty_config_returns_empty_vec() {
319        let mut f = tempfile::NamedTempFile::new().unwrap();
320        write!(f, "").unwrap();
321        let decls = load_config(f.path()).unwrap();
322        assert!(decls.is_empty());
323    }
324}