1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use husako_config::{HusakoConfig, PluginManifest, PluginSource};
5
6use crate::progress::ProgressReporter;
7use crate::HusakoError;
8
9#[derive(Debug)]
11pub struct InstalledPlugin {
12 pub name: String,
13 pub manifest: PluginManifest,
14 pub dir: PathBuf,
15}
16
17pub fn install_plugins(
24 config: &HusakoConfig,
25 project_root: &Path,
26 progress: &dyn ProgressReporter,
27) -> Result<Vec<InstalledPlugin>, HusakoError> {
28 if config.plugins.is_empty() {
29 return Ok(Vec::new());
30 }
31
32 let plugins_dir = project_root.join(".husako/plugins");
33
34 let mut installed = Vec::new();
35
36 for (name, source) in &config.plugins {
37 let plugin_dir = plugins_dir.join(name);
38 let task = progress.start_task(&format!("Installing plugin {name}..."));
39
40 match install_plugin(name, source, project_root, &plugin_dir) {
41 Ok(()) => {
42 match husako_config::load_plugin_manifest(&plugin_dir) {
43 Ok(manifest) => {
44 task.finish_ok(&format!("{name}: installed (v{})", manifest.plugin.version));
45 installed.push(InstalledPlugin {
46 name: name.clone(),
47 manifest,
48 dir: plugin_dir,
49 });
50 }
51 Err(e) => {
52 task.finish_err(&format!("{name}: invalid manifest: {e}"));
53 return Err(HusakoError::Config(e));
54 }
55 }
56 }
57 Err(e) => {
58 task.finish_err(&format!("{name}: {e}"));
59 return Err(e);
60 }
61 }
62 }
63
64 Ok(installed)
65}
66
67fn install_plugin(
68 name: &str,
69 source: &PluginSource,
70 project_root: &Path,
71 target_dir: &Path,
72) -> Result<(), HusakoError> {
73 if target_dir.exists() {
75 std::fs::remove_dir_all(target_dir).map_err(|e| {
76 HusakoError::GenerateIo(format!("remove {}: {e}", target_dir.display()))
77 })?;
78 }
79
80 match source {
81 PluginSource::Git { url } => install_git(name, url, target_dir),
82 PluginSource::Path { path } => {
83 let source_dir = project_root.join(path);
84 install_path(name, &source_dir, target_dir)
85 }
86 }
87}
88
89fn install_git(name: &str, url: &str, target_dir: &Path) -> Result<(), HusakoError> {
90 std::fs::create_dir_all(target_dir).map_err(|e| {
91 HusakoError::GenerateIo(format!("create dir {}: {e}", target_dir.display()))
92 })?;
93
94 let output = std::process::Command::new("git")
95 .args([
96 "clone",
97 "--depth",
98 "1",
99 "--single-branch",
100 url,
101 &target_dir.to_string_lossy(),
102 ])
103 .output()
104 .map_err(|e| {
105 HusakoError::GenerateIo(format!("plugin '{name}': git clone failed: {e}"))
106 })?;
107
108 if !output.status.success() {
109 let stderr = String::from_utf8_lossy(&output.stderr);
110 let _ = std::fs::remove_dir_all(target_dir);
112 return Err(HusakoError::GenerateIo(format!(
113 "plugin '{name}': git clone failed: {stderr}"
114 )));
115 }
116
117 let git_dir = target_dir.join(".git");
119 if git_dir.exists() {
120 let _ = std::fs::remove_dir_all(&git_dir);
121 }
122
123 Ok(())
124}
125
126fn install_path(name: &str, source_dir: &Path, target_dir: &Path) -> Result<(), HusakoError> {
127 if !source_dir.is_dir() {
128 return Err(HusakoError::GenerateIo(format!(
129 "plugin '{name}': source directory not found: {}",
130 source_dir.display()
131 )));
132 }
133
134 copy_dir_recursive(source_dir, target_dir).map_err(|e| {
135 HusakoError::GenerateIo(format!(
136 "plugin '{name}': copy failed: {e}"
137 ))
138 })
139}
140
141fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), std::io::Error> {
142 std::fs::create_dir_all(dst)?;
143 for entry in std::fs::read_dir(src)? {
144 let entry = entry?;
145 let src_path = entry.path();
146 let dst_path = dst.join(entry.file_name());
147
148 if entry.metadata()?.is_dir() {
149 copy_dir_recursive(&src_path, &dst_path)?;
150 } else {
151 std::fs::copy(&src_path, &dst_path)?;
152 }
153 }
154 Ok(())
155}
156
157pub fn merge_plugin_presets(
160 config: &mut HusakoConfig,
161 plugins: &[InstalledPlugin],
162) {
163 for plugin in plugins {
164 for (res_name, res_source) in &plugin.manifest.resources {
165 let key = format!("{}:{}", plugin.name, res_name);
166 config.resources.entry(key).or_insert_with(|| res_source.clone());
167 }
168 for (chart_name, chart_source) in &plugin.manifest.charts {
169 let key = format!("{}:{}", plugin.name, chart_name);
170 config.charts.entry(key).or_insert_with(|| chart_source.clone());
171 }
172 }
173}
174
175pub fn plugin_tsconfig_paths(
179 plugins: &[InstalledPlugin],
180) -> HashMap<String, String> {
181 let mut paths = HashMap::new();
182 for plugin in plugins {
183 for (specifier, rel_path) in &plugin.manifest.modules {
184 let dts_path = rel_path.replace(".js", ".d.ts");
186 let ts_path = format!(".husako/plugins/{}/{}", plugin.name, dts_path);
187 paths.insert(specifier.clone(), ts_path);
188 }
189 }
190 paths
191}
192
193pub fn remove_plugin(
195 project_root: &Path,
196 name: &str,
197) -> Result<bool, HusakoError> {
198 let plugin_dir = project_root.join(".husako/plugins").join(name);
199 if plugin_dir.exists() {
200 std::fs::remove_dir_all(&plugin_dir).map_err(|e| {
201 HusakoError::GenerateIo(format!("remove {}: {e}", plugin_dir.display()))
202 })?;
203 Ok(true)
204 } else {
205 Ok(false)
206 }
207}
208
209pub fn list_plugins(
211 project_root: &Path,
212) -> Vec<InstalledPlugin> {
213 let plugins_dir = project_root.join(".husako/plugins");
214 if !plugins_dir.is_dir() {
215 return Vec::new();
216 }
217
218 let Ok(entries) = std::fs::read_dir(&plugins_dir) else {
219 return Vec::new();
220 };
221
222 let mut plugins = Vec::new();
223 for entry in entries.flatten() {
224 let dir = entry.path();
225 if !dir.is_dir() {
226 continue;
227 }
228 let name = entry.file_name().to_string_lossy().to_string();
229 if let Ok(manifest) = husako_config::load_plugin_manifest(&dir) {
230 plugins.push(InstalledPlugin {
231 name,
232 manifest,
233 dir,
234 });
235 }
236 }
237 plugins.sort_by(|a, b| a.name.cmp(&b.name));
238 plugins
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use husako_config::{ChartSource, SchemaSource};
245
246 #[test]
247 fn install_path_source() {
248 let tmp = tempfile::tempdir().unwrap();
249 let project_root = tmp.path();
250
251 let plugin_src = project_root.join("my-plugin");
253 std::fs::create_dir_all(plugin_src.join("modules")).unwrap();
254 std::fs::write(
255 plugin_src.join("plugin.toml"),
256 r#"
257[plugin]
258name = "test"
259version = "0.1.0"
260
261[modules]
262"test" = "modules/index.js"
263"#,
264 )
265 .unwrap();
266 std::fs::write(
267 plugin_src.join("modules/index.js"),
268 "export function hello() { return 42; }",
269 )
270 .unwrap();
271
272 let config = HusakoConfig {
273 plugins: HashMap::from([(
274 "test".to_string(),
275 PluginSource::Path {
276 path: "my-plugin".to_string(),
277 },
278 )]),
279 ..Default::default()
280 };
281
282 let progress = crate::progress::SilentProgress;
283 let installed = install_plugins(&config, project_root, &progress).unwrap();
284
285 assert_eq!(installed.len(), 1);
286 assert_eq!(installed[0].name, "test");
287 assert_eq!(installed[0].manifest.plugin.version, "0.1.0");
288
289 let installed_dir = project_root.join(".husako/plugins/test");
291 assert!(installed_dir.join("plugin.toml").exists());
292 assert!(installed_dir.join("modules/index.js").exists());
293 }
294
295 #[test]
296 fn install_path_source_missing_dir() {
297 let tmp = tempfile::tempdir().unwrap();
298 let project_root = tmp.path();
299
300 let config = HusakoConfig {
301 plugins: HashMap::from([(
302 "test".to_string(),
303 PluginSource::Path {
304 path: "nonexistent".to_string(),
305 },
306 )]),
307 ..Default::default()
308 };
309
310 let progress = crate::progress::SilentProgress;
311 let err = install_plugins(&config, project_root, &progress).unwrap_err();
312 assert!(err.to_string().contains("not found"));
313 }
314
315 #[test]
316 fn install_replaces_existing() {
317 let tmp = tempfile::tempdir().unwrap();
318 let project_root = tmp.path();
319
320 let plugin_src = project_root.join("my-plugin");
322 std::fs::create_dir_all(&plugin_src).unwrap();
323 std::fs::write(
324 plugin_src.join("plugin.toml"),
325 "[plugin]\nname = \"test\"\nversion = \"0.2.0\"\n",
326 )
327 .unwrap();
328
329 let old_dir = project_root.join(".husako/plugins/test");
331 std::fs::create_dir_all(&old_dir).unwrap();
332 std::fs::write(
333 old_dir.join("plugin.toml"),
334 "[plugin]\nname = \"test\"\nversion = \"0.1.0\"\n",
335 )
336 .unwrap();
337
338 let config = HusakoConfig {
339 plugins: HashMap::from([(
340 "test".to_string(),
341 PluginSource::Path {
342 path: "my-plugin".to_string(),
343 },
344 )]),
345 ..Default::default()
346 };
347
348 let progress = crate::progress::SilentProgress;
349 let installed = install_plugins(&config, project_root, &progress).unwrap();
350
351 assert_eq!(installed[0].manifest.plugin.version, "0.2.0");
352 }
353
354 #[test]
355 fn merge_plugin_presets_adds_resources() {
356 let mut config = HusakoConfig {
357 resources: HashMap::from([(
358 "kubernetes".to_string(),
359 SchemaSource::Release {
360 version: "1.35".to_string(),
361 },
362 )]),
363 ..Default::default()
364 };
365
366 let plugins = vec![InstalledPlugin {
367 name: "flux".to_string(),
368 manifest: PluginManifest {
369 plugin: husako_config::PluginMeta {
370 name: "flux".to_string(),
371 version: "0.1.0".to_string(),
372 description: None,
373 },
374 resources: HashMap::from([(
375 "flux-source".to_string(),
376 SchemaSource::Git {
377 repo: "https://github.com/fluxcd/source-controller".to_string(),
378 tag: "v1.5.0".to_string(),
379 path: "config/crd/bases".to_string(),
380 },
381 )]),
382 charts: HashMap::new(),
383 modules: HashMap::new(),
384 },
385 dir: PathBuf::from("/tmp/plugins/flux"),
386 }];
387
388 merge_plugin_presets(&mut config, &plugins);
389
390 assert!(config.resources.contains_key("kubernetes"));
392 assert!(config.resources.contains_key("flux:flux-source"));
394 }
395
396 #[test]
397 fn merge_plugin_presets_adds_charts() {
398 let mut config = HusakoConfig::default();
399
400 let plugins = vec![InstalledPlugin {
401 name: "my".to_string(),
402 manifest: PluginManifest {
403 plugin: husako_config::PluginMeta {
404 name: "my".to_string(),
405 version: "0.1.0".to_string(),
406 description: None,
407 },
408 resources: HashMap::new(),
409 charts: HashMap::from([(
410 "nginx".to_string(),
411 ChartSource::Registry {
412 repo: "https://charts.bitnami.com/bitnami".to_string(),
413 chart: "nginx".to_string(),
414 version: "16.0.0".to_string(),
415 },
416 )]),
417 modules: HashMap::new(),
418 },
419 dir: PathBuf::from("/tmp/plugins/my"),
420 }];
421
422 merge_plugin_presets(&mut config, &plugins);
423 assert!(config.charts.contains_key("my:nginx"));
424 }
425
426 #[test]
427 fn plugin_tsconfig_paths_builds_mappings() {
428 let plugins = vec![InstalledPlugin {
429 name: "flux".to_string(),
430 manifest: PluginManifest {
431 plugin: husako_config::PluginMeta {
432 name: "flux".to_string(),
433 version: "0.1.0".to_string(),
434 description: None,
435 },
436 resources: HashMap::new(),
437 charts: HashMap::new(),
438 modules: HashMap::from([
439 ("flux".to_string(), "modules/index.js".to_string()),
440 ("flux/helm".to_string(), "modules/helm.js".to_string()),
441 ]),
442 },
443 dir: PathBuf::from("/tmp/plugins/flux"),
444 }];
445
446 let paths = plugin_tsconfig_paths(&plugins);
447 assert_eq!(
448 paths["flux"],
449 ".husako/plugins/flux/modules/index.d.ts"
450 );
451 assert_eq!(
452 paths["flux/helm"],
453 ".husako/plugins/flux/modules/helm.d.ts"
454 );
455 }
456
457 #[test]
458 fn remove_plugin_existing() {
459 let tmp = tempfile::tempdir().unwrap();
460 let project_root = tmp.path();
461
462 let plugin_dir = project_root.join(".husako/plugins/test");
463 std::fs::create_dir_all(&plugin_dir).unwrap();
464 std::fs::write(plugin_dir.join("plugin.toml"), "").unwrap();
465
466 let removed = remove_plugin(project_root, "test").unwrap();
467 assert!(removed);
468 assert!(!plugin_dir.exists());
469 }
470
471 #[test]
472 fn remove_plugin_missing() {
473 let tmp = tempfile::tempdir().unwrap();
474 let removed = remove_plugin(tmp.path(), "nonexistent").unwrap();
475 assert!(!removed);
476 }
477
478 #[test]
479 fn list_plugins_empty() {
480 let tmp = tempfile::tempdir().unwrap();
481 let plugins = list_plugins(tmp.path());
482 assert!(plugins.is_empty());
483 }
484
485 #[test]
486 fn list_plugins_finds_installed() {
487 let tmp = tempfile::tempdir().unwrap();
488 let project_root = tmp.path();
489
490 for name in ["alpha", "beta"] {
492 let plugin_dir = project_root.join(format!(".husako/plugins/{name}"));
493 std::fs::create_dir_all(&plugin_dir).unwrap();
494 std::fs::write(
495 plugin_dir.join("plugin.toml"),
496 format!("[plugin]\nname = \"{name}\"\nversion = \"0.1.0\"\n"),
497 )
498 .unwrap();
499 }
500
501 let plugins = list_plugins(project_root);
502 assert_eq!(plugins.len(), 2);
503 assert_eq!(plugins[0].name, "alpha");
504 assert_eq!(plugins[1].name, "beta");
505 }
506
507 #[test]
508 fn empty_plugins_config() {
509 let tmp = tempfile::tempdir().unwrap();
510 let config = HusakoConfig::default();
511 let progress = crate::progress::SilentProgress;
512 let installed = install_plugins(&config, tmp.path(), &progress).unwrap();
513 assert!(installed.is_empty());
514 }
515}