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#[derive(Debug, Clone, Deserialize, Default)]
25pub struct HusakoConfig {
26 #[serde(default)]
28 pub entries: HashMap<String, String>,
29
30 #[serde(default)]
32 pub cluster: Option<ClusterConfig>,
33
34 #[serde(default)]
36 pub clusters: HashMap<String, ClusterConfig>,
37
38 #[serde(default, alias = "schemas")]
40 pub resources: HashMap<String, SchemaSource>,
41
42 #[serde(default)]
44 pub charts: HashMap<String, ChartSource>,
45
46 #[serde(default)]
48 pub plugins: HashMap<String, PluginSource>,
49}
50
51#[derive(Debug, Clone, Deserialize)]
53pub struct ClusterConfig {
54 pub server: String,
55}
56
57#[derive(Debug, Clone, Deserialize)]
59#[serde(tag = "source")]
60pub enum SchemaSource {
61 #[serde(rename = "release")]
64 Release { version: String },
65
66 #[serde(rename = "cluster")]
70 Cluster {
71 #[serde(default)]
72 cluster: Option<String>,
73 },
74
75 #[serde(rename = "git")]
78 Git {
79 repo: String,
80 tag: String,
81 path: String,
82 },
83
84 #[serde(rename = "file")]
87 File { path: String },
88}
89
90#[derive(Debug, Clone, Deserialize)]
92#[serde(tag = "source")]
93pub enum ChartSource {
94 #[serde(rename = "registry")]
97 Registry {
98 repo: String,
99 chart: String,
100 version: String,
101 },
102
103 #[serde(rename = "artifacthub")]
106 ArtifactHub { package: String, version: String },
107
108 #[serde(rename = "file")]
111 File { path: String },
112
113 #[serde(rename = "git")]
116 Git {
117 repo: String,
118 tag: String,
119 path: String,
120 },
121}
122
123#[derive(Debug, Clone, Deserialize)]
127#[serde(tag = "source")]
128pub enum PluginSource {
129 #[serde(rename = "git")]
131 Git { url: String },
132
133 #[serde(rename = "path")]
135 Path { path: String },
136}
137
138#[derive(Debug, Clone, Deserialize)]
140pub struct PluginManifest {
141 pub plugin: PluginMeta,
142
143 #[serde(default)]
145 pub resources: HashMap<String, SchemaSource>,
146
147 #[serde(default)]
149 pub charts: HashMap<String, ChartSource>,
150
151 #[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
166pub 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
184pub 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 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 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 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 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 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 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 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}