yosh_plugin_manager/
config.rs1use 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}