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!("invalid github source '{}': expected 'github:owner/repo'", s));
47        }
48        Ok(PluginSource::GitHub {
49            owner: parts[0].to_string(),
50            repo: parts[1].to_string(),
51        })
52    } else if let Some(rest) = s.strip_prefix("local:") {
53        if rest.is_empty() {
54            return Err(format!("invalid local source '{}': path is empty", s));
55        }
56        Ok(PluginSource::Local { path: rest.to_string() })
57    } else {
58        Err(format!("unknown source type '{}': expected 'github:' or 'local:' prefix", s))
59    }
60}
61
62fn validate_plugin_name(name: &str) -> Result<(), String> {
63    if name.is_empty() {
64        return Err("plugin name is empty".to_string());
65    }
66    if name.contains('/') || name.contains('\\') || name.contains("..") {
67        return Err(format!(
68            "plugin '{}': name must not contain '/', '\\', or '..'",
69            name
70        ));
71    }
72    if !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
73        return Err(format!(
74            "plugin '{}': name must contain only alphanumeric characters, hyphens, or underscores",
75            name
76        ));
77    }
78    Ok(())
79}
80
81pub fn load_config(path: &Path) -> Result<Vec<PluginDecl>, String> {
82    let content = std::fs::read_to_string(path)
83        .map_err(|e| format!("{}: {}", path.display(), e))?;
84    let raw: RawConfig = toml::from_str(&content)
85        .map_err(|e| format!("{}: {}", path.display(), e))?;
86    raw.plugin
87        .into_iter()
88        .map(|entry| {
89            validate_plugin_name(&entry.name)?;
90            let source = parse_source(&entry.source)?;
91            if matches!(source, PluginSource::GitHub { .. }) && entry.version.is_none() {
92                return Err(format!(
93                    "plugin '{}': github source requires 'version' field",
94                    entry.name
95                ));
96            }
97            Ok(PluginDecl {
98                name: entry.name,
99                source,
100                version: entry.version,
101                enabled: entry.enabled,
102                capabilities: entry.capabilities,
103                asset: entry.asset,
104            })
105        })
106        .collect()
107}
108
109pub fn expand_tilde_path(path: &str) -> std::path::PathBuf {
110    if let Some(rest) = path.strip_prefix("~/") {
111        if let Ok(home) = std::env::var("HOME") {
112            return std::path::PathBuf::from(home).join(rest);
113        }
114    }
115    std::path::PathBuf::from(path)
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use std::io::Write;
122
123    #[test]
124    fn parse_github_source() {
125        let src = parse_source("github:user/repo").unwrap();
126        assert_eq!(
127            src,
128            PluginSource::GitHub { owner: "user".into(), repo: "repo".into() }
129        );
130    }
131
132    #[test]
133    fn parse_local_source() {
134        let src = parse_source("local:~/.yosh/plugins/lib.dylib").unwrap();
135        assert_eq!(
136            src,
137            PluginSource::Local { path: "~/.yosh/plugins/lib.dylib".into() }
138        );
139    }
140
141    #[test]
142    fn parse_invalid_source_no_prefix() {
143        assert!(parse_source("invalid:foo").is_err());
144    }
145
146    #[test]
147    fn parse_invalid_github_missing_repo() {
148        assert!(parse_source("github:useronly").is_err());
149    }
150
151    #[test]
152    fn parse_empty_local_path() {
153        assert!(parse_source("local:").is_err());
154    }
155
156    #[test]
157    fn load_full_config() {
158        let mut f = tempfile::NamedTempFile::new().unwrap();
159        write!(f, r#"
160[[plugin]]
161name = "git-status"
162source = "github:user/kish-plugin-git-status"
163version = "1.2.3"
164capabilities = ["variables:read", "io"]
165
166[[plugin]]
167name = "local-tool"
168source = "local:~/.yosh/plugins/liblocal.dylib"
169capabilities = ["io"]
170"#).unwrap();
171        let decls = load_config(f.path()).unwrap();
172        assert_eq!(decls.len(), 2);
173        assert_eq!(decls[0].name, "git-status");
174        assert!(matches!(&decls[0].source, PluginSource::GitHub { owner, repo } if owner == "user" && repo == "kish-plugin-git-status"));
175        assert_eq!(decls[0].version.as_deref(), Some("1.2.3"));
176        assert_eq!(decls[1].name, "local-tool");
177        assert!(matches!(&decls[1].source, PluginSource::Local { .. }));
178        assert!(decls[1].version.is_none());
179    }
180
181    #[test]
182    fn load_config_enabled_defaults_true() {
183        let mut f = tempfile::NamedTempFile::new().unwrap();
184        write!(f, r#"
185[[plugin]]
186name = "p"
187source = "local:/tmp/lib.dylib"
188"#).unwrap();
189        let decls = load_config(f.path()).unwrap();
190        assert!(decls[0].enabled);
191    }
192
193    #[test]
194    fn load_config_disabled_plugin() {
195        let mut f = tempfile::NamedTempFile::new().unwrap();
196        write!(f, r#"
197[[plugin]]
198name = "p"
199source = "local:/tmp/lib.dylib"
200enabled = false
201"#).unwrap();
202        let decls = load_config(f.path()).unwrap();
203        assert!(!decls[0].enabled);
204    }
205
206    #[test]
207    fn load_config_with_asset_template() {
208        let mut f = tempfile::NamedTempFile::new().unwrap();
209        write!(f, r#"
210[[plugin]]
211name = "custom"
212source = "github:user/repo"
213version = "1.0.0"
214asset = "myplugin-{{os}}-{{arch}}.{{ext}}"
215"#).unwrap();
216        let decls = load_config(f.path()).unwrap();
217        assert_eq!(decls[0].asset.as_deref(), Some("myplugin-{os}-{arch}.{ext}"));
218    }
219
220    #[test]
221    fn github_source_without_version_is_error() {
222        let mut f = tempfile::NamedTempFile::new().unwrap();
223        write!(f, r#"
224[[plugin]]
225name = "bad"
226source = "github:user/repo"
227"#).unwrap();
228        assert!(load_config(f.path()).is_err());
229    }
230
231    #[test]
232    fn reject_path_traversal_in_name() {
233        let mut f = tempfile::NamedTempFile::new().unwrap();
234        write!(f, r#"
235[[plugin]]
236name = "../../../etc"
237source = "local:/tmp/lib.dylib"
238"#).unwrap();
239        assert!(load_config(f.path()).is_err());
240    }
241
242    #[test]
243    fn reject_slash_in_name() {
244        let mut f = tempfile::NamedTempFile::new().unwrap();
245        write!(f, r#"
246[[plugin]]
247name = "foo/bar"
248source = "local:/tmp/lib.dylib"
249"#).unwrap();
250        assert!(load_config(f.path()).is_err());
251    }
252
253    #[test]
254    fn reject_empty_name() {
255        let mut f = tempfile::NamedTempFile::new().unwrap();
256        write!(f, r#"
257[[plugin]]
258name = ""
259source = "local:/tmp/lib.dylib"
260"#).unwrap();
261        assert!(load_config(f.path()).is_err());
262    }
263
264    #[test]
265    fn empty_config_returns_empty_vec() {
266        let mut f = tempfile::NamedTempFile::new().unwrap();
267        write!(f, "").unwrap();
268        let decls = load_config(f.path()).unwrap();
269        assert!(decls.is_empty());
270    }
271}