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 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}