Skip to main content

husako_config/
lib.rs

1pub mod edit;
2
3use std::collections::HashMap;
4use std::path::Path;
5
6use serde::Deserialize;
7
8pub const CONFIG_FILENAME: &str = "husako.toml";
9
10#[derive(Debug, thiserror::Error)]
11pub enum ConfigError {
12    #[error("failed to read {path}: {source}")]
13    Io {
14        path: String,
15        source: std::io::Error,
16    },
17    #[error("failed to parse husako.toml: {0}")]
18    Parse(String),
19    #[error("config validation error: {0}")]
20    Validation(String),
21}
22
23/// Full `husako.toml` configuration.
24#[derive(Debug, Clone, Deserialize, Default)]
25pub struct HusakoConfig {
26    /// Entry file aliases: `dev = "env/dev.ts"`.
27    #[serde(default)]
28    pub entries: HashMap<String, String>,
29
30    /// Single cluster connection (shorthand for the common case).
31    #[serde(default)]
32    pub cluster: Option<ClusterConfig>,
33
34    /// Named cluster connections for multi-cluster setups.
35    #[serde(default)]
36    pub clusters: HashMap<String, ClusterConfig>,
37
38    /// Resource schema dependencies (renamed from `schemas`).
39    #[serde(default, alias = "schemas")]
40    pub resources: HashMap<String, SchemaSource>,
41
42    /// Chart values schema sources.
43    #[serde(default)]
44    pub charts: HashMap<String, ChartSource>,
45
46    /// Plugin dependencies.
47    #[serde(default)]
48    pub plugins: HashMap<String, PluginSource>,
49}
50
51/// Cluster connection configuration.
52#[derive(Debug, Clone, Deserialize)]
53pub struct ClusterConfig {
54    pub server: String,
55}
56
57/// A schema dependency entry. Every entry must specify `source` explicitly.
58#[derive(Debug, Clone, Deserialize)]
59#[serde(tag = "source")]
60pub enum SchemaSource {
61    /// Fetch OpenAPI v3 specs from kubernetes/kubernetes GitHub releases.
62    /// `kubernetes = { source = "release", version = "1.35" }`
63    #[serde(rename = "release")]
64    Release { version: String },
65
66    /// Fetch all specs from a live K8s API server.
67    /// `cluster-crds = { source = "cluster" }` — uses `[cluster]`
68    /// `dev-crds = { source = "cluster", cluster = "dev" }` — uses `[clusters.dev]`
69    #[serde(rename = "cluster")]
70    Cluster {
71        #[serde(default)]
72        cluster: Option<String>,
73    },
74
75    /// Clone a git repo at a tag and extract CRD YAML manifests.
76    /// `cert-manager = { source = "git", repo = "...", tag = "v1.17.2", path = "deploy/crds" }`
77    #[serde(rename = "git")]
78    Git {
79        repo: String,
80        tag: String,
81        path: String,
82    },
83
84    /// Read CRD YAML from a local file or directory.
85    /// `my-crd = { source = "file", path = "./crds/my-crd.yaml" }`
86    #[serde(rename = "file")]
87    File { path: String },
88}
89
90/// A chart values schema source. Specifies where to find `values.schema.json`.
91#[derive(Debug, Clone, Deserialize)]
92#[serde(tag = "source")]
93pub enum ChartSource {
94    /// Fetch from an HTTP Helm chart repository.
95    /// `ingress-nginx = { source = "registry", repo = "https://...", chart = "ingress-nginx", version = "4.12.0" }`
96    #[serde(rename = "registry")]
97    Registry {
98        repo: String,
99        chart: String,
100        version: String,
101    },
102
103    /// Fetch from ArtifactHub API.
104    /// `postgresql = { source = "artifacthub", package = "bitnami/postgresql", version = "16.4.0" }`
105    #[serde(rename = "artifacthub")]
106    ArtifactHub { package: String, version: String },
107
108    /// Read a local `values.schema.json` file.
109    /// `my-chart = { source = "file", path = "./schemas/my-chart-values.schema.json" }`
110    #[serde(rename = "file")]
111    File { path: String },
112
113    /// Clone a git repo at a tag and extract `values.schema.json`.
114    /// `my-chart = { source = "git", repo = "https://...", tag = "v1.0.0", path = "charts/my-chart" }`
115    #[serde(rename = "git")]
116    Git {
117        repo: String,
118        tag: String,
119        path: String,
120    },
121}
122
123/// A plugin dependency entry in `husako.toml`.
124/// `flux = { source = "git", url = "https://github.com/nanazt/husako-plugin-flux" }`
125/// `my-plugin = { source = "path", path = "./plugins/my-plugin" }`
126#[derive(Debug, Clone, Deserialize)]
127#[serde(tag = "source")]
128pub enum PluginSource {
129    /// Clone from a git repository.
130    #[serde(rename = "git")]
131    Git { url: String },
132
133    /// Use a local directory.
134    #[serde(rename = "path")]
135    Path { path: String },
136}
137
138/// Plugin manifest (`plugin.toml`).
139#[derive(Debug, Clone, Deserialize)]
140pub struct PluginManifest {
141    pub plugin: PluginMeta,
142
143    /// Resource dependency presets.
144    #[serde(default)]
145    pub resources: HashMap<String, SchemaSource>,
146
147    /// Chart dependency presets.
148    #[serde(default)]
149    pub charts: HashMap<String, ChartSource>,
150
151    /// Module import mappings: specifier → relative file path.
152    #[serde(default)]
153    pub modules: HashMap<String, String>,
154}
155
156#[derive(Debug, Clone, Deserialize)]
157pub struct PluginMeta {
158    pub name: String,
159    pub version: String,
160    #[serde(default)]
161    pub description: Option<String>,
162}
163
164pub const PLUGIN_MANIFEST: &str = "plugin.toml";
165
166/// Load a plugin manifest from a directory.
167pub fn load_plugin_manifest(plugin_dir: &Path) -> Result<PluginManifest, ConfigError> {
168    let path = plugin_dir.join(PLUGIN_MANIFEST);
169    if !path.exists() {
170        return Err(ConfigError::Validation(format!(
171            "plugin manifest not found: {}",
172            path.display()
173        )));
174    }
175    let content = std::fs::read_to_string(&path).map_err(|e| ConfigError::Io {
176        path: path.display().to_string(),
177        source: e,
178    })?;
179    let manifest: PluginManifest =
180        toml::from_str(&content).map_err(|e| ConfigError::Parse(e.to_string()))?;
181    Ok(manifest)
182}
183
184/// Load `husako.toml` from the given directory.
185///
186/// Returns `Ok(None)` if the file does not exist.
187/// Returns `Err` if the file exists but cannot be read or parsed.
188pub fn load(project_root: &Path) -> Result<Option<HusakoConfig>, ConfigError> {
189    let path = project_root.join(CONFIG_FILENAME);
190    if !path.exists() {
191        return Ok(None);
192    }
193    let content = std::fs::read_to_string(&path).map_err(|e| ConfigError::Io {
194        path: path.display().to_string(),
195        source: e,
196    })?;
197
198    // Detect deprecated [schemas] section
199    if content.contains("[schemas]") && !content.contains("[resources]") {
200        eprintln!("warning: [schemas] is deprecated in husako.toml, use [resources] instead");
201    }
202
203    let config: HusakoConfig =
204        toml::from_str(&content).map_err(|e| ConfigError::Parse(e.to_string()))?;
205    validate(&config)?;
206    Ok(Some(config))
207}
208
209fn validate(config: &HusakoConfig) -> Result<(), ConfigError> {
210    // Entry paths must be relative
211    for (alias, path) in &config.entries {
212        if Path::new(path).is_absolute() {
213            return Err(ConfigError::Validation(format!(
214                "entry '{alias}' has absolute path '{path}'; use a relative path"
215            )));
216        }
217    }
218
219    // Cannot have both [cluster] and [clusters]
220    if config.cluster.is_some() && !config.clusters.is_empty() {
221        return Err(ConfigError::Validation(
222            "cannot use both [cluster] and [clusters]; use one or the other".to_string(),
223        ));
224    }
225
226    // Schema cluster references must resolve
227    for (name, source) in &config.resources {
228        if let SchemaSource::Cluster {
229            cluster: Some(cluster_name),
230        } = source
231            && !config.clusters.contains_key(cluster_name)
232        {
233            return Err(ConfigError::Validation(format!(
234                "schema '{name}' references unknown cluster '{cluster_name}'; \
235                 define it in [clusters.{cluster_name}]"
236            )));
237        }
238
239        if let SchemaSource::Cluster { cluster: None } = source {
240            if config.cluster.is_none() && config.clusters.is_empty() {
241                return Err(ConfigError::Validation(format!(
242                    "schema '{name}' uses source = \"cluster\" but no [cluster] section is defined"
243                )));
244            }
245            if config.cluster.is_none() && !config.clusters.is_empty() {
246                return Err(ConfigError::Validation(format!(
247                    "schema '{name}' uses source = \"cluster\" without a cluster name; \
248                     specify which cluster to use, e.g. cluster = \"dev\""
249                )));
250            }
251        }
252
253        // File paths must be relative
254        if let SchemaSource::File { path } = source
255            && Path::new(path).is_absolute()
256        {
257            return Err(ConfigError::Validation(format!(
258                "schema '{name}' has absolute path '{path}'; use a relative path"
259            )));
260        }
261    }
262
263    // Chart file paths must be relative
264    for (name, source) in &config.charts {
265        if let ChartSource::File { path } = source
266            && Path::new(path).is_absolute()
267        {
268            return Err(ConfigError::Validation(format!(
269                "chart '{name}' has absolute path '{path}'; use a relative path"
270            )));
271        }
272    }
273
274    // Plugin path sources must be relative
275    for (name, source) in &config.plugins {
276        if let PluginSource::Path { path } = source
277            && Path::new(path).is_absolute()
278        {
279            return Err(ConfigError::Validation(format!(
280                "plugin '{name}' has absolute path '{path}'; use a relative path"
281            )));
282        }
283    }
284
285    Ok(())
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn parse_full_config() {
294        let toml = r#"
295[entries]
296dev = "env/dev.ts"
297staging = "env/staging.ts"
298
299[cluster]
300server = "https://10.0.0.1:6443"
301
302[schemas]
303kubernetes = { source = "release", version = "1.35" }
304cluster-crds = { source = "cluster" }
305cert-manager = { source = "git", repo = "https://github.com/cert-manager/cert-manager", tag = "v1.17.2", path = "deploy/crds" }
306my-crd = { source = "file", path = "./crds/my-crd.yaml" }
307"#;
308        let config: HusakoConfig = toml::from_str(toml).unwrap();
309        assert_eq!(config.entries.len(), 2);
310        assert_eq!(config.entries["dev"], "env/dev.ts");
311        assert_eq!(config.resources.len(), 4);
312        assert!(config.cluster.is_some());
313        assert_eq!(config.cluster.unwrap().server, "https://10.0.0.1:6443");
314
315        assert!(matches!(
316            config.resources["kubernetes"],
317            SchemaSource::Release { ref version } if version == "1.35"
318        ));
319        assert!(matches!(
320            config.resources["cluster-crds"],
321            SchemaSource::Cluster { cluster: None }
322        ));
323        assert!(matches!(
324            config.resources["cert-manager"],
325            SchemaSource::Git { .. }
326        ));
327        assert!(matches!(
328            config.resources["my-crd"],
329            SchemaSource::File { .. }
330        ));
331    }
332
333    #[test]
334    fn parse_multi_cluster_config() {
335        let toml = r#"
336[clusters.dev]
337server = "https://dev:6443"
338
339[clusters.prod]
340server = "https://prod:6443"
341
342[schemas]
343dev-crds = { source = "cluster", cluster = "dev" }
344prod-crds = { source = "cluster", cluster = "prod" }
345"#;
346        let config: HusakoConfig = toml::from_str(toml).unwrap();
347        assert_eq!(config.clusters.len(), 2);
348        assert!(config.cluster.is_none());
349        validate(&config).unwrap();
350    }
351
352    #[test]
353    fn parse_empty_config() {
354        let config: HusakoConfig = toml::from_str("").unwrap();
355        assert!(config.entries.is_empty());
356        assert!(config.resources.is_empty());
357        assert!(config.cluster.is_none());
358        assert!(config.clusters.is_empty());
359    }
360
361    #[test]
362    fn parse_entries_only() {
363        let toml = r#"
364[entries]
365dev = "env/dev.ts"
366"#;
367        let config: HusakoConfig = toml::from_str(toml).unwrap();
368        assert_eq!(config.entries.len(), 1);
369        assert!(config.resources.is_empty());
370    }
371
372    #[test]
373    fn invalid_toml_returns_error() {
374        let result: Result<HusakoConfig, _> = toml::from_str("invalid [[ toml");
375        assert!(result.is_err());
376    }
377
378    #[test]
379    fn reject_absolute_entry_path() {
380        let config = HusakoConfig {
381            entries: HashMap::from([("dev".to_string(), "/absolute/path.ts".to_string())]),
382            ..Default::default()
383        };
384        let err = validate(&config).unwrap_err();
385        assert!(err.to_string().contains("absolute path"));
386    }
387
388    #[test]
389    fn reject_absolute_file_source_path() {
390        let config = HusakoConfig {
391            resources: HashMap::from([(
392                "my-crd".to_string(),
393                SchemaSource::File {
394                    path: "/absolute/crd.yaml".to_string(),
395                },
396            )]),
397            ..Default::default()
398        };
399        let err = validate(&config).unwrap_err();
400        assert!(err.to_string().contains("absolute path"));
401    }
402
403    #[test]
404    fn reject_both_cluster_and_clusters() {
405        let config = HusakoConfig {
406            cluster: Some(ClusterConfig {
407                server: "https://a:6443".to_string(),
408            }),
409            clusters: HashMap::from([(
410                "dev".to_string(),
411                ClusterConfig {
412                    server: "https://b:6443".to_string(),
413                },
414            )]),
415            ..Default::default()
416        };
417        let err = validate(&config).unwrap_err();
418        assert!(err.to_string().contains("cannot use both"));
419    }
420
421    #[test]
422    fn reject_unknown_cluster_reference() {
423        let config = HusakoConfig {
424            clusters: HashMap::from([(
425                "dev".to_string(),
426                ClusterConfig {
427                    server: "https://dev:6443".to_string(),
428                },
429            )]),
430            resources: HashMap::from([(
431                "crds".to_string(),
432                SchemaSource::Cluster {
433                    cluster: Some("staging".to_string()),
434                },
435            )]),
436            ..Default::default()
437        };
438        let err = validate(&config).unwrap_err();
439        assert!(err.to_string().contains("unknown cluster 'staging'"));
440    }
441
442    #[test]
443    fn reject_cluster_source_without_cluster_section() {
444        let config = HusakoConfig {
445            resources: HashMap::from([(
446                "crds".to_string(),
447                SchemaSource::Cluster { cluster: None },
448            )]),
449            ..Default::default()
450        };
451        let err = validate(&config).unwrap_err();
452        assert!(err.to_string().contains("no [cluster] section"));
453    }
454
455    #[test]
456    fn reject_unnamed_cluster_with_named_clusters() {
457        let config = HusakoConfig {
458            clusters: HashMap::from([(
459                "dev".to_string(),
460                ClusterConfig {
461                    server: "https://dev:6443".to_string(),
462                },
463            )]),
464            resources: HashMap::from([(
465                "crds".to_string(),
466                SchemaSource::Cluster { cluster: None },
467            )]),
468            ..Default::default()
469        };
470        let err = validate(&config).unwrap_err();
471        assert!(err.to_string().contains("specify which cluster"));
472    }
473
474    #[test]
475    fn load_missing_file_returns_none() {
476        let tmp = tempfile::tempdir().unwrap();
477        let result = load(tmp.path()).unwrap();
478        assert!(result.is_none());
479    }
480
481    #[test]
482    fn load_valid_file() {
483        let tmp = tempfile::tempdir().unwrap();
484        std::fs::write(
485            tmp.path().join("husako.toml"),
486            r#"
487[entries]
488dev = "env/dev.ts"
489
490[schemas]
491kubernetes = { source = "release", version = "1.35" }
492"#,
493        )
494        .unwrap();
495        let config = load(tmp.path()).unwrap().unwrap();
496        assert_eq!(config.entries["dev"], "env/dev.ts");
497    }
498
499    #[test]
500    fn load_invalid_file_returns_error() {
501        let tmp = tempfile::tempdir().unwrap();
502        std::fs::write(tmp.path().join("husako.toml"), "invalid [[ toml").unwrap();
503        let err = load(tmp.path()).unwrap_err();
504        assert!(matches!(err, ConfigError::Parse(_)));
505    }
506
507    #[test]
508    fn parse_unknown_source_returns_error() {
509        let toml = r#"
510[schemas]
511foo = { source = "unknown", bar = "baz" }
512"#;
513        let result: Result<HusakoConfig, _> = toml::from_str(toml);
514        assert!(result.is_err());
515    }
516
517    #[test]
518    fn parse_resources_section() {
519        let toml = r#"
520[resources]
521kubernetes = { source = "release", version = "1.35" }
522"#;
523        let config: HusakoConfig = toml::from_str(toml).unwrap();
524        assert_eq!(config.resources.len(), 1);
525        assert!(matches!(
526            config.resources["kubernetes"],
527            SchemaSource::Release { ref version } if version == "1.35"
528        ));
529    }
530
531    #[test]
532    fn schemas_alias_still_works() {
533        let toml = r#"
534[schemas]
535kubernetes = { source = "release", version = "1.35" }
536"#;
537        let config: HusakoConfig = toml::from_str(toml).unwrap();
538        assert_eq!(config.resources.len(), 1);
539    }
540
541    #[test]
542    fn parse_charts_section() {
543        let toml = r#"
544[charts]
545ingress-nginx = { source = "registry", repo = "https://kubernetes.github.io/ingress-nginx", chart = "ingress-nginx", version = "4.12.0" }
546postgresql = { source = "artifacthub", package = "bitnami/postgresql", version = "16.4.0" }
547my-chart = { source = "file", path = "./schemas/my-chart.schema.json" }
548my-other = { source = "git", repo = "https://github.com/example/repo", tag = "v1.0.0", path = "charts/my-chart" }
549"#;
550        let config: HusakoConfig = toml::from_str(toml).unwrap();
551        assert_eq!(config.charts.len(), 4);
552        assert!(matches!(
553            config.charts["ingress-nginx"],
554            ChartSource::Registry { .. }
555        ));
556        assert!(matches!(
557            config.charts["postgresql"],
558            ChartSource::ArtifactHub { .. }
559        ));
560        assert!(matches!(
561            config.charts["my-chart"],
562            ChartSource::File { .. }
563        ));
564        assert!(matches!(config.charts["my-other"], ChartSource::Git { .. }));
565    }
566
567    #[test]
568    fn reject_absolute_chart_file_path() {
569        let config = HusakoConfig {
570            charts: HashMap::from([(
571                "my-chart".to_string(),
572                ChartSource::File {
573                    path: "/absolute/schema.json".to_string(),
574                },
575            )]),
576            ..Default::default()
577        };
578        let err = validate(&config).unwrap_err();
579        assert!(err.to_string().contains("absolute path"));
580    }
581
582    #[test]
583    fn parse_plugins_section() {
584        let toml = r#"
585[plugins]
586flux = { source = "git", url = "https://github.com/nanazt/husako-plugin-flux" }
587my-plugin = { source = "path", path = "./plugins/my-plugin" }
588"#;
589        let config: HusakoConfig = toml::from_str(toml).unwrap();
590        assert_eq!(config.plugins.len(), 2);
591        assert!(matches!(
592            config.plugins["flux"],
593            PluginSource::Git { ref url } if url == "https://github.com/nanazt/husako-plugin-flux"
594        ));
595        assert!(matches!(
596            config.plugins["my-plugin"],
597            PluginSource::Path { ref path } if path == "./plugins/my-plugin"
598        ));
599    }
600
601    #[test]
602    fn reject_absolute_plugin_path() {
603        let config = HusakoConfig {
604            plugins: HashMap::from([(
605                "test".to_string(),
606                PluginSource::Path {
607                    path: "/absolute/path".to_string(),
608                },
609            )]),
610            ..Default::default()
611        };
612        let err = validate(&config).unwrap_err();
613        assert!(err.to_string().contains("absolute path"));
614    }
615
616    #[test]
617    fn parse_plugin_manifest() {
618        let toml = r#"
619[plugin]
620name = "flux"
621version = "0.1.0"
622description = "Flux CD integration for husako"
623
624[resources]
625flux-source = { source = "git", repo = "https://github.com/fluxcd/source-controller", tag = "v1.5.0", path = "config/crd/bases" }
626
627[modules]
628"flux" = "modules/index.js"
629"flux/helm" = "modules/helm.js"
630"#;
631        let manifest: PluginManifest = toml::from_str(toml).unwrap();
632        assert_eq!(manifest.plugin.name, "flux");
633        assert_eq!(manifest.plugin.version, "0.1.0");
634        assert_eq!(manifest.plugin.description.as_deref(), Some("Flux CD integration for husako"));
635        assert_eq!(manifest.resources.len(), 1);
636        assert!(matches!(manifest.resources["flux-source"], SchemaSource::Git { .. }));
637        assert_eq!(manifest.modules.len(), 2);
638        assert_eq!(manifest.modules["flux"], "modules/index.js");
639        assert_eq!(manifest.modules["flux/helm"], "modules/helm.js");
640    }
641
642    #[test]
643    fn parse_plugin_manifest_minimal() {
644        let toml = r#"
645[plugin]
646name = "test"
647version = "0.1.0"
648"#;
649        let manifest: PluginManifest = toml::from_str(toml).unwrap();
650        assert_eq!(manifest.plugin.name, "test");
651        assert!(manifest.resources.is_empty());
652        assert!(manifest.charts.is_empty());
653        assert!(manifest.modules.is_empty());
654    }
655
656    #[test]
657    fn load_plugin_manifest_missing() {
658        let tmp = tempfile::tempdir().unwrap();
659        let err = load_plugin_manifest(tmp.path()).unwrap_err();
660        assert!(err.to_string().contains("not found"));
661    }
662
663    #[test]
664    fn load_plugin_manifest_valid() {
665        let tmp = tempfile::tempdir().unwrap();
666        std::fs::write(
667            tmp.path().join("plugin.toml"),
668            r#"
669[plugin]
670name = "test"
671version = "0.1.0"
672
673[modules]
674"test" = "modules/index.js"
675"#,
676        )
677        .unwrap();
678        let manifest = load_plugin_manifest(tmp.path()).unwrap();
679        assert_eq!(manifest.plugin.name, "test");
680        assert_eq!(manifest.modules["test"], "modules/index.js");
681    }
682
683    #[test]
684    fn parse_mixed_resources_and_charts() {
685        let toml = r#"
686[resources]
687kubernetes = { source = "release", version = "1.35" }
688
689[charts]
690my-chart = { source = "file", path = "./values.schema.json" }
691"#;
692        let config: HusakoConfig = toml::from_str(toml).unwrap();
693        assert_eq!(config.resources.len(), 1);
694        assert_eq!(config.charts.len(), 1);
695    }
696}