Skip to main content

husako_core/
plugin.rs

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/// Installed plugin data collected during `install_plugins`.
10#[derive(Debug)]
11pub struct InstalledPlugin {
12    pub name: String,
13    pub manifest: PluginManifest,
14    pub dir: PathBuf,
15}
16
17/// Install all plugins declared in `[plugins]` to `.husako/plugins/<name>/`.
18///
19/// For `source = "git"`, shallow-clones the repository.
20/// For `source = "path"`, copies the directory contents.
21///
22/// Returns a list of installed plugins with their parsed manifests.
23pub 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    // Clean existing install
74    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        // Clean up partial clone
111        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    // Remove .git directory to save space
118    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
157/// Merge plugin resource presets into the config's resources map.
158/// Plugin resources are added with a namespaced key `<plugin>/<resource>` to avoid collisions.
159pub 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
175/// Build plugin module path mappings for tsconfig.json.
176///
177/// Returns a map of import specifier → `.d.ts` path (relative to project root).
178pub 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            // Convert .js path to .d.ts path for TypeScript
185            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
193/// Remove a plugin from `.husako/plugins/`.
194pub 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
209/// List installed plugins from `.husako/plugins/`.
210pub 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        // Create a local plugin
252        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        // Verify files were copied
290        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        // Create plugin source
321        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        // Pre-create old installation
330        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        // Original resource preserved
391        assert!(config.resources.contains_key("kubernetes"));
392        // Plugin resource added with namespaced key
393        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        // Create two installed plugins
491        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}