1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8use crate::deep_merge_yaml;
9use crate::errors::{ConfigError, Result};
10use crate::union_extend;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct CfgdConfig {
17 pub api_version: String,
18 pub kind: String,
19 pub metadata: ConfigMetadata,
20 pub spec: ConfigSpec,
21}
22
23impl CfgdConfig {
24 pub fn active_profile(&self) -> Result<&str> {
26 self.spec
27 .profile
28 .as_deref()
29 .filter(|p| !p.is_empty())
30 .ok_or_else(|| {
31 crate::errors::CfgdError::Config(crate::errors::ConfigError::Invalid {
32 message: "no profile configured — run: cfgd profile create <name>".to_string(),
33 })
34 })
35 }
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(rename_all = "camelCase")]
40pub struct ConfigMetadata {
41 pub name: String,
42}
43
44#[derive(Debug, Clone, Default, Serialize, Deserialize)]
45#[serde(rename_all = "camelCase")]
46pub struct ConfigSpec {
47 #[serde(default)]
48 pub profile: Option<String>,
49
50 #[serde(default)]
51 pub origin: Vec<OriginSpec>,
52
53 #[serde(default)]
54 pub daemon: Option<DaemonConfig>,
55
56 #[serde(default)]
57 pub secrets: Option<SecretsConfig>,
58
59 #[serde(default)]
60 pub sources: Vec<SourceSpec>,
61
62 #[serde(default)]
63 pub theme: Option<ThemeConfig>,
64
65 #[serde(default)]
67 pub modules: Option<ModulesConfig>,
68
69 #[serde(default)]
71 pub file_strategy: FileStrategy,
72
73 #[serde(default)]
75 pub security: Option<SecurityConfig>,
76
77 #[serde(default)]
80 pub aliases: HashMap<String, String>,
81
82 #[serde(default)]
84 pub ai: Option<AiConfig>,
85
86 #[serde(default)]
88 pub compliance: Option<ComplianceConfig>,
89}
90
91pub fn minimal_config() -> CfgdConfig {
93 CfgdConfig {
94 api_version: crate::API_VERSION.to_string(),
95 kind: "Config".to_string(),
96 metadata: ConfigMetadata {
97 name: "default".to_string(),
98 },
99 spec: ConfigSpec::default(),
100 }
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct AiConfig {
106 #[serde(default = "default_ai_provider")]
107 pub provider: String,
108 #[serde(default = "default_ai_model")]
109 pub model: String,
110 #[serde(default = "default_api_key_env")]
111 pub api_key_env: String,
112}
113
114impl Default for AiConfig {
115 fn default() -> Self {
116 Self {
117 provider: default_ai_provider(),
118 model: default_ai_model(),
119 api_key_env: default_api_key_env(),
120 }
121 }
122}
123
124fn default_ai_provider() -> String {
125 "claude".into()
126}
127fn default_ai_model() -> String {
128 "claude-sonnet-4-6".into()
129}
130fn default_api_key_env() -> String {
131 "ANTHROPIC_API_KEY".into()
132}
133
134#[derive(Debug, Clone, Default, Serialize, Deserialize)]
135#[serde(rename_all = "camelCase")]
136pub struct SecurityConfig {
137 #[serde(default)]
140 pub allow_unsigned: bool,
141}
142
143#[derive(Debug, Clone, Default, Serialize, Deserialize)]
144#[serde(rename_all = "camelCase")]
145pub struct ModulesConfig {
146 #[serde(default)]
148 pub registries: Vec<ModuleRegistryEntry>,
149
150 #[serde(default)]
152 pub security: Option<ModuleSecurityConfig>,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156#[serde(rename_all = "camelCase")]
157pub struct ModuleSecurityConfig {
158 #[serde(default)]
161 pub require_signatures: bool,
162}
163
164#[derive(Debug, Clone, Serialize)]
165#[serde(rename_all = "camelCase")]
166pub struct ThemeConfig {
167 #[serde(default = "default_theme_name")]
168 pub name: String,
169 #[serde(default, skip_serializing_if = "ThemeOverrides::is_empty")]
170 pub overrides: ThemeOverrides,
171}
172
173fn default_theme_name() -> String {
174 "default".to_string()
175}
176
177impl Default for ThemeConfig {
178 fn default() -> Self {
179 Self {
180 name: default_theme_name(),
181 overrides: ThemeOverrides::default(),
182 }
183 }
184}
185
186impl<'de> serde::Deserialize<'de> for ThemeConfig {
188 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
189 where
190 D: serde::Deserializer<'de>,
191 {
192 use serde::de;
193
194 struct ThemeVisitor;
195 impl<'de> de::Visitor<'de> for ThemeVisitor {
196 type Value = ThemeConfig;
197 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
198 f.write_str("a theme name string or a theme config mapping")
199 }
200 fn visit_str<E: de::Error>(self, v: &str) -> std::result::Result<ThemeConfig, E> {
201 Ok(ThemeConfig {
202 name: v.to_string(),
203 overrides: ThemeOverrides::default(),
204 })
205 }
206 fn visit_map<M: de::MapAccess<'de>>(
207 self,
208 map: M,
209 ) -> std::result::Result<ThemeConfig, M::Error> {
210 #[derive(Deserialize)]
211 #[serde(rename_all = "camelCase")]
212 struct Inner {
213 #[serde(default = "default_theme_name")]
214 name: String,
215 #[serde(default)]
216 overrides: ThemeOverrides,
217 }
218 let inner = Inner::deserialize(de::value::MapAccessDeserializer::new(map))?;
219 Ok(ThemeConfig {
220 name: inner.name,
221 overrides: inner.overrides,
222 })
223 }
224 }
225 deserializer.deserialize_any(ThemeVisitor)
226 }
227}
228
229#[derive(Debug, Clone, Default, Serialize, Deserialize)]
230#[serde(rename_all = "camelCase")]
231pub struct ThemeOverrides {
232 pub success: Option<String>,
233 pub warning: Option<String>,
234 pub error: Option<String>,
235 pub info: Option<String>,
236 pub muted: Option<String>,
237 pub header: Option<String>,
238 pub subheader: Option<String>,
239 pub key: Option<String>,
240 pub value: Option<String>,
241 pub diff_add: Option<String>,
242 pub diff_remove: Option<String>,
243 pub diff_context: Option<String>,
244 pub icon_success: Option<String>,
245 pub icon_warning: Option<String>,
246 pub icon_error: Option<String>,
247 pub icon_info: Option<String>,
248 pub icon_pending: Option<String>,
249 pub icon_arrow: Option<String>,
250}
251
252impl ThemeOverrides {
253 pub fn is_empty(&self) -> bool {
254 self.success.is_none()
255 && self.warning.is_none()
256 && self.error.is_none()
257 && self.info.is_none()
258 && self.muted.is_none()
259 && self.header.is_none()
260 && self.subheader.is_none()
261 && self.key.is_none()
262 && self.value.is_none()
263 && self.diff_add.is_none()
264 && self.diff_remove.is_none()
265 && self.diff_context.is_none()
266 && self.icon_success.is_none()
267 && self.icon_warning.is_none()
268 && self.icon_error.is_none()
269 && self.icon_info.is_none()
270 && self.icon_pending.is_none()
271 && self.icon_arrow.is_none()
272 }
273}
274
275impl ConfigSpec {
278 pub fn primary_origin(&self) -> Option<&OriginSpec> {
279 self.origin.first()
280 }
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
284#[serde(rename_all = "camelCase")]
285pub struct OriginSpec {
286 #[serde(rename = "type")]
287 pub origin_type: OriginType,
288 pub url: String,
289 #[serde(default = "default_branch")]
290 pub branch: String,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub auth: Option<String>,
293 #[serde(default)]
298 pub ssh_strict_host_key_checking: SshHostKeyPolicy,
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
303pub enum SshHostKeyPolicy {
304 #[default]
306 AcceptNew,
307 Yes,
309 No,
311}
312
313impl SshHostKeyPolicy {
314 pub fn as_ssh_option(&self) -> &'static str {
315 match self {
316 SshHostKeyPolicy::AcceptNew => "accept-new",
317 SshHostKeyPolicy::Yes => "yes",
318 SshHostKeyPolicy::No => "no",
319 }
320 }
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub enum OriginType {
325 Git,
326 Server,
327}
328
329fn default_branch() -> String {
330 "master".to_string()
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize)]
334#[serde(rename_all = "camelCase")]
335pub struct DaemonConfig {
336 #[serde(default)]
337 pub enabled: bool,
338 #[serde(default)]
339 pub reconcile: Option<ReconcileConfig>,
340 #[serde(default)]
341 pub sync: Option<SyncConfig>,
342 #[serde(default)]
343 pub notify: Option<NotifyConfig>,
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize)]
347#[serde(rename_all = "camelCase")]
348pub struct ReconcileConfig {
349 #[serde(default = "default_reconcile_interval")]
350 pub interval: String,
351 #[serde(default)]
352 pub on_change: bool,
353 #[serde(default)]
354 pub auto_apply: bool,
355 #[serde(default)]
356 pub policy: Option<AutoApplyPolicyConfig>,
357 #[serde(default)]
361 pub drift_policy: DriftPolicy,
362 #[serde(default)]
366 pub patches: Vec<ReconcilePatch>,
367}
368
369#[derive(Debug, Clone, Serialize, Deserialize)]
372#[serde(rename_all = "camelCase")]
373pub struct ReconcilePatch {
374 pub kind: ReconcilePatchKind,
375 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub name: Option<String>,
377 #[serde(default, skip_serializing_if = "Option::is_none")]
378 pub interval: Option<String>,
379 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub auto_apply: Option<bool>,
381 #[serde(default, skip_serializing_if = "Option::is_none")]
382 pub drift_policy: Option<DriftPolicy>,
383}
384
385#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
386pub enum ReconcilePatchKind {
387 Module,
388 Profile,
389}
390
391#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
393pub enum DriftPolicy {
394 Auto,
396 #[default]
398 NotifyOnly,
399 Prompt,
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize)]
404#[serde(rename_all = "camelCase")]
405pub struct AutoApplyPolicyConfig {
406 #[serde(default = "default_policy_notify")]
407 pub new_recommended: PolicyAction,
408 #[serde(default = "default_policy_ignore")]
409 pub new_optional: PolicyAction,
410 #[serde(default = "default_policy_notify")]
411 pub locked_conflict: PolicyAction,
412}
413
414impl Default for AutoApplyPolicyConfig {
415 fn default() -> Self {
416 Self {
417 new_recommended: PolicyAction::Notify,
418 new_optional: PolicyAction::Ignore,
419 locked_conflict: PolicyAction::Notify,
420 }
421 }
422}
423
424#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
425pub enum PolicyAction {
426 Notify,
427 Accept,
428 Reject,
429 Ignore,
430}
431
432fn default_policy_notify() -> PolicyAction {
433 PolicyAction::Notify
434}
435
436fn default_policy_ignore() -> PolicyAction {
437 PolicyAction::Ignore
438}
439
440fn default_reconcile_interval() -> String {
441 "5m".to_string()
442}
443
444#[derive(Debug, Clone, Serialize, Deserialize)]
445#[serde(rename_all = "camelCase")]
446pub struct SyncConfig {
447 #[serde(default)]
448 pub auto_push: bool,
449 #[serde(default)]
450 pub auto_pull: bool,
451 #[serde(default = "default_sync_interval")]
452 pub interval: String,
453}
454
455#[derive(Debug, Clone, Serialize, Deserialize)]
456#[serde(rename_all = "camelCase")]
457pub struct NotifyConfig {
458 #[serde(default)]
459 pub drift: bool,
460 #[serde(default)]
461 pub method: NotifyMethod,
462 #[serde(default, skip_serializing_if = "Option::is_none")]
463 pub webhook_url: Option<String>,
464}
465
466#[derive(Debug, Clone, Default, Serialize, Deserialize)]
467pub enum NotifyMethod {
468 #[default]
469 Desktop,
470 Stdout,
471 Webhook,
472}
473
474#[derive(Debug, Clone, Serialize, Deserialize)]
475#[serde(rename_all = "camelCase")]
476pub struct SecretsConfig {
477 #[serde(default = "default_secrets_backend")]
478 pub backend: String,
479 #[serde(default, skip_serializing_if = "Option::is_none")]
480 pub sops: Option<SopsConfig>,
481 #[serde(default)]
482 pub integrations: Vec<SecretIntegration>,
483}
484
485fn default_secrets_backend() -> String {
486 "sops".to_string()
487}
488
489#[derive(Debug, Clone, Serialize, Deserialize)]
490#[serde(rename_all = "camelCase")]
491pub struct SopsConfig {
492 #[serde(default, skip_serializing_if = "Option::is_none")]
493 pub age_key: Option<PathBuf>,
494}
495
496#[derive(Debug, Clone, Serialize, Deserialize)]
497#[serde(rename_all = "camelCase")]
498pub struct SecretIntegration {
499 pub name: String,
500 #[serde(flatten)]
501 pub extra: HashMap<String, serde_yaml::Value>,
502}
503
504#[derive(Debug, Clone, Serialize, Deserialize)]
507#[serde(rename_all = "camelCase")]
508pub struct SourceSpec {
509 pub name: String,
510 pub origin: OriginSpec,
511 #[serde(default)]
512 pub subscription: SubscriptionSpec,
513 #[serde(default)]
514 pub sync: SourceSyncSpec,
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize)]
518#[serde(rename_all = "camelCase")]
519pub struct SubscriptionSpec {
520 #[serde(default, skip_serializing_if = "Option::is_none")]
521 pub profile: Option<String>,
522 #[serde(default = "default_source_priority")]
523 pub priority: u32,
524 #[serde(default)]
525 pub accept_recommended: bool,
526 #[serde(default)]
527 pub opt_in: Vec<String>,
528 #[serde(default)]
529 pub overrides: serde_yaml::Value,
530 #[serde(default)]
531 pub reject: serde_yaml::Value,
532}
533
534impl Default for SubscriptionSpec {
535 fn default() -> Self {
536 Self {
537 profile: None,
538 priority: default_source_priority(),
539 accept_recommended: false,
540 opt_in: Vec::new(),
541 overrides: serde_yaml::Value::Null,
542 reject: serde_yaml::Value::Null,
543 }
544 }
545}
546
547fn default_source_priority() -> u32 {
548 500
549}
550
551#[derive(Debug, Clone, Serialize, Deserialize)]
552#[serde(rename_all = "camelCase")]
553pub struct SourceSyncSpec {
554 #[serde(default = "default_sync_interval")]
555 pub interval: String,
556 #[serde(default)]
557 pub auto_apply: bool,
558 #[serde(default, skip_serializing_if = "Option::is_none")]
559 pub pin_version: Option<String>,
560}
561
562impl Default for SourceSyncSpec {
563 fn default() -> Self {
564 Self {
565 interval: default_sync_interval(),
566 auto_apply: false,
567 pin_version: None,
568 }
569 }
570}
571
572fn default_sync_interval() -> String {
573 "1h".to_string()
574}
575
576#[derive(Debug, Clone, Serialize, Deserialize)]
579#[serde(rename_all = "camelCase")]
580pub struct ConfigSourceDocument {
581 pub api_version: String,
582 pub kind: String,
583 pub metadata: ConfigSourceMetadata,
584 pub spec: ConfigSourceSpec,
585}
586
587#[derive(Debug, Clone, Serialize, Deserialize)]
588#[serde(rename_all = "camelCase")]
589pub struct ConfigSourceMetadata {
590 pub name: String,
591 #[serde(default)]
592 pub version: Option<String>,
593 #[serde(default)]
594 pub description: Option<String>,
595}
596
597#[derive(Debug, Clone, Serialize, Deserialize)]
598#[serde(rename_all = "camelCase")]
599pub struct ConfigSourceSpec {
600 #[serde(default)]
601 pub provides: ConfigSourceProvides,
602 #[serde(default)]
603 pub policy: ConfigSourcePolicy,
604}
605
606#[derive(Debug, Clone, Default, Serialize, Deserialize)]
607#[serde(rename_all = "camelCase")]
608pub struct ConfigSourceProvides {
609 #[serde(default)]
610 pub profiles: Vec<String>,
611 #[serde(default)]
612 pub profile_details: Vec<ConfigSourceProfileEntry>,
613 #[serde(default)]
614 pub platform_profiles: HashMap<String, String>,
615 #[serde(default)]
616 pub modules: Vec<String>,
617}
618
619#[derive(Debug, Clone, Serialize, Deserialize)]
622#[serde(rename_all = "camelCase")]
623pub struct ConfigSourceProfileEntry {
624 pub name: String,
625 #[serde(default)]
626 pub description: Option<String>,
627 #[serde(default)]
628 pub path: Option<String>,
629 #[serde(default)]
630 pub inherits: Vec<String>,
631}
632
633#[derive(Debug, Clone, Default, Serialize, Deserialize)]
634#[serde(rename_all = "camelCase")]
635pub struct ConfigSourcePolicy {
636 #[serde(default)]
637 pub required: PolicyItems,
638 #[serde(default)]
639 pub recommended: PolicyItems,
640 #[serde(default)]
641 pub optional: PolicyItems,
642 #[serde(default)]
643 pub locked: PolicyItems,
644 #[serde(default)]
645 pub constraints: SourceConstraints,
646}
647
648#[derive(Debug, Clone, Serialize, PartialEq)]
649#[serde(rename_all = "camelCase")]
650pub struct EnvVar {
651 pub name: String,
652 pub value: String,
653}
654
655impl<'de> Deserialize<'de> for EnvVar {
656 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
657 where
658 D: serde::Deserializer<'de>,
659 {
660 #[derive(Deserialize)]
661 #[serde(rename_all = "camelCase")]
662 struct Raw {
663 name: String,
664 value: String,
665 }
666 let raw = Raw::deserialize(deserializer)?;
667 crate::validate_env_var_name(&raw.name).map_err(serde::de::Error::custom)?;
668 Ok(EnvVar {
669 name: raw.name,
670 value: raw.value,
671 })
672 }
673}
674
675#[derive(Debug, Clone, Serialize, PartialEq)]
676#[serde(rename_all = "camelCase")]
677pub struct ShellAlias {
678 pub name: String,
679 pub command: String,
680}
681
682impl<'de> Deserialize<'de> for ShellAlias {
683 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
684 where
685 D: serde::Deserializer<'de>,
686 {
687 #[derive(Deserialize)]
688 #[serde(rename_all = "camelCase")]
689 struct Raw {
690 name: String,
691 command: String,
692 }
693 let raw = Raw::deserialize(deserializer)?;
694 crate::validate_alias_name(&raw.name).map_err(serde::de::Error::custom)?;
695 Ok(ShellAlias {
696 name: raw.name,
697 command: raw.command,
698 })
699 }
700}
701
702#[derive(Debug, Clone, Default, Serialize, Deserialize)]
703#[serde(rename_all = "camelCase")]
704pub struct PolicyItems {
705 #[serde(default)]
706 pub packages: Option<PackagesSpec>,
707 #[serde(default)]
708 pub files: Vec<ManagedFileSpec>,
709 #[serde(default)]
710 pub env: Vec<EnvVar>,
711 #[serde(default)]
712 pub aliases: Vec<ShellAlias>,
713 #[serde(default)]
714 pub system: HashMap<String, serde_yaml::Value>,
715 #[serde(default)]
716 pub profiles: Vec<String>,
717 #[serde(default)]
718 pub modules: Vec<String>,
719 #[serde(default)]
720 pub secrets: Vec<SecretSpec>,
721}
722
723#[derive(Debug, Clone, Serialize, Deserialize)]
724#[serde(rename_all = "camelCase")]
725pub struct SourceConstraints {
726 #[serde(default = "default_true")]
727 pub no_scripts: bool,
728 #[serde(default = "default_true")]
729 pub no_secrets_read: bool,
730 #[serde(default)]
731 pub allowed_target_paths: Vec<String>,
732 #[serde(default)]
733 pub allow_system_changes: bool,
734 #[serde(default)]
737 pub require_signed_commits: bool,
738 #[serde(default, skip_serializing_if = "Option::is_none")]
740 pub encryption: Option<EncryptionConstraint>,
741}
742
743impl Default for SourceConstraints {
744 fn default() -> Self {
745 Self {
746 no_scripts: true,
747 no_secrets_read: true,
748 allowed_target_paths: Vec::new(),
749 allow_system_changes: false,
750 require_signed_commits: false,
751 encryption: None,
752 }
753 }
754}
755
756fn default_true() -> bool {
757 true
758}
759
760#[derive(Debug, Clone, Serialize, Deserialize)]
765#[serde(rename_all = "camelCase")]
766pub struct ComplianceConfig {
767 #[serde(default)]
768 pub enabled: bool,
769 #[serde(default = "default_compliance_interval")]
770 pub interval: String,
771 #[serde(default = "default_compliance_retention")]
772 pub retention: String,
773 #[serde(default)]
774 pub scope: ComplianceScope,
775 #[serde(default)]
776 pub export: ComplianceExport,
777}
778
779fn default_compliance_interval() -> String {
780 "1h".into()
781}
782fn default_compliance_retention() -> String {
783 "30d".into()
784}
785
786#[derive(Debug, Clone, Serialize, Deserialize)]
787#[serde(rename_all = "camelCase")]
788pub struct ComplianceScope {
789 #[serde(default = "default_true")]
790 pub files: bool,
791 #[serde(default = "default_true")]
792 pub packages: bool,
793 #[serde(default = "default_true")]
794 pub system: bool,
795 #[serde(default = "default_true")]
796 pub secrets: bool,
797 #[serde(default)]
798 pub watch_paths: Vec<String>,
799 #[serde(default)]
800 pub watch_package_managers: Vec<String>,
801}
802
803impl Default for ComplianceScope {
804 fn default() -> Self {
805 Self {
806 files: true,
807 packages: true,
808 system: true,
809 secrets: true,
810 watch_paths: Vec::new(),
811 watch_package_managers: Vec::new(),
812 }
813 }
814}
815
816#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
817pub enum ComplianceFormat {
818 #[default]
819 Json,
820 Yaml,
821}
822
823#[derive(Debug, Clone, Serialize, Deserialize)]
824#[serde(rename_all = "camelCase")]
825pub struct ComplianceExport {
826 #[serde(default)]
827 pub format: ComplianceFormat,
828 #[serde(default = "default_compliance_path")]
829 pub path: String,
830}
831
832fn default_compliance_path() -> String {
833 "~/.local/share/cfgd/compliance/".into()
834}
835
836impl Default for ComplianceExport {
837 fn default() -> Self {
838 Self {
839 format: ComplianceFormat::default(),
840 path: default_compliance_path(),
841 }
842 }
843}
844
845const MAX_YAML_ANCHORS: usize = 256;
848
849fn check_yaml_anchor_limit(contents: &str, context: &Path) -> Result<()> {
852 let anchor_count = contents
856 .as_bytes()
857 .windows(2)
858 .filter(|w| w[0] == b'&' && (w[1].is_ascii_alphanumeric() || w[1] == b'_'))
859 .count();
860
861 if anchor_count > MAX_YAML_ANCHORS {
862 return Err(ConfigError::Invalid {
863 message: format!(
864 "{}: too many YAML anchors ({}, max {}) — possible anchor/alias bomb",
865 context.display(),
866 anchor_count,
867 MAX_YAML_ANCHORS
868 ),
869 }
870 .into());
871 }
872 Ok(())
873}
874
875pub fn parse_config_source(contents: &str) -> Result<ConfigSourceDocument> {
877 check_yaml_anchor_limit(contents, Path::new("ConfigSource"))?;
878 let doc: ConfigSourceDocument = serde_yaml::from_str(contents).map_err(ConfigError::from)?;
879
880 if doc.kind != "ConfigSource" {
881 return Err(ConfigError::Invalid {
882 message: format!("expected kind 'ConfigSource', got '{}'", doc.kind),
883 }
884 .into());
885 }
886
887 Ok(doc)
888}
889
890#[derive(Debug, Clone, Serialize, Deserialize)]
893#[serde(rename_all = "camelCase")]
894pub struct ModuleDocument {
895 pub api_version: String,
896 pub kind: String,
897 pub metadata: ModuleMetadata,
898 pub spec: ModuleSpec,
899}
900
901#[derive(Debug, Clone, Serialize, Deserialize)]
902#[serde(rename_all = "camelCase")]
903pub struct ModuleMetadata {
904 pub name: String,
905 #[serde(default)]
906 pub description: Option<String>,
907}
908
909#[derive(Debug, Clone, Default, Serialize, Deserialize)]
910#[serde(rename_all = "camelCase")]
911pub struct ModuleSpec {
912 #[serde(default, skip_serializing_if = "Vec::is_empty")]
913 pub depends: Vec<String>,
914
915 #[serde(default, skip_serializing_if = "Vec::is_empty")]
916 pub packages: Vec<ModulePackageEntry>,
917
918 #[serde(default, skip_serializing_if = "Vec::is_empty")]
919 pub files: Vec<ModuleFileEntry>,
920
921 #[serde(default, skip_serializing_if = "Vec::is_empty")]
922 pub env: Vec<EnvVar>,
923
924 #[serde(default, skip_serializing_if = "Vec::is_empty")]
925 pub aliases: Vec<ShellAlias>,
926
927 #[serde(default, skip_serializing_if = "Option::is_none")]
928 pub scripts: Option<ScriptSpec>,
929
930 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
933 pub system: HashMap<String, serde_yaml::Value>,
934}
935
936#[derive(Debug, Clone, Default, Serialize, Deserialize)]
937#[serde(rename_all = "camelCase")]
938pub struct ModulePackageEntry {
939 #[serde(default)]
940 pub name: String,
941
942 #[serde(default, skip_serializing_if = "Option::is_none")]
943 pub min_version: Option<String>,
944
945 #[serde(default, skip_serializing_if = "Vec::is_empty")]
946 pub prefer: Vec<String>,
947
948 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
949 pub aliases: HashMap<String, String>,
950
951 #[serde(default, skip_serializing_if = "Option::is_none")]
952 pub script: Option<String>,
953
954 #[serde(default, skip_serializing_if = "Vec::is_empty")]
955 pub deny: Vec<String>,
956
957 #[serde(default, skip_serializing_if = "Vec::is_empty")]
958 pub platforms: Vec<String>,
959}
960
961#[derive(Debug, Clone, Serialize, Deserialize)]
962#[serde(rename_all = "camelCase")]
963pub struct ModuleFileEntry {
964 pub source: String,
965 pub target: String,
966 #[serde(default, skip_serializing_if = "Option::is_none")]
968 pub strategy: Option<FileStrategy>,
969 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
972 pub private: bool,
973 #[serde(default, skip_serializing_if = "Option::is_none")]
975 pub encryption: Option<EncryptionSpec>,
976}
977
978#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
979#[serde(untagged)]
980pub enum ScriptEntry {
981 Simple(String),
982 Full {
983 run: String,
984 #[serde(default, skip_serializing_if = "Option::is_none")]
985 timeout: Option<String>,
986 #[serde(
990 default,
991 skip_serializing_if = "Option::is_none",
992 rename = "idleTimeout"
993 )]
994 idle_timeout: Option<String>,
995 #[serde(
996 default,
997 skip_serializing_if = "Option::is_none",
998 rename = "continueOnError"
999 )]
1000 continue_on_error: Option<bool>,
1001 },
1002}
1003
1004impl ScriptEntry {
1005 pub fn run_str(&self) -> &str {
1007 match self {
1008 ScriptEntry::Simple(s) => s,
1009 ScriptEntry::Full { run, .. } => run,
1010 }
1011 }
1012}
1013
1014impl std::fmt::Display for ScriptEntry {
1015 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1016 f.write_str(self.run_str())
1017 }
1018}
1019
1020#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1025#[serde(rename_all = "camelCase")]
1026pub struct ModuleLockfile {
1027 #[serde(default)]
1028 pub modules: Vec<ModuleLockEntry>,
1029}
1030
1031#[derive(Debug, Clone, Serialize, Deserialize)]
1033#[serde(rename_all = "camelCase")]
1034pub struct ModuleLockEntry {
1035 pub name: String,
1037 pub url: String,
1039 pub pinned_ref: String,
1041 pub commit: String,
1043 pub integrity: String,
1045 #[serde(default, skip_serializing_if = "Option::is_none")]
1047 pub subdir: Option<String>,
1048}
1049
1050#[derive(Debug, Clone, Serialize, Deserialize)]
1054#[serde(rename_all = "camelCase")]
1055pub struct ModuleRegistryEntry {
1056 pub name: String,
1058 pub url: String,
1060}
1061
1062pub fn parse_module(contents: &str) -> Result<ModuleDocument> {
1064 check_yaml_anchor_limit(contents, Path::new("Module"))?;
1065 let doc: ModuleDocument = serde_yaml::from_str(contents).map_err(ConfigError::from)?;
1066
1067 if doc.kind != "Module" {
1068 return Err(ConfigError::Invalid {
1069 message: format!("expected kind 'Module', got '{}'", doc.kind),
1070 }
1071 .into());
1072 }
1073
1074 Ok(doc)
1075}
1076
1077#[derive(Debug, Clone, Serialize, Deserialize)]
1080#[serde(rename_all = "camelCase")]
1081pub struct ProfileDocument {
1082 pub api_version: String,
1083 pub kind: String,
1084 pub metadata: ProfileMetadata,
1085 pub spec: ProfileSpec,
1086}
1087
1088#[derive(Debug, Clone, Serialize, Deserialize)]
1089#[serde(rename_all = "camelCase")]
1090pub struct ProfileMetadata {
1091 pub name: String,
1092}
1093
1094#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1095#[serde(rename_all = "camelCase")]
1096pub struct ProfileSpec {
1097 #[serde(default)]
1098 pub inherits: Vec<String>,
1099
1100 #[serde(default)]
1101 pub modules: Vec<String>,
1102
1103 #[serde(default)]
1104 pub env: Vec<EnvVar>,
1105
1106 #[serde(default)]
1107 pub aliases: Vec<ShellAlias>,
1108
1109 #[serde(default)]
1110 pub packages: Option<PackagesSpec>,
1111
1112 #[serde(default)]
1113 pub files: Option<FilesSpec>,
1114
1115 #[serde(default)]
1116 pub system: HashMap<String, serde_yaml::Value>,
1117
1118 #[serde(default)]
1119 pub secrets: Vec<SecretSpec>,
1120
1121 #[serde(default)]
1122 pub scripts: Option<ScriptSpec>,
1123}
1124
1125#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1126#[serde(rename_all = "camelCase")]
1127pub struct PackagesSpec {
1128 #[serde(default)]
1129 pub brew: Option<BrewSpec>,
1130 #[serde(default)]
1131 pub apt: Option<AptSpec>,
1132 #[serde(default)]
1133 pub cargo: Option<CargoSpec>,
1134 #[serde(default)]
1135 pub npm: Option<NpmSpec>,
1136 #[serde(default)]
1137 pub pipx: Vec<String>,
1138 #[serde(default)]
1139 pub dnf: Vec<String>,
1140 #[serde(default)]
1141 pub apk: Vec<String>,
1142 #[serde(default)]
1143 pub pacman: Vec<String>,
1144 #[serde(default)]
1145 pub zypper: Vec<String>,
1146 #[serde(default)]
1147 pub yum: Vec<String>,
1148 #[serde(default)]
1149 pub pkg: Vec<String>,
1150 #[serde(default)]
1151 pub snap: Option<SnapSpec>,
1152 #[serde(default)]
1153 pub flatpak: Option<FlatpakSpec>,
1154 #[serde(default)]
1155 pub nix: Vec<String>,
1156 #[serde(default)]
1157 pub go: Vec<String>,
1158 #[serde(default)]
1159 pub winget: Vec<String>,
1160 #[serde(default)]
1161 pub chocolatey: Vec<String>,
1162 #[serde(default)]
1163 pub scoop: Vec<String>,
1164 #[serde(default)]
1165 pub custom: Vec<CustomManagerSpec>,
1166}
1167
1168impl PackagesSpec {
1169 pub fn simple_list_mut(&mut self, manager: &str) -> Option<&mut Vec<String>> {
1173 match manager {
1174 "pipx" => Some(&mut self.pipx),
1175 "dnf" => Some(&mut self.dnf),
1176 "apk" => Some(&mut self.apk),
1177 "pacman" => Some(&mut self.pacman),
1178 "zypper" => Some(&mut self.zypper),
1179 "yum" => Some(&mut self.yum),
1180 "pkg" => Some(&mut self.pkg),
1181 "nix" => Some(&mut self.nix),
1182 "go" => Some(&mut self.go),
1183 "winget" => Some(&mut self.winget),
1184 "chocolatey" => Some(&mut self.chocolatey),
1185 "scoop" => Some(&mut self.scoop),
1186 _ => None,
1187 }
1188 }
1189
1190 pub fn simple_list(&self, manager: &str) -> Option<&[String]> {
1193 match manager {
1194 "pipx" => Some(&self.pipx),
1195 "dnf" => Some(&self.dnf),
1196 "apk" => Some(&self.apk),
1197 "pacman" => Some(&self.pacman),
1198 "zypper" => Some(&self.zypper),
1199 "yum" => Some(&self.yum),
1200 "pkg" => Some(&self.pkg),
1201 "nix" => Some(&self.nix),
1202 "go" => Some(&self.go),
1203 "winget" => Some(&self.winget),
1204 "chocolatey" => Some(&self.chocolatey),
1205 "scoop" => Some(&self.scoop),
1206 _ => None,
1207 }
1208 }
1209
1210 pub fn non_empty_simple_lists(&self) -> Vec<(&str, &[String])> {
1212 let mut result = Vec::new();
1213 for name in &[
1214 "pipx",
1215 "dnf",
1216 "apk",
1217 "pacman",
1218 "zypper",
1219 "yum",
1220 "pkg",
1221 "nix",
1222 "go",
1223 "winget",
1224 "chocolatey",
1225 "scoop",
1226 ] {
1227 if let Some(list) = self.simple_list(name)
1228 && !list.is_empty()
1229 {
1230 result.push((*name, list));
1231 }
1232 }
1233 result
1234 }
1235}
1236
1237#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1238#[serde(rename_all = "camelCase")]
1239pub struct BrewSpec {
1240 #[serde(default)]
1241 pub file: Option<String>,
1242 #[serde(default)]
1243 pub taps: Vec<String>,
1244 #[serde(default)]
1245 pub formulae: Vec<String>,
1246 #[serde(default)]
1247 pub casks: Vec<String>,
1248}
1249
1250#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1251#[serde(rename_all = "camelCase")]
1252pub struct AptSpec {
1253 #[serde(default)]
1254 pub file: Option<String>,
1255 #[serde(default)]
1256 pub packages: Vec<String>,
1257}
1258
1259#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1260#[serde(rename_all = "camelCase")]
1261pub struct NpmSpec {
1262 #[serde(default)]
1263 pub file: Option<String>,
1264 #[serde(default)]
1265 pub global: Vec<String>,
1266}
1267
1268#[derive(Debug, Clone, Default, PartialEq, Serialize)]
1271pub struct CargoSpec {
1272 #[serde(default, skip_serializing_if = "Option::is_none")]
1273 pub file: Option<String>,
1274 #[serde(default)]
1275 pub packages: Vec<String>,
1276}
1277
1278impl<'de> Deserialize<'de> for CargoSpec {
1279 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1280 where
1281 D: serde::Deserializer<'de>,
1282 {
1283 use serde::de;
1284
1285 #[derive(Deserialize)]
1286 #[serde(rename_all = "camelCase")]
1287 struct CargoSpecFull {
1288 #[serde(default)]
1289 file: Option<String>,
1290 #[serde(default)]
1291 packages: Vec<String>,
1292 }
1293
1294 struct CargoSpecVisitor;
1296
1297 impl<'de> de::Visitor<'de> for CargoSpecVisitor {
1298 type Value = CargoSpec;
1299
1300 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
1301 formatter.write_str("a list of package names or a map with file/packages keys")
1302 }
1303
1304 fn visit_seq<A>(self, mut seq: A) -> std::result::Result<CargoSpec, A::Error>
1305 where
1306 A: de::SeqAccess<'de>,
1307 {
1308 let mut packages = Vec::new();
1309 while let Some(item) = seq.next_element::<String>()? {
1310 packages.push(item);
1311 }
1312 Ok(CargoSpec {
1313 file: None,
1314 packages,
1315 })
1316 }
1317
1318 fn visit_map<M>(self, map: M) -> std::result::Result<CargoSpec, M::Error>
1319 where
1320 M: de::MapAccess<'de>,
1321 {
1322 let full = CargoSpecFull::deserialize(de::value::MapAccessDeserializer::new(map))?;
1323 Ok(CargoSpec {
1324 file: full.file,
1325 packages: full.packages,
1326 })
1327 }
1328 }
1329
1330 deserializer.deserialize_any(CargoSpecVisitor)
1331 }
1332}
1333
1334#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1335#[serde(rename_all = "camelCase")]
1336pub struct SnapSpec {
1337 #[serde(default)]
1338 pub packages: Vec<String>,
1339 #[serde(default)]
1340 pub classic: Vec<String>,
1341}
1342
1343#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1344#[serde(rename_all = "camelCase")]
1345pub struct FlatpakSpec {
1346 #[serde(default)]
1347 pub packages: Vec<String>,
1348 #[serde(default)]
1349 pub remote: Option<String>,
1350}
1351
1352#[derive(Debug, Clone, Serialize, Deserialize)]
1353#[serde(rename_all = "camelCase")]
1354pub struct CustomManagerSpec {
1355 pub name: String,
1356 pub check: String,
1357 pub list_installed: String,
1358 pub install: String,
1359 pub uninstall: String,
1360 #[serde(default)]
1361 pub update: Option<String>,
1362 #[serde(default)]
1363 pub packages: Vec<String>,
1364}
1365
1366#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1367#[serde(rename_all = "camelCase")]
1368pub struct FilesSpec {
1369 #[serde(default)]
1370 pub managed: Vec<ManagedFileSpec>,
1371 #[serde(default)]
1372 pub permissions: HashMap<String, String>,
1373}
1374
1375#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1377pub enum FileStrategy {
1378 #[default]
1380 Symlink,
1381 Copy,
1383 Template,
1385 Hardlink,
1387}
1388
1389#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1391pub enum EncryptionMode {
1392 #[default]
1394 InRepo,
1395 Always,
1397}
1398
1399#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1401#[serde(rename_all = "camelCase")]
1402pub struct EncryptionSpec {
1403 pub backend: String,
1405 #[serde(default)]
1407 pub mode: EncryptionMode,
1408}
1409
1410#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1412#[serde(rename_all = "camelCase")]
1413pub struct EncryptionConstraint {
1414 #[serde(default)]
1416 pub required_targets: Vec<String>,
1417 #[serde(default, skip_serializing_if = "Option::is_none")]
1419 pub backend: Option<String>,
1420 #[serde(default, skip_serializing_if = "Option::is_none")]
1422 pub mode: Option<EncryptionMode>,
1423}
1424
1425#[derive(Debug, Clone, Serialize, Deserialize)]
1426#[serde(rename_all = "camelCase")]
1427pub struct ManagedFileSpec {
1428 pub source: String,
1429 pub target: PathBuf,
1430 #[serde(default, skip_serializing_if = "Option::is_none")]
1432 pub strategy: Option<FileStrategy>,
1433 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1436 pub private: bool,
1437 #[serde(skip)]
1440 pub origin: Option<String>,
1441 #[serde(default, skip_serializing_if = "Option::is_none")]
1443 pub encryption: Option<EncryptionSpec>,
1444 #[serde(default, skip_serializing_if = "Option::is_none")]
1446 pub permissions: Option<String>,
1447}
1448
1449#[derive(Debug, Clone, Serialize, Deserialize)]
1450#[serde(rename_all = "camelCase")]
1451pub struct SecretSpec {
1452 pub source: String,
1453 #[serde(default, skip_serializing_if = "Option::is_none")]
1454 pub target: Option<PathBuf>,
1455 #[serde(default, skip_serializing_if = "Option::is_none")]
1456 pub template: Option<String>,
1457 #[serde(default, skip_serializing_if = "Option::is_none")]
1458 pub backend: Option<String>,
1459 #[serde(default, skip_serializing_if = "Option::is_none")]
1460 pub envs: Option<Vec<String>>,
1461}
1462
1463pub fn validate_secret_specs(specs: &[SecretSpec]) -> Result<()> {
1465 for spec in specs {
1466 if spec.target.is_none() && spec.envs.as_ref().is_none_or(|e| e.is_empty()) {
1467 return Err(ConfigError::Invalid {
1468 message: format!(
1469 "secret '{}' must have at least one of 'target' or 'envs'",
1470 spec.source
1471 ),
1472 }
1473 .into());
1474 }
1475 }
1476 Ok(())
1477}
1478
1479#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1480#[serde(rename_all = "camelCase")]
1481pub struct ScriptSpec {
1482 #[serde(default)]
1483 pub pre_apply: Vec<ScriptEntry>,
1484 #[serde(default)]
1485 pub post_apply: Vec<ScriptEntry>,
1486 #[serde(default)]
1487 pub pre_reconcile: Vec<ScriptEntry>,
1488 #[serde(default)]
1489 pub post_reconcile: Vec<ScriptEntry>,
1490 #[serde(default)]
1491 pub on_drift: Vec<ScriptEntry>,
1492 #[serde(default)]
1493 pub on_change: Vec<ScriptEntry>,
1494}
1495
1496#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1499pub enum LayerPolicy {
1500 Local,
1501 Required,
1502 Recommended,
1503 Optional,
1504}
1505
1506#[derive(Debug, Clone, Serialize)]
1507pub struct ProfileLayer {
1508 pub source: String,
1509 pub profile_name: String,
1510 pub priority: u32,
1511 pub policy: LayerPolicy,
1512 pub spec: ProfileSpec,
1513}
1514
1515#[derive(Debug, Clone, Serialize)]
1516pub struct ResolvedProfile {
1517 pub layers: Vec<ProfileLayer>,
1518 pub merged: MergedProfile,
1519}
1520
1521#[derive(Debug, Clone, Default, Serialize)]
1522pub struct MergedProfile {
1523 pub modules: Vec<String>,
1524 pub env: Vec<EnvVar>,
1525 pub aliases: Vec<ShellAlias>,
1526 pub packages: PackagesSpec,
1527 pub files: FilesSpec,
1528 pub system: HashMap<String, serde_yaml::Value>,
1529 pub secrets: Vec<SecretSpec>,
1530 pub scripts: ScriptSpec,
1531}
1532
1533pub fn load_config(path: &Path) -> Result<CfgdConfig> {
1535 if !path.exists() {
1536 return Err(ConfigError::NotFound {
1537 path: path.to_path_buf(),
1538 }
1539 .into());
1540 }
1541
1542 const MAX_CONFIG_SIZE: u64 = 50 * 1024 * 1024; if let Ok(meta) = std::fs::metadata(path)
1545 && meta.len() > MAX_CONFIG_SIZE
1546 {
1547 return Err(ConfigError::Invalid {
1548 message: format!(
1549 "{} is too large ({} bytes, max {})",
1550 path.display(),
1551 meta.len(),
1552 MAX_CONFIG_SIZE
1553 ),
1554 }
1555 .into());
1556 }
1557
1558 let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::Invalid {
1559 message: format!("failed to read {}: {}", path.display(), e),
1560 })?;
1561
1562 parse_config(&contents, path)
1563}
1564
1565pub fn parse_config(contents: &str, path: &Path) -> Result<CfgdConfig> {
1567 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("yaml");
1568
1569 if ext != "toml" {
1570 check_yaml_anchor_limit(contents, path)?;
1571 }
1572
1573 let raw: RawCfgdConfig = match ext {
1574 "toml" => toml::from_str(contents).map_err(ConfigError::from)?,
1575 _ => serde_yaml::from_str(contents).map_err(ConfigError::from)?,
1576 };
1577
1578 let origin = match raw.spec.origin {
1580 Some(RawOrigin::Single(o)) => vec![o],
1581 Some(RawOrigin::Multiple(v)) => v,
1582 None => vec![],
1583 };
1584
1585 Ok(CfgdConfig {
1586 api_version: raw.api_version,
1587 kind: raw.kind,
1588 metadata: raw.metadata,
1589 spec: ConfigSpec {
1590 profile: raw.spec.profile,
1591 origin,
1592 daemon: raw.spec.daemon,
1593 secrets: raw.spec.secrets,
1594 sources: raw.spec.sources,
1595 theme: raw.spec.theme,
1596 modules: raw.spec.modules,
1597 file_strategy: raw.spec.file_strategy,
1598 security: raw.spec.security,
1599 aliases: raw.spec.aliases,
1600 ai: raw.spec.ai,
1601 compliance: raw.spec.compliance,
1602 },
1603 })
1604}
1605
1606#[derive(Debug, Deserialize)]
1608#[serde(rename_all = "camelCase")]
1609struct RawCfgdConfig {
1610 api_version: String,
1611 kind: String,
1612 metadata: ConfigMetadata,
1613 spec: RawConfigSpec,
1614}
1615
1616#[derive(Debug, Deserialize)]
1617#[serde(rename_all = "camelCase")]
1618struct RawConfigSpec {
1619 #[serde(default)]
1620 profile: Option<String>,
1621 #[serde(default)]
1622 origin: Option<RawOrigin>,
1623 #[serde(default)]
1624 daemon: Option<DaemonConfig>,
1625 #[serde(default)]
1626 secrets: Option<SecretsConfig>,
1627 #[serde(default)]
1628 sources: Vec<SourceSpec>,
1629 #[serde(default)]
1630 theme: Option<ThemeConfig>,
1631 #[serde(default)]
1632 modules: Option<ModulesConfig>,
1633 #[serde(default)]
1634 file_strategy: FileStrategy,
1635 #[serde(default)]
1636 security: Option<SecurityConfig>,
1637 #[serde(default)]
1638 aliases: HashMap<String, String>,
1639 #[serde(default)]
1640 ai: Option<AiConfig>,
1641 #[serde(default)]
1642 compliance: Option<ComplianceConfig>,
1643}
1644
1645#[derive(Debug, Deserialize)]
1646#[serde(untagged)]
1647enum RawOrigin {
1648 Single(OriginSpec),
1649 Multiple(Vec<OriginSpec>),
1650}
1651
1652pub fn load_profile(path: &Path) -> Result<ProfileDocument> {
1654 if !path.exists() {
1655 return Err(ConfigError::NotFound {
1656 path: path.to_path_buf(),
1657 }
1658 .into());
1659 }
1660
1661 let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::Invalid {
1662 message: format!("failed to read {}: {}", path.display(), e),
1663 })?;
1664
1665 check_yaml_anchor_limit(&contents, path)?;
1666 let doc: ProfileDocument = serde_yaml::from_str(&contents).map_err(ConfigError::from)?;
1667 Ok(doc)
1668}
1669
1670fn find_profile_path(profiles_dir: &Path, name: &str) -> PathBuf {
1672 let yaml_path = profiles_dir.join(format!("{}.yaml", name));
1673 if yaml_path.exists() {
1674 return yaml_path;
1675 }
1676 let yml_path = profiles_dir.join(format!("{}.yml", name));
1677 if yml_path.exists() {
1678 return yml_path;
1679 }
1680 yaml_path
1682}
1683
1684pub fn resolve_profile(profile_name: &str, profiles_dir: &Path) -> Result<ResolvedProfile> {
1686 let resolution_order = resolve_inheritance_order(profile_name, profiles_dir, &mut vec![])?;
1687
1688 let mut layers = Vec::new();
1689 for name in &resolution_order {
1690 let path = find_profile_path(profiles_dir, name);
1691 let doc = load_profile(&path).map_err(|e| match e {
1692 crate::errors::CfgdError::Config(ConfigError::NotFound { .. }) => {
1693 crate::errors::CfgdError::Config(ConfigError::ProfileNotFound {
1694 name: name.clone(),
1695 })
1696 }
1697 other => other,
1698 })?;
1699 layers.push(ProfileLayer {
1700 source: "local".to_string(),
1701 profile_name: name.clone(),
1702 priority: 1000,
1703 policy: LayerPolicy::Local,
1704 spec: doc.spec,
1705 });
1706 }
1707
1708 let merged = merge_layers(&layers);
1709
1710 validate_secret_specs(&merged.secrets)?;
1711
1712 Ok(ResolvedProfile { layers, merged })
1713}
1714
1715fn resolve_inheritance_order(
1718 profile_name: &str,
1719 profiles_dir: &Path,
1720 visited: &mut Vec<String>,
1721) -> Result<Vec<String>> {
1722 if visited.contains(&profile_name.to_string()) {
1723 let mut chain = visited.clone();
1724 chain.push(profile_name.to_string());
1725 return Err(ConfigError::CircularInheritance { chain }.into());
1726 }
1727
1728 visited.push(profile_name.to_string());
1729
1730 let path = find_profile_path(profiles_dir, profile_name);
1731 let doc = load_profile(&path).map_err(|e| match e {
1732 crate::errors::CfgdError::Config(ConfigError::NotFound { .. }) => {
1733 crate::errors::CfgdError::Config(ConfigError::ProfileNotFound {
1734 name: profile_name.to_string(),
1735 })
1736 }
1737 other => other,
1738 })?;
1739
1740 let mut order = Vec::new();
1741 for parent in &doc.spec.inherits {
1742 let parent_order = resolve_inheritance_order(parent, profiles_dir, visited)?;
1743 for name in parent_order {
1744 if !order.contains(&name) {
1745 order.push(name);
1746 }
1747 }
1748 }
1749
1750 order.push(profile_name.to_string());
1751 visited.pop();
1752
1753 Ok(order)
1754}
1755
1756fn merge_layers(layers: &[ProfileLayer]) -> MergedProfile {
1764 let mut merged = MergedProfile::default();
1765
1766 for layer in layers {
1767 let spec = &layer.spec;
1768
1769 union_extend(&mut merged.modules, &spec.modules);
1771
1772 crate::merge_env(&mut merged.env, &spec.env);
1774
1775 crate::merge_aliases(&mut merged.aliases, &spec.aliases);
1777
1778 if let Some(ref pkgs) = spec.packages {
1780 crate::composition::merge_packages(&mut merged.packages, pkgs);
1781 }
1782
1783 if let Some(ref files) = spec.files {
1785 for managed in &files.managed {
1786 if let Some(existing) = merged
1787 .files
1788 .managed
1789 .iter_mut()
1790 .find(|m| m.target == managed.target)
1791 {
1792 *existing = managed.clone();
1793 } else {
1794 merged.files.managed.push(managed.clone());
1795 }
1796 }
1797 for (path, mode) in &files.permissions {
1798 merged.files.permissions.insert(path.clone(), mode.clone());
1799 }
1800 }
1801
1802 for (key, value) in &spec.system {
1804 deep_merge_yaml(
1805 merged
1806 .system
1807 .entry(key.clone())
1808 .or_insert(serde_yaml::Value::Null),
1809 value,
1810 );
1811 }
1812
1813 for secret in &spec.secrets {
1815 if let Some(existing) = merged
1816 .secrets
1817 .iter_mut()
1818 .find(|s| s.source == secret.source)
1819 {
1820 *existing = secret.clone();
1821 } else {
1822 merged.secrets.push(secret.clone());
1823 }
1824 }
1825
1826 if let Some(ref scripts) = spec.scripts {
1828 merged.scripts.pre_apply.extend(scripts.pre_apply.clone());
1829 merged.scripts.post_apply.extend(scripts.post_apply.clone());
1830 merged
1831 .scripts
1832 .pre_reconcile
1833 .extend(scripts.pre_reconcile.clone());
1834 merged
1835 .scripts
1836 .post_reconcile
1837 .extend(scripts.post_reconcile.clone());
1838 merged.scripts.on_drift.extend(scripts.on_drift.clone());
1839 merged.scripts.on_change.extend(scripts.on_change.clone());
1840 }
1841 }
1842
1843 merged
1844}
1845
1846pub fn desired_packages_for(manager_name: &str, profile: &MergedProfile) -> Vec<String> {
1848 desired_packages_for_spec(manager_name, &profile.packages)
1849}
1850
1851pub fn desired_packages_for_spec(manager_name: &str, packages: &PackagesSpec) -> Vec<String> {
1852 match manager_name {
1853 "brew" => packages
1854 .brew
1855 .as_ref()
1856 .map(|b| b.formulae.clone())
1857 .unwrap_or_default(),
1858 "brew-tap" => packages
1859 .brew
1860 .as_ref()
1861 .map(|b| b.taps.clone())
1862 .unwrap_or_default(),
1863 "brew-cask" => packages
1864 .brew
1865 .as_ref()
1866 .map(|b| b.casks.clone())
1867 .unwrap_or_default(),
1868 "apt" => packages
1869 .apt
1870 .as_ref()
1871 .map(|a| a.packages.clone())
1872 .unwrap_or_default(),
1873 "cargo" => packages
1874 .cargo
1875 .as_ref()
1876 .map(|c| c.packages.clone())
1877 .unwrap_or_default(),
1878 "npm" => packages
1879 .npm
1880 .as_ref()
1881 .map(|n| n.global.clone())
1882 .unwrap_or_default(),
1883 "pipx" => packages.pipx.clone(),
1884 "dnf" => packages.dnf.clone(),
1885 "apk" => packages.apk.clone(),
1886 "pacman" => packages.pacman.clone(),
1887 "zypper" => packages.zypper.clone(),
1888 "yum" => packages.yum.clone(),
1889 "pkg" => packages.pkg.clone(),
1890 "snap" => packages
1891 .snap
1892 .as_ref()
1893 .map(|s| {
1894 let mut all = s.packages.clone();
1895 for p in &s.classic {
1896 if !all.contains(p) {
1897 all.push(p.clone());
1898 }
1899 }
1900 all
1901 })
1902 .unwrap_or_default(),
1903 "flatpak" => packages
1904 .flatpak
1905 .as_ref()
1906 .map(|f| f.packages.clone())
1907 .unwrap_or_default(),
1908 "nix" => packages.nix.clone(),
1909 "go" => packages.go.clone(),
1910 "winget" => packages.winget.clone(),
1911 "chocolatey" => packages.chocolatey.clone(),
1912 "scoop" => packages.scoop.clone(),
1913 _ => {
1914 for custom in &packages.custom {
1916 if custom.name == manager_name {
1917 return custom.packages.clone();
1918 }
1919 }
1920 Vec::new()
1921 }
1922 }
1923}
1924
1925#[derive(Debug, Clone)]
1929pub struct PlatformInfo {
1930 pub os: String,
1931 pub distro: Option<String>,
1932 pub distro_version: Option<String>,
1933}
1934
1935pub fn detect_platform() -> PlatformInfo {
1937 let os = match std::env::consts::OS {
1938 "macos" => "macos".to_string(),
1939 other => other.to_string(),
1940 };
1941 let (distro, version) = if os == "linux" {
1942 parse_os_release_file()
1943 } else {
1944 (None, None)
1945 };
1946 PlatformInfo {
1947 os,
1948 distro,
1949 distro_version: version,
1950 }
1951}
1952
1953pub fn match_platform_profile(
1956 platform: &PlatformInfo,
1957 platform_profiles: &HashMap<String, String>,
1958) -> Option<String> {
1959 if let Some(ref distro) = platform.distro
1961 && let Some(path) = platform_profiles.get(distro)
1962 {
1963 return Some(path.clone());
1964 }
1965 if let Some(path) = platform_profiles.get(&platform.os) {
1967 return Some(path.clone());
1968 }
1969 None
1970}
1971
1972fn parse_os_release_file() -> (Option<String>, Option<String>) {
1973 let fields = crate::platform::parse_os_release_content(
1974 &std::fs::read_to_string("/etc/os-release").unwrap_or_default(),
1975 );
1976 (
1977 fields.get("ID").map(|v| v.to_lowercase()),
1978 fields.get("VERSION_ID").cloned(),
1979 )
1980}
1981
1982pub fn source_profile_names(provides: &ConfigSourceProvides) -> Vec<String> {
1985 if !provides.profile_details.is_empty() {
1986 provides
1987 .profile_details
1988 .iter()
1989 .map(|p| p.name.clone())
1990 .collect()
1991 } else {
1992 provides.profiles.clone()
1993 }
1994}
1995
1996#[cfg(test)]
1997mod tests {
1998 use super::*;
1999 use crate::test_helpers::{
2000 SAMPLE_CONFIG_NO_ORIGIN_YAML, SAMPLE_CONFIG_YAML, SAMPLE_PROFILE_YAML,
2001 };
2002
2003 #[test]
2004 fn parse_yaml_config() {
2005 let config = parse_config(SAMPLE_CONFIG_YAML, Path::new("cfgd.yaml")).unwrap();
2006 assert_eq!(config.metadata.name, "test-config");
2007 assert_eq!(config.spec.profile.as_deref(), Some("default"));
2008 assert_eq!(config.spec.origin.len(), 1);
2009 assert_eq!(
2010 config.spec.origin[0].url,
2011 "https://github.com/test/repo.git"
2012 );
2013 assert_eq!(config.spec.origin[0].branch, "master");
2014 }
2015
2016 #[test]
2017 fn parse_config_without_origin() {
2018 let config = parse_config(SAMPLE_CONFIG_NO_ORIGIN_YAML, Path::new("cfgd.yaml")).unwrap();
2019 assert!(config.spec.origin.is_empty());
2020 assert!(config.spec.sources.is_empty());
2021 }
2022
2023 #[test]
2024 fn parse_profile_yaml() {
2025 let doc: ProfileDocument = serde_yaml::from_str(SAMPLE_PROFILE_YAML).unwrap();
2026 assert_eq!(doc.metadata.name, "base");
2027 assert_eq!(doc.spec.env.len(), 2);
2028 let pkgs = doc.spec.packages.as_ref().unwrap();
2029 let brew = pkgs.brew.as_ref().unwrap();
2030 assert_eq!(brew.formulae, vec!["ripgrep", "fd"]);
2031 assert_eq!(pkgs.cargo.as_ref().unwrap().packages, vec!["bat"]);
2032 }
2033
2034 #[test]
2035 fn merge_env_override() {
2036 let layer1 = ProfileLayer {
2037 source: "local".into(),
2038 profile_name: "base".into(),
2039 priority: 1000,
2040 policy: LayerPolicy::Local,
2041 spec: ProfileSpec {
2042 env: vec![
2043 EnvVar {
2044 name: "editor".into(),
2045 value: "vim".into(),
2046 },
2047 EnvVar {
2048 name: "shell".into(),
2049 value: "/bin/bash".into(),
2050 },
2051 ],
2052 ..Default::default()
2053 },
2054 };
2055 let layer2 = ProfileLayer {
2056 source: "local".into(),
2057 profile_name: "work".into(),
2058 priority: 1000,
2059 policy: LayerPolicy::Local,
2060 spec: ProfileSpec {
2061 env: vec![EnvVar {
2062 name: "editor".into(),
2063 value: "code".into(),
2064 }],
2065 ..Default::default()
2066 },
2067 };
2068
2069 let merged = merge_layers(&[layer1, layer2]);
2070 assert_eq!(
2071 merged
2072 .env
2073 .iter()
2074 .find(|e| e.name == "editor")
2075 .map(|e| &e.value),
2076 Some(&"code".to_string())
2077 );
2078 assert_eq!(
2079 merged
2080 .env
2081 .iter()
2082 .find(|e| e.name == "shell")
2083 .map(|e| &e.value),
2084 Some(&"/bin/bash".to_string())
2085 );
2086 }
2087
2088 #[test]
2089 fn merge_packages_union() {
2090 let layer1 = ProfileLayer {
2091 source: "local".into(),
2092 profile_name: "base".into(),
2093 priority: 1000,
2094 policy: LayerPolicy::Local,
2095 spec: ProfileSpec {
2096 packages: Some(PackagesSpec {
2097 cargo: Some(CargoSpec {
2098 file: None,
2099 packages: vec!["bat".into()],
2100 }),
2101 ..Default::default()
2102 }),
2103 ..Default::default()
2104 },
2105 };
2106 let layer2 = ProfileLayer {
2107 source: "local".into(),
2108 profile_name: "work".into(),
2109 priority: 1000,
2110 policy: LayerPolicy::Local,
2111 spec: ProfileSpec {
2112 packages: Some(PackagesSpec {
2113 cargo: Some(CargoSpec {
2114 file: None,
2115 packages: vec!["bat".into(), "exa".into()],
2116 }),
2117 ..Default::default()
2118 }),
2119 ..Default::default()
2120 },
2121 };
2122
2123 let merged = merge_layers(&[layer1, layer2]);
2124 assert_eq!(
2125 merged.packages.cargo.as_ref().unwrap().packages,
2126 vec!["bat", "exa"]
2127 );
2128 }
2129
2130 #[test]
2131 fn merge_files_overlay() {
2132 let layer1 = ProfileLayer {
2133 source: "local".into(),
2134 profile_name: "base".into(),
2135 priority: 1000,
2136 policy: LayerPolicy::Local,
2137 spec: ProfileSpec {
2138 files: Some(FilesSpec {
2139 managed: vec![ManagedFileSpec {
2140 source: "base/.zshrc".into(),
2141 target: PathBuf::from("/home/user/.zshrc"),
2142 strategy: None,
2143 private: false,
2144 origin: None,
2145 encryption: None,
2146 permissions: None,
2147 }],
2148 ..Default::default()
2149 }),
2150 ..Default::default()
2151 },
2152 };
2153 let layer2 = ProfileLayer {
2154 source: "local".into(),
2155 profile_name: "work".into(),
2156 priority: 1000,
2157 policy: LayerPolicy::Local,
2158 spec: ProfileSpec {
2159 files: Some(FilesSpec {
2160 managed: vec![ManagedFileSpec {
2161 source: "work/.zshrc".into(),
2162 target: PathBuf::from("/home/user/.zshrc"),
2163 strategy: None,
2164 private: false,
2165 origin: None,
2166 encryption: None,
2167 permissions: None,
2168 }],
2169 ..Default::default()
2170 }),
2171 ..Default::default()
2172 },
2173 };
2174
2175 let merged = merge_layers(&[layer1, layer2]);
2176 assert_eq!(merged.files.managed.len(), 1);
2177 assert_eq!(merged.files.managed[0].source, "work/.zshrc");
2178 }
2179
2180 #[test]
2181 fn deep_merge_yaml_maps() {
2182 let mut base = serde_yaml::from_str::<serde_yaml::Value>(
2183 r#"
2184 domain1:
2185 key1: value1
2186 key2: value2
2187 "#,
2188 )
2189 .unwrap();
2190
2191 let overlay = serde_yaml::from_str::<serde_yaml::Value>(
2192 r#"
2193 domain1:
2194 key2: overridden
2195 key3: value3
2196 "#,
2197 )
2198 .unwrap();
2199
2200 deep_merge_yaml(&mut base, &overlay);
2201
2202 let map = base.as_mapping().unwrap();
2203 let domain = map
2204 .get(serde_yaml::Value::String("domain1".into()))
2205 .unwrap()
2206 .as_mapping()
2207 .unwrap();
2208 assert_eq!(
2209 domain.get(serde_yaml::Value::String("key1".into())),
2210 Some(&serde_yaml::Value::String("value1".into()))
2211 );
2212 assert_eq!(
2213 domain.get(serde_yaml::Value::String("key2".into())),
2214 Some(&serde_yaml::Value::String("overridden".into()))
2215 );
2216 assert_eq!(
2217 domain.get(serde_yaml::Value::String("key3".into())),
2218 Some(&serde_yaml::Value::String("value3".into()))
2219 );
2220 }
2221
2222 #[test]
2223 fn profile_resolution_with_filesystem() {
2224 let dir = tempfile::tempdir().unwrap();
2225
2226 std::fs::write(
2228 dir.path().join("base.yaml"),
2229 r#"
2230apiVersion: cfgd.io/v1alpha1
2231kind: Profile
2232metadata:
2233 name: base
2234spec:
2235 env:
2236 - name: editor
2237 value: vim
2238 packages:
2239 cargo:
2240 - bat
2241"#,
2242 )
2243 .unwrap();
2244
2245 std::fs::write(
2247 dir.path().join("work.yaml"),
2248 r#"
2249apiVersion: cfgd.io/v1alpha1
2250kind: Profile
2251metadata:
2252 name: work
2253spec:
2254 inherits:
2255 - base
2256 env:
2257 - name: editor
2258 value: code
2259 packages:
2260 cargo:
2261 - exa
2262"#,
2263 )
2264 .unwrap();
2265
2266 let resolved = resolve_profile("work", dir.path()).unwrap();
2267
2268 assert_eq!(resolved.layers.len(), 2);
2269 assert_eq!(resolved.layers[0].profile_name, "base");
2270 assert_eq!(resolved.layers[1].profile_name, "work");
2271
2272 assert_eq!(
2274 resolved
2275 .merged
2276 .env
2277 .iter()
2278 .find(|e| e.name == "editor")
2279 .map(|e| &e.value),
2280 Some(&"code".to_string())
2281 );
2282 assert_eq!(
2284 resolved.merged.packages.cargo.as_ref().unwrap().packages,
2285 vec!["bat", "exa"]
2286 );
2287 }
2288
2289 #[test]
2290 fn circular_inheritance_detected() {
2291 let dir = tempfile::tempdir().unwrap();
2292
2293 std::fs::write(
2294 dir.path().join("a.yaml"),
2295 r#"
2296apiVersion: cfgd.io/v1alpha1
2297kind: Profile
2298metadata:
2299 name: a
2300spec:
2301 inherits:
2302 - b
2303"#,
2304 )
2305 .unwrap();
2306
2307 std::fs::write(
2308 dir.path().join("b.yaml"),
2309 r#"
2310apiVersion: cfgd.io/v1alpha1
2311kind: Profile
2312metadata:
2313 name: b
2314spec:
2315 inherits:
2316 - a
2317"#,
2318 )
2319 .unwrap();
2320
2321 let result = resolve_profile("a", dir.path());
2322 assert!(result.is_err());
2323 let err = result.unwrap_err().to_string();
2324 assert!(err.contains("circular"));
2325 }
2326
2327 #[test]
2328 fn config_not_found_error() {
2329 let result = load_config(Path::new("/nonexistent/cfgd.yaml"));
2330 assert!(result.is_err());
2331 assert!(result.unwrap_err().to_string().contains("not found"));
2332 }
2333
2334 #[test]
2335 fn parse_config_source_manifest() {
2336 let yaml = r#"
2337apiVersion: cfgd.io/v1alpha1
2338kind: ConfigSource
2339metadata:
2340 name: acme-corp-dev
2341 version: "2.1.0"
2342 description: "ACME Corp developer environment"
2343spec:
2344 provides:
2345 profiles:
2346 - acme-base
2347 - acme-backend
2348 policy:
2349 required:
2350 packages:
2351 brew:
2352 formulae:
2353 - git-secrets
2354 - pre-commit
2355 recommended:
2356 packages:
2357 brew:
2358 formulae:
2359 - k9s
2360 env:
2361 - name: EDITOR
2362 value: "code --wait"
2363 locked:
2364 files:
2365 - source: "security/policy.yaml"
2366 target: "~/.config/company/security-policy.yaml"
2367 constraints:
2368 noScripts: true
2369 noSecretsRead: true
2370 allowedTargetPaths:
2371 - "~/.config/acme/"
2372 - "~/.eslintrc*"
2373"#;
2374 let doc = parse_config_source(yaml).unwrap();
2375 assert_eq!(doc.metadata.name, "acme-corp-dev");
2376 assert_eq!(doc.metadata.version.as_deref(), Some("2.1.0"));
2377 assert_eq!(doc.spec.provides.profiles.len(), 2);
2378
2379 let required_pkgs = doc.spec.policy.required.packages.as_ref().unwrap();
2380 let brew = required_pkgs.brew.as_ref().unwrap();
2381 assert_eq!(brew.formulae, vec!["git-secrets", "pre-commit"]);
2382
2383 assert!(doc.spec.policy.constraints.no_scripts);
2384 assert_eq!(doc.spec.policy.constraints.allowed_target_paths.len(), 2);
2385 assert_eq!(doc.spec.policy.locked.files.len(), 1);
2386 }
2387
2388 #[test]
2389 fn parse_config_source_wrong_kind() {
2390 let yaml = r#"
2391apiVersion: cfgd.io/v1alpha1
2392kind: Config
2393metadata:
2394 name: not-a-source
2395spec:
2396 provides:
2397 profiles: []
2398 policy: {}
2399"#;
2400 let result = parse_config_source(yaml);
2401 assert!(result.is_err());
2402 assert!(result.unwrap_err().to_string().contains("ConfigSource"));
2403 }
2404
2405 #[test]
2406 fn source_spec_defaults() {
2407 let yaml = r#"
2408name: test-source
2409origin:
2410 type: Git
2411 url: https://example.com/config.git
2412"#;
2413 let spec: SourceSpec = serde_yaml::from_str(yaml).unwrap();
2414 assert_eq!(spec.subscription.priority, 500);
2415 assert_eq!(spec.sync.interval, "1h");
2416 assert!(!spec.sync.auto_apply);
2417 }
2418
2419 #[test]
2420 fn cargo_spec_deserialize_list() {
2421 let yaml = r#"
2422cargo:
2423 - bat
2424 - ripgrep
2425"#;
2426 #[derive(Deserialize)]
2427 struct Wrapper {
2428 cargo: CargoSpec,
2429 }
2430 let w: Wrapper = serde_yaml::from_str(yaml).unwrap();
2431 assert_eq!(w.cargo.packages, vec!["bat", "ripgrep"]);
2432 assert!(w.cargo.file.is_none());
2433 }
2434
2435 #[test]
2436 fn cargo_spec_deserialize_map() {
2437 let yaml = r#"
2438cargo:
2439 file: Cargo.toml
2440 packages:
2441 - extra-pkg
2442"#;
2443 #[derive(Deserialize)]
2444 struct Wrapper {
2445 cargo: CargoSpec,
2446 }
2447 let w: Wrapper = serde_yaml::from_str(yaml).unwrap();
2448 assert_eq!(w.cargo.file.as_deref(), Some("Cargo.toml"));
2449 assert_eq!(w.cargo.packages, vec!["extra-pkg"]);
2450 }
2451
2452 #[test]
2453 fn cargo_spec_deserialize_file_only() {
2454 let yaml = r#"
2455cargo:
2456 file: Cargo.toml
2457"#;
2458 #[derive(Deserialize)]
2459 struct Wrapper {
2460 cargo: CargoSpec,
2461 }
2462 let w: Wrapper = serde_yaml::from_str(yaml).unwrap();
2463 assert_eq!(w.cargo.file.as_deref(), Some("Cargo.toml"));
2464 assert!(w.cargo.packages.is_empty());
2465 }
2466
2467 #[test]
2468 fn packages_spec_with_manifest_files() {
2469 let yaml = r#"
2470brew:
2471 file: Brewfile
2472 formulae:
2473 - extra-tool
2474apt:
2475 file: packages.apt.txt
2476npm:
2477 file: package.json
2478 global:
2479 - extra-global
2480cargo:
2481 file: Cargo.toml
2482"#;
2483 let spec: PackagesSpec = serde_yaml::from_str(yaml).unwrap();
2484
2485 let brew = spec.brew.as_ref().unwrap();
2486 assert_eq!(brew.file.as_deref(), Some("Brewfile"));
2487 assert_eq!(brew.formulae, vec!["extra-tool"]);
2488
2489 let apt = spec.apt.as_ref().unwrap();
2490 assert_eq!(apt.file.as_deref(), Some("packages.apt.txt"));
2491
2492 let npm = spec.npm.as_ref().unwrap();
2493 assert_eq!(npm.file.as_deref(), Some("package.json"));
2494 assert_eq!(npm.global, vec!["extra-global"]);
2495
2496 let cargo = spec.cargo.as_ref().unwrap();
2497 assert_eq!(cargo.file.as_deref(), Some("Cargo.toml"));
2498 }
2499
2500 #[test]
2501 fn merge_manifest_file_fields() {
2502 let layer1 = ProfileLayer {
2503 source: "local".into(),
2504 profile_name: "base".into(),
2505 priority: 1000,
2506 policy: LayerPolicy::Local,
2507 spec: ProfileSpec {
2508 packages: Some(PackagesSpec {
2509 brew: Some(BrewSpec {
2510 file: Some("Brewfile".into()),
2511 formulae: vec!["git".into()],
2512 ..Default::default()
2513 }),
2514 ..Default::default()
2515 }),
2516 ..Default::default()
2517 },
2518 };
2519 let layer2 = ProfileLayer {
2520 source: "local".into(),
2521 profile_name: "work".into(),
2522 priority: 1000,
2523 policy: LayerPolicy::Local,
2524 spec: ProfileSpec {
2525 packages: Some(PackagesSpec {
2526 brew: Some(BrewSpec {
2527 formulae: vec!["ripgrep".into()],
2528 ..Default::default()
2529 }),
2530 ..Default::default()
2531 }),
2532 ..Default::default()
2533 },
2534 };
2535
2536 let merged = merge_layers(&[layer1, layer2]);
2537 let brew = merged.packages.brew.as_ref().unwrap();
2538 assert_eq!(brew.file.as_deref(), Some("Brewfile"));
2540 assert_eq!(brew.formulae, vec!["git", "ripgrep"]);
2542 }
2543
2544 #[test]
2545 fn parse_config_source_with_profile_details() {
2546 let yaml = r#"
2547apiVersion: cfgd.io/v1alpha1
2548kind: ConfigSource
2549metadata:
2550 name: acme
2551spec:
2552 provides:
2553 profiles:
2554 - acme-base
2555 - acme-backend
2556 profileDetails:
2557 - name: acme-base
2558 description: "Core tools and security"
2559 path: profiles/base.yaml
2560 - name: acme-backend
2561 description: "Go, k8s tools"
2562 path: profiles/backend.yaml
2563 inherits:
2564 - acme-base
2565 platformProfiles:
2566 macos: acme-base
2567 debian: acme-backend
2568 policy: {}
2569"#;
2570 let doc = parse_config_source(yaml).unwrap();
2571 assert_eq!(doc.spec.provides.profile_details.len(), 2);
2572 assert_eq!(doc.spec.provides.profile_details[0].name, "acme-base");
2573 assert_eq!(
2574 doc.spec.provides.profile_details[0].description.as_deref(),
2575 Some("Core tools and security")
2576 );
2577 assert_eq!(
2578 doc.spec.provides.profile_details[1].inherits,
2579 vec!["acme-base"]
2580 );
2581 assert_eq!(doc.spec.provides.platform_profiles.len(), 2);
2582 assert_eq!(
2583 doc.spec.provides.platform_profiles.get("macos").unwrap(),
2584 "acme-base"
2585 );
2586 }
2587
2588 #[test]
2589 fn parse_os_release_debian() {
2590 let content = r#"PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
2591NAME="Debian GNU/Linux"
2592VERSION_ID="12"
2593ID=debian
2594"#;
2595 let fields = crate::platform::parse_os_release_content(content);
2596 assert_eq!(
2597 fields.get("ID").map(|v| v.to_lowercase()).as_deref(),
2598 Some("debian")
2599 );
2600 assert_eq!(fields.get("VERSION_ID").map(|s| s.as_str()), Some("12"));
2601 }
2602
2603 #[test]
2604 fn parse_os_release_ubuntu() {
2605 let content = r#"NAME="Ubuntu"
2606VERSION="22.04.3 LTS (Jammy Jellyfish)"
2607ID=ubuntu
2608VERSION_ID="22.04"
2609"#;
2610 let fields = crate::platform::parse_os_release_content(content);
2611 assert_eq!(
2612 fields.get("ID").map(|v| v.to_lowercase()).as_deref(),
2613 Some("ubuntu")
2614 );
2615 assert_eq!(fields.get("VERSION_ID").map(|s| s.as_str()), Some("22.04"));
2616 }
2617
2618 #[test]
2619 fn parse_os_release_empty() {
2620 let fields = crate::platform::parse_os_release_content("");
2621 assert!(!fields.contains_key("ID"));
2622 assert!(!fields.contains_key("VERSION_ID"));
2623 }
2624
2625 #[test]
2626 fn match_platform_profile_exact_distro() {
2627 let mut profiles = HashMap::new();
2628 profiles.insert("macos".into(), "profiles/macos.yaml".into());
2629 profiles.insert("debian".into(), "profiles/debian.yaml".into());
2630
2631 let platform = PlatformInfo {
2632 os: "linux".into(),
2633 distro: Some("debian".into()),
2634 distro_version: Some("12".into()),
2635 };
2636 assert_eq!(
2637 match_platform_profile(&platform, &profiles),
2638 Some("profiles/debian.yaml".into())
2639 );
2640 }
2641
2642 #[test]
2643 fn match_platform_profile_os_fallback() {
2644 let mut profiles = HashMap::new();
2645 profiles.insert("macos".into(), "profiles/macos.yaml".into());
2646 profiles.insert("linux".into(), "profiles/linux.yaml".into());
2647
2648 let platform = PlatformInfo {
2649 os: "linux".into(),
2650 distro: Some("arch".into()),
2651 distro_version: None,
2652 };
2653 assert_eq!(
2655 match_platform_profile(&platform, &profiles),
2656 Some("profiles/linux.yaml".into())
2657 );
2658 }
2659
2660 #[test]
2661 fn match_platform_profile_no_match() {
2662 let mut profiles = HashMap::new();
2663 profiles.insert("debian".into(), "profiles/debian.yaml".into());
2664
2665 let platform = PlatformInfo {
2666 os: "macos".into(),
2667 distro: None,
2668 distro_version: None,
2669 };
2670 assert!(match_platform_profile(&platform, &profiles).is_none());
2671 }
2672
2673 #[test]
2674 fn source_profile_names_from_details() {
2675 let provides = ConfigSourceProvides {
2676 profiles: vec!["old-name".into()],
2677 profile_details: vec![
2678 ConfigSourceProfileEntry {
2679 name: "base".into(),
2680 description: Some("Base profile".into()),
2681 path: None,
2682 inherits: vec![],
2683 },
2684 ConfigSourceProfileEntry {
2685 name: "backend".into(),
2686 description: None,
2687 path: None,
2688 inherits: vec!["base".into()],
2689 },
2690 ],
2691 platform_profiles: HashMap::new(),
2692 modules: vec![],
2693 };
2694 assert_eq!(source_profile_names(&provides), vec!["base", "backend"]);
2696 }
2697
2698 #[test]
2699 fn source_profile_names_fallback_to_profiles() {
2700 let provides = ConfigSourceProvides {
2701 profiles: vec!["alpha".into(), "beta".into()],
2702 profile_details: vec![],
2703 platform_profiles: HashMap::new(),
2704 modules: vec![],
2705 };
2706 assert_eq!(source_profile_names(&provides), vec!["alpha", "beta"]);
2707 }
2708
2709 #[test]
2710 fn auto_apply_policy_deserializes() {
2711 let yaml = r#"
2712newRecommended: Accept
2713newOptional: Notify
2714lockedConflict: Reject
2715"#;
2716 let policy: AutoApplyPolicyConfig = serde_yaml::from_str(yaml).unwrap();
2717 assert_eq!(policy.new_recommended, PolicyAction::Accept);
2718 assert_eq!(policy.new_optional, PolicyAction::Notify);
2719 assert_eq!(policy.locked_conflict, PolicyAction::Reject);
2720 }
2721
2722 #[test]
2723 fn reconcile_config_with_policy_deserializes() {
2724 let yaml = r#"
2725interval: 5m
2726onChange: true
2727autoApply: true
2728policy:
2729 newRecommended: Accept
2730 newOptional: Ignore
2731 lockedConflict: Notify
2732"#;
2733 let config: ReconcileConfig = serde_yaml::from_str(yaml).unwrap();
2734 assert!(config.auto_apply);
2735 assert!(config.on_change);
2736 let policy = config.policy.unwrap();
2737 assert_eq!(policy.new_recommended, PolicyAction::Accept);
2738 }
2739
2740 #[test]
2741 fn reconcile_patches_deserialize() {
2742 let yaml = r#"
2743interval: 5m
2744patches:
2745 - kind: Module
2746 name: certificates
2747 interval: 1m
2748 driftPolicy: Auto
2749 - kind: Profile
2750 name: work
2751 autoApply: true
2752 - kind: Module
2753 interval: 30s
2754"#;
2755 let config: ReconcileConfig = serde_yaml::from_str(yaml).unwrap();
2756 assert_eq!(config.patches.len(), 3);
2757 assert_eq!(config.patches[0].kind, ReconcilePatchKind::Module);
2758 assert_eq!(config.patches[0].name.as_deref(), Some("certificates"));
2759 assert_eq!(config.patches[0].interval.as_deref(), Some("1m"));
2760 assert_eq!(config.patches[0].drift_policy, Some(DriftPolicy::Auto));
2761 assert!(config.patches[0].auto_apply.is_none());
2762 assert_eq!(config.patches[1].kind, ReconcilePatchKind::Profile);
2763 assert_eq!(config.patches[1].name.as_deref(), Some("work"));
2764 assert_eq!(config.patches[1].auto_apply, Some(true));
2765 assert!(config.patches[1].interval.is_none());
2766 assert_eq!(config.patches[2].kind, ReconcilePatchKind::Module);
2768 assert!(config.patches[2].name.is_none());
2769 assert_eq!(config.patches[2].interval.as_deref(), Some("30s"));
2770 }
2771
2772 #[test]
2773 fn reconcile_config_without_patches_has_empty_vec() {
2774 let yaml = "interval: 10m\n";
2775 let config: ReconcileConfig = serde_yaml::from_str(yaml).unwrap();
2776 assert!(config.patches.is_empty());
2777 }
2778
2779 #[test]
2782 fn desired_packages_brew_formulae() {
2783 let spec = PackagesSpec {
2784 brew: Some(BrewSpec {
2785 formulae: vec!["curl".into(), "wget".into()],
2786 ..Default::default()
2787 }),
2788 ..Default::default()
2789 };
2790 assert_eq!(
2791 desired_packages_for_spec("brew", &spec),
2792 vec!["curl", "wget"]
2793 );
2794 }
2795
2796 #[test]
2797 fn desired_packages_brew_taps() {
2798 let spec = PackagesSpec {
2799 brew: Some(BrewSpec {
2800 taps: vec!["homebrew/core".into()],
2801 ..Default::default()
2802 }),
2803 ..Default::default()
2804 };
2805 assert_eq!(
2806 desired_packages_for_spec("brew-tap", &spec),
2807 vec!["homebrew/core"]
2808 );
2809 }
2810
2811 #[test]
2812 fn desired_packages_brew_casks() {
2813 let spec = PackagesSpec {
2814 brew: Some(BrewSpec {
2815 casks: vec!["firefox".into()],
2816 ..Default::default()
2817 }),
2818 ..Default::default()
2819 };
2820 assert_eq!(
2821 desired_packages_for_spec("brew-cask", &spec),
2822 vec!["firefox"]
2823 );
2824 }
2825
2826 #[test]
2827 fn desired_packages_apt() {
2828 let spec = PackagesSpec {
2829 apt: Some(AptSpec {
2830 packages: vec!["git".into()],
2831 ..Default::default()
2832 }),
2833 ..Default::default()
2834 };
2835 assert_eq!(desired_packages_for_spec("apt", &spec), vec!["git"]);
2836 }
2837
2838 #[test]
2839 fn desired_packages_cargo() {
2840 let spec = PackagesSpec {
2841 cargo: Some(CargoSpec {
2842 packages: vec!["ripgrep".into()],
2843 ..Default::default()
2844 }),
2845 ..Default::default()
2846 };
2847 assert_eq!(desired_packages_for_spec("cargo", &spec), vec!["ripgrep"]);
2848 }
2849
2850 #[test]
2851 fn desired_packages_npm() {
2852 let spec = PackagesSpec {
2853 npm: Some(NpmSpec {
2854 global: vec!["typescript".into()],
2855 ..Default::default()
2856 }),
2857 ..Default::default()
2858 };
2859 assert_eq!(desired_packages_for_spec("npm", &spec), vec!["typescript"]);
2860 }
2861
2862 #[test]
2863 fn desired_packages_pipx() {
2864 let spec = PackagesSpec {
2865 pipx: vec!["black".into()],
2866 ..Default::default()
2867 };
2868 assert_eq!(desired_packages_for_spec("pipx", &spec), vec!["black"]);
2869 }
2870
2871 #[test]
2872 fn desired_packages_snap_merges_classic() {
2873 let spec = PackagesSpec {
2874 snap: Some(SnapSpec {
2875 packages: vec!["core".into()],
2876 classic: vec!["code".into()],
2877 }),
2878 ..Default::default()
2879 };
2880 let result = desired_packages_for_spec("snap", &spec);
2881 assert_eq!(result, vec!["core", "code"]);
2882 }
2883
2884 #[test]
2885 fn desired_packages_snap_classic_dedup() {
2886 let spec = PackagesSpec {
2887 snap: Some(SnapSpec {
2888 packages: vec!["code".into()],
2889 classic: vec!["code".into()],
2890 }),
2891 ..Default::default()
2892 };
2893 let result = desired_packages_for_spec("snap", &spec);
2894 assert_eq!(result, vec!["code"]);
2895 }
2896
2897 #[test]
2898 fn desired_packages_custom_manager() {
2899 let spec = PackagesSpec {
2900 custom: vec![CustomManagerSpec {
2901 name: "my-mgr".into(),
2902 check: "which my-mgr".into(),
2903 list_installed: "my-mgr list".into(),
2904 install: "my-mgr install".into(),
2905 uninstall: "my-mgr remove".into(),
2906 update: None,
2907 packages: vec!["tool-a".into()],
2908 }],
2909 ..Default::default()
2910 };
2911 assert_eq!(desired_packages_for_spec("my-mgr", &spec), vec!["tool-a"]);
2912 }
2913
2914 #[test]
2915 fn desired_packages_unknown_manager() {
2916 let spec = PackagesSpec::default();
2917 assert!(desired_packages_for_spec("nonexistent", &spec).is_empty());
2918 }
2919
2920 #[test]
2921 fn desired_packages_winget() {
2922 let spec = PackagesSpec {
2923 winget: vec!["Microsoft.VisualStudioCode".into(), "Git.Git".into()],
2924 ..Default::default()
2925 };
2926 assert_eq!(
2927 desired_packages_for_spec("winget", &spec),
2928 vec!["Microsoft.VisualStudioCode", "Git.Git"]
2929 );
2930 }
2931
2932 #[test]
2933 fn desired_packages_chocolatey() {
2934 let spec = PackagesSpec {
2935 chocolatey: vec!["nodejs".into()],
2936 ..Default::default()
2937 };
2938 assert_eq!(
2939 desired_packages_for_spec("chocolatey", &spec),
2940 vec!["nodejs"]
2941 );
2942 }
2943
2944 #[test]
2945 fn desired_packages_scoop() {
2946 let spec = PackagesSpec {
2947 scoop: vec!["ripgrep".into()],
2948 ..Default::default()
2949 };
2950 assert_eq!(desired_packages_for_spec("scoop", &spec), vec!["ripgrep"]);
2951 }
2952
2953 #[test]
2956 fn load_config_valid_yaml() {
2957 let dir = tempfile::tempdir().unwrap();
2958 let path = dir.path().join("cfgd.yaml");
2959 let yaml = "apiVersion: cfgd.io/v1alpha1\nkind: Config\nmetadata:\n name: test\nspec:\n profile: default\n".to_string();
2960 std::fs::write(&path, &yaml).unwrap();
2961 let cfg = load_config(&path).unwrap();
2962 assert_eq!(cfg.metadata.name, "test");
2963 }
2964
2965 #[test]
2966 fn load_config_valid_toml() {
2967 let dir = tempfile::tempdir().unwrap();
2968 let path = dir.path().join("cfgd.toml");
2969 let toml = "apiVersion = \"cfgd.io/v1alpha1\"\nkind = \"Config\"\n\n[metadata]\nname = \"test\"\n\n[spec]\nprofile = \"default\"\n";
2970 std::fs::write(&path, toml).unwrap();
2971 let cfg = load_config(&path).unwrap();
2972 assert_eq!(cfg.metadata.name, "test");
2973 }
2974
2975 #[test]
2976 fn load_config_missing_file() {
2977 let result = load_config(std::path::Path::new("/nonexistent-12345/cfgd.yaml"));
2978 let err = result.unwrap_err();
2979 let msg = err.to_string();
2980 assert!(
2981 msg.contains("config file not found"),
2982 "expected 'config file not found' in error, got: {msg}"
2983 );
2984 assert!(
2985 msg.contains("/nonexistent-12345/cfgd.yaml"),
2986 "expected path in error, got: {msg}"
2987 );
2988 }
2989
2990 #[test]
2993 fn test_ai_config_defaults() {
2994 let yaml = r#"
2995apiVersion: cfgd.io/v1alpha1
2996kind: Config
2997metadata:
2998 name: test
2999spec: {}
3000"#;
3001 let config: CfgdConfig = serde_yaml::from_str(yaml).unwrap();
3002 let ai = config.spec.ai.unwrap_or_default();
3003 assert_eq!(ai.provider, "claude");
3004 assert_eq!(ai.model, "claude-sonnet-4-6");
3005 assert_eq!(ai.api_key_env, "ANTHROPIC_API_KEY");
3006 }
3007
3008 #[test]
3009 fn test_ai_config_custom() {
3010 let yaml = r#"
3011apiVersion: cfgd.io/v1alpha1
3012kind: Config
3013metadata:
3014 name: test
3015spec:
3016 ai:
3017 provider: claude
3018 model: claude-opus-4-6
3019 apiKeyEnv: MY_CLAUDE_KEY
3020"#;
3021 let config: CfgdConfig = serde_yaml::from_str(yaml).unwrap();
3022 let ai = config.spec.ai.unwrap_or_default();
3023 assert_eq!(ai.model, "claude-opus-4-6");
3024 assert_eq!(ai.api_key_env, "MY_CLAUDE_KEY");
3025 }
3026
3027 #[test]
3028 fn test_existing_config_without_ai_still_parses() {
3029 let yaml = r#"
3030apiVersion: cfgd.io/v1alpha1
3031kind: Config
3032metadata:
3033 name: my-workstation
3034spec:
3035 profile: work
3036 theme: default
3037"#;
3038 let config: CfgdConfig = serde_yaml::from_str(yaml).unwrap();
3039 assert!(config.spec.ai.is_none());
3040 }
3041
3042 #[test]
3043 fn three_level_inheritance() {
3044 let dir = tempfile::tempdir().unwrap();
3045 let profiles = dir.path().join("profiles");
3046 std::fs::create_dir_all(&profiles).unwrap();
3047
3048 let grandparent = "apiVersion: cfgd.io/v1alpha1\nkind: Profile\nmetadata:\n name: grandparent\nspec:\n inherits: []\n modules: []\n env:\n - name: A\n value: '1'\n";
3049 let parent = "apiVersion: cfgd.io/v1alpha1\nkind: Profile\nmetadata:\n name: parent\nspec:\n inherits:\n - grandparent\n modules: []\n env:\n - name: B\n value: '2'\n";
3050 let child = "apiVersion: cfgd.io/v1alpha1\nkind: Profile\nmetadata:\n name: child\nspec:\n inherits:\n - parent\n modules: []\n env:\n - name: C\n value: '3'\n";
3051
3052 std::fs::write(profiles.join("grandparent.yaml"), grandparent).unwrap();
3053 std::fs::write(profiles.join("parent.yaml"), parent).unwrap();
3054 std::fs::write(profiles.join("child.yaml"), child).unwrap();
3055
3056 let resolved = resolve_profile("child", &profiles).unwrap();
3057
3058 let names: Vec<&str> = resolved
3060 .merged
3061 .env
3062 .iter()
3063 .map(|e| e.name.as_str())
3064 .collect();
3065 assert!(names.contains(&"A"));
3066 assert!(names.contains(&"B"));
3067 assert!(names.contains(&"C"));
3068 }
3069
3070 #[test]
3071 fn script_entry_deserialize_simple() {
3072 let yaml = r#""echo hello""#;
3073 let entry: ScriptEntry = serde_yaml::from_str(yaml).unwrap();
3074 match entry {
3075 ScriptEntry::Simple(s) => assert_eq!(s, "echo hello"),
3076 _ => panic!("expected Simple variant"),
3077 }
3078 }
3079
3080 #[test]
3081 fn script_entry_deserialize_full() {
3082 let yaml = r#"
3083run: scripts/check.sh
3084timeout: 30s
3085continueOnError: true
3086"#;
3087 let entry: ScriptEntry = serde_yaml::from_str(yaml).unwrap();
3088 match entry {
3089 ScriptEntry::Full {
3090 run,
3091 timeout,
3092 continue_on_error,
3093 ..
3094 } => {
3095 assert_eq!(run, "scripts/check.sh");
3096 assert_eq!(timeout, Some("30s".to_string()));
3097 assert_eq!(continue_on_error, Some(true));
3098 }
3099 _ => panic!("expected Full variant"),
3100 }
3101 }
3102
3103 #[test]
3104 fn script_spec_deserialize_all_hooks() {
3105 let yaml = r#"
3106preApply:
3107 - scripts/pre.sh
3108postApply:
3109 - run: scripts/post.sh
3110 timeout: 60s
3111preReconcile:
3112 - scripts/reconcile-pre.sh
3113postReconcile:
3114 - scripts/reconcile-post.sh
3115onDrift:
3116 - scripts/drift.sh
3117onChange:
3118 - run: systemctl restart myservice
3119 continueOnError: true
3120"#;
3121 let spec: ScriptSpec = serde_yaml::from_str(yaml).unwrap();
3122 assert_eq!(spec.pre_apply.len(), 1);
3123 assert_eq!(spec.post_apply.len(), 1);
3124 assert_eq!(spec.pre_reconcile.len(), 1);
3125 assert_eq!(spec.post_reconcile.len(), 1);
3126 assert_eq!(spec.on_drift.len(), 1);
3127 assert_eq!(spec.on_change.len(), 1);
3128 }
3129
3130 #[test]
3131 fn script_spec_backward_compat_empty() {
3132 let yaml = "{}";
3133 let spec: ScriptSpec = serde_yaml::from_str(yaml).unwrap();
3134 assert!(spec.pre_apply.is_empty());
3135 assert!(spec.post_apply.is_empty());
3136 assert!(spec.pre_reconcile.is_empty());
3137 assert!(spec.post_reconcile.is_empty());
3138 assert!(spec.on_drift.is_empty());
3139 assert!(spec.on_change.is_empty());
3140 }
3141
3142 #[test]
3145 fn encryption_mode_default_is_in_repo() {
3146 let mode = EncryptionMode::default();
3147 assert_eq!(mode, EncryptionMode::InRepo);
3148 }
3149
3150 #[test]
3151 fn managed_file_spec_encryption_in_repo() {
3152 let yaml = r#"
3153source: dotfiles/.zshrc
3154target: ~/.zshrc
3155encryption:
3156 backend: sops
3157 mode: InRepo
3158"#;
3159 let spec: ManagedFileSpec = serde_yaml::from_str(yaml).unwrap();
3160 let enc = spec.encryption.expect("encryption should be Some");
3161 assert_eq!(enc.backend, "sops");
3162 assert_eq!(enc.mode, EncryptionMode::InRepo);
3163 }
3164
3165 #[test]
3166 fn managed_file_spec_encryption_always() {
3167 let yaml = r#"
3168source: secrets/.env
3169target: ~/.env
3170encryption:
3171 backend: age
3172 mode: Always
3173"#;
3174 let spec: ManagedFileSpec = serde_yaml::from_str(yaml).unwrap();
3175 let enc = spec.encryption.expect("encryption should be Some");
3176 assert_eq!(enc.backend, "age");
3177 assert_eq!(enc.mode, EncryptionMode::Always);
3178 }
3179
3180 #[test]
3181 fn managed_file_spec_no_encryption() {
3182 let yaml = r#"
3183source: dotfiles/.bashrc
3184target: ~/.bashrc
3185"#;
3186 let spec: ManagedFileSpec = serde_yaml::from_str(yaml).unwrap();
3187 assert!(spec.encryption.is_none());
3188 }
3189
3190 #[test]
3191 fn managed_file_spec_permissions() {
3192 let yaml = r#"
3193source: dotfiles/.ssh/config
3194target: ~/.ssh/config
3195permissions: "600"
3196"#;
3197 let spec: ManagedFileSpec = serde_yaml::from_str(yaml).unwrap();
3198 assert_eq!(spec.permissions.as_deref(), Some("600"));
3199 }
3200
3201 #[test]
3202 fn managed_file_spec_permissions_absent() {
3203 let yaml = r#"
3204source: dotfiles/.vimrc
3205target: ~/.vimrc
3206"#;
3207 let spec: ManagedFileSpec = serde_yaml::from_str(yaml).unwrap();
3208 assert!(spec.permissions.is_none());
3209 }
3210
3211 #[test]
3212 fn source_constraints_encryption() {
3213 let yaml = r#"
3214noScripts: true
3215noSecretsRead: true
3216allowedTargetPaths: []
3217allowSystemChanges: false
3218requireSignedCommits: false
3219encryption:
3220 requiredTargets:
3221 - "~/.ssh/*"
3222 - "~/.gnupg/*"
3223 backend: sops
3224 mode: InRepo
3225"#;
3226 let sc: SourceConstraints = serde_yaml::from_str(yaml).unwrap();
3227 let enc = sc.encryption.expect("encryption should be Some");
3228 assert_eq!(enc.required_targets.len(), 2);
3229 assert_eq!(enc.required_targets[0], "~/.ssh/*");
3230 assert_eq!(enc.required_targets[1], "~/.gnupg/*");
3231 assert_eq!(enc.backend.as_deref(), Some("sops"));
3232 assert_eq!(enc.mode, Some(EncryptionMode::InRepo));
3233 }
3234
3235 #[test]
3236 fn source_constraints_no_encryption_defaults_none() {
3237 let sc = SourceConstraints::default();
3238 assert!(sc.encryption.is_none());
3239 }
3240
3241 #[test]
3242 fn source_constraints_encryption_required_targets_only() {
3243 let yaml = r#"
3244encryption:
3245 requiredTargets:
3246 - "~/.aws/credentials"
3247"#;
3248 let sc: SourceConstraints = serde_yaml::from_str(yaml).unwrap();
3249 let enc = sc.encryption.expect("encryption should be Some");
3250 assert_eq!(enc.required_targets.len(), 1);
3251 assert!(enc.backend.is_none());
3252 assert!(enc.mode.is_none());
3253 }
3254
3255 #[test]
3256 fn module_file_entry_with_encryption() {
3257 let yaml = r#"
3258source: files/.gitconfig
3259target: ~/.gitconfig
3260encryption:
3261 backend: sops
3262 mode: InRepo
3263"#;
3264 let entry: ModuleFileEntry = serde_yaml::from_str(yaml).unwrap();
3265 let enc = entry.encryption.expect("encryption should be Some");
3266 assert_eq!(enc.backend, "sops");
3267 assert_eq!(enc.mode, EncryptionMode::InRepo);
3268 }
3269
3270 #[test]
3271 fn module_file_entry_no_encryption() {
3272 let yaml = r#"
3273source: files/.tmux.conf
3274target: ~/.tmux.conf
3275"#;
3276 let entry: ModuleFileEntry = serde_yaml::from_str(yaml).unwrap();
3277 assert!(entry.encryption.is_none());
3278 }
3279
3280 #[test]
3281 fn encryption_spec_mode_defaults_to_in_repo_when_omitted() {
3282 let yaml = r#"
3283backend: sops
3284"#;
3285 let spec: EncryptionSpec = serde_yaml::from_str(yaml).unwrap();
3286 assert_eq!(spec.backend, "sops");
3287 assert_eq!(spec.mode, EncryptionMode::InRepo);
3288 }
3289
3290 #[test]
3291 fn secret_spec_with_envs_only() {
3292 let yaml = r#"
3293source: op://vault/item/password
3294envs:
3295 - DB_PASSWORD
3296"#;
3297 let spec: SecretSpec = serde_yaml::from_str(yaml).unwrap();
3298 assert_eq!(spec.source, "op://vault/item/password");
3299 assert!(spec.target.is_none());
3300 assert_eq!(spec.envs.as_ref().unwrap(), &["DB_PASSWORD"]);
3301 }
3302
3303 #[test]
3304 fn secret_spec_with_target_and_envs() {
3305 let yaml = r#"
3306source: secrets/api-key.enc
3307target: ~/.config/app/key
3308envs:
3309 - API_KEY
3310"#;
3311 let spec: SecretSpec = serde_yaml::from_str(yaml).unwrap();
3312 assert_eq!(spec.source, "secrets/api-key.enc");
3313 assert_eq!(
3314 spec.target.unwrap(),
3315 std::path::PathBuf::from("~/.config/app/key")
3316 );
3317 assert_eq!(spec.envs.as_ref().unwrap(), &["API_KEY"]);
3318 }
3319
3320 #[test]
3321 fn secret_spec_with_target_only() {
3322 let yaml = r#"
3323source: secrets/credentials.enc
3324target: ~/.config/app/credentials
3325"#;
3326 let spec: SecretSpec = serde_yaml::from_str(yaml).unwrap();
3327 assert_eq!(spec.source, "secrets/credentials.enc");
3328 assert_eq!(
3329 spec.target.unwrap(),
3330 std::path::PathBuf::from("~/.config/app/credentials")
3331 );
3332 assert!(spec.envs.is_none());
3333 }
3334
3335 #[test]
3336 fn secret_spec_neither_target_nor_envs_fails_validation() {
3337 let specs = vec![SecretSpec {
3338 source: "secrets/orphan.enc".to_string(),
3339 target: None,
3340 template: None,
3341 backend: None,
3342 envs: None,
3343 }];
3344 let result = validate_secret_specs(&specs);
3345 assert!(result.is_err());
3346 let err_msg = result.unwrap_err().to_string();
3347 assert!(
3348 err_msg.contains("must have at least one of 'target' or 'envs'"),
3349 "unexpected error: {}",
3350 err_msg
3351 );
3352 }
3353
3354 #[test]
3355 fn secret_spec_validation_passes_with_target() {
3356 let specs = vec![SecretSpec {
3357 source: "secrets/key.enc".to_string(),
3358 target: Some(std::path::PathBuf::from("~/.ssh/key")),
3359 template: None,
3360 backend: None,
3361 envs: None,
3362 }];
3363 validate_secret_specs(&specs).expect("validation should pass for spec with target");
3364 assert_eq!(specs[0].source, "secrets/key.enc");
3365 assert_eq!(
3366 specs[0].target.as_deref(),
3367 Some(std::path::Path::new("~/.ssh/key"))
3368 );
3369 assert!(specs[0].envs.is_none());
3370 }
3371
3372 #[test]
3373 fn secret_spec_validation_passes_with_envs() {
3374 let specs = vec![SecretSpec {
3375 source: "op://vault/item".to_string(),
3376 target: None,
3377 template: None,
3378 backend: None,
3379 envs: Some(vec!["SECRET_KEY".to_string()]),
3380 }];
3381 validate_secret_specs(&specs).expect("validation should pass for spec with envs");
3382 assert_eq!(specs[0].source, "op://vault/item");
3383 assert!(specs[0].target.is_none());
3384 let envs = specs[0].envs.as_ref().expect("envs should be Some");
3385 assert_eq!(envs.len(), 1);
3386 assert_eq!(envs[0], "SECRET_KEY");
3387 }
3388
3389 #[test]
3390 fn policy_items_with_secrets() {
3391 let yaml = r#"
3392secrets:
3393 - source: op://vault/db/password
3394 envs:
3395 - DB_PASSWORD
3396 - source: secrets/tls.enc
3397 target: /etc/tls/cert.pem
3398"#;
3399 let items: PolicyItems = serde_yaml::from_str(yaml).unwrap();
3400 assert_eq!(items.secrets.len(), 2);
3401 assert_eq!(items.secrets[0].source, "op://vault/db/password");
3402 assert!(items.secrets[0].target.is_none());
3403 assert_eq!(items.secrets[0].envs.as_ref().unwrap(), &["DB_PASSWORD"]);
3404 assert_eq!(items.secrets[1].source, "secrets/tls.enc");
3405 assert_eq!(
3406 items.secrets[1].target.as_ref().unwrap(),
3407 &std::path::PathBuf::from("/etc/tls/cert.pem")
3408 );
3409 assert!(items.secrets[1].envs.is_none());
3410 }
3411
3412 #[test]
3413 fn policy_items_default_has_empty_secrets() {
3414 let items = PolicyItems::default();
3415 assert!(items.secrets.is_empty());
3416 }
3417
3418 #[test]
3419 fn module_spec_system_field_deserializes() {
3420 let yaml = r#"
3421apiVersion: cfgd.io/v1alpha1
3422kind: Module
3423metadata:
3424 name: git-setup
3425spec:
3426 system:
3427 git:
3428 user.name: Jane Doe
3429 user.email: jane@example.com
3430 sshKeys:
3431 - path: ~/.ssh/id_ed25519.pub
3432 comment: jane@example.com
3433"#;
3434 let doc: ModuleDocument = serde_yaml::from_str(yaml).unwrap();
3435 assert_eq!(doc.spec.system.len(), 2);
3436 assert!(doc.spec.system.contains_key("git"));
3437 assert!(doc.spec.system.contains_key("sshKeys"));
3438 let git_val = &doc.spec.system["git"];
3439 assert_eq!(
3440 git_val["user.name"],
3441 serde_yaml::Value::String("Jane Doe".into())
3442 );
3443 assert_eq!(
3444 git_val["user.email"],
3445 serde_yaml::Value::String("jane@example.com".into())
3446 );
3447 }
3448
3449 #[test]
3450 fn module_spec_system_defaults_to_empty() {
3451 let yaml = r#"
3452apiVersion: cfgd.io/v1alpha1
3453kind: Module
3454metadata:
3455 name: nvim
3456spec:
3457 packages:
3458 - name: neovim
3459"#;
3460 let doc: ModuleDocument = serde_yaml::from_str(yaml).unwrap();
3461 assert!(doc.spec.system.is_empty());
3462 }
3463
3464 #[test]
3465 fn module_system_merges_into_profile_system() {
3466 let profile_yaml = r#"
3468git:
3469 user.name: Old Name
3470 user.signingkey: ABC123
3471"#;
3472 let module_yaml = r#"
3473git:
3474 user.name: New Name
3475 user.email: new@example.com
3476"#;
3477 let mut profile_system: HashMap<String, serde_yaml::Value> =
3478 serde_yaml::from_str(profile_yaml).unwrap();
3479 let module_system: HashMap<String, serde_yaml::Value> =
3480 serde_yaml::from_str(module_yaml).unwrap();
3481
3482 for (key, value) in &module_system {
3484 crate::deep_merge_yaml(
3485 profile_system
3486 .entry(key.clone())
3487 .or_insert(serde_yaml::Value::Null),
3488 value,
3489 );
3490 }
3491
3492 let git = &profile_system["git"];
3493 assert_eq!(
3495 git["user.name"],
3496 serde_yaml::Value::String("New Name".into())
3497 );
3498 assert_eq!(
3500 git["user.email"],
3501 serde_yaml::Value::String("new@example.com".into())
3502 );
3503 assert_eq!(
3505 git["user.signingkey"],
3506 serde_yaml::Value::String("ABC123".into())
3507 );
3508 }
3509
3510 #[test]
3511 fn module_system_overrides_profile_on_conflict() {
3512 let mut profile_system: HashMap<String, serde_yaml::Value> = {
3513 let mut m = HashMap::new();
3514 m.insert(
3515 "git".to_string(),
3516 serde_yaml::from_str("user.name: Profile Name").unwrap(),
3517 );
3518 m
3519 };
3520 let module_system: HashMap<String, serde_yaml::Value> = {
3521 let mut m = HashMap::new();
3522 m.insert(
3523 "git".to_string(),
3524 serde_yaml::from_str("user.name: Module Name").unwrap(),
3525 );
3526 m
3527 };
3528
3529 for (key, value) in &module_system {
3530 crate::deep_merge_yaml(
3531 profile_system
3532 .entry(key.clone())
3533 .or_insert(serde_yaml::Value::Null),
3534 value,
3535 );
3536 }
3537
3538 assert_eq!(
3539 profile_system["git"]["user.name"],
3540 serde_yaml::Value::String("Module Name".into())
3541 );
3542 }
3543
3544 #[test]
3549 fn parse_full_compliance_config() {
3550 let yaml = r#"
3551apiVersion: cfgd.io/v1alpha1
3552kind: Config
3553metadata:
3554 name: test
3555spec:
3556 compliance:
3557 enabled: true
3558 interval: 30m
3559 retention: 90d
3560 scope:
3561 files: true
3562 packages: false
3563 system: true
3564 secrets: false
3565 watchPaths:
3566 - /etc
3567 - /usr/local
3568 watchPackageManagers:
3569 - brew
3570 - apt
3571 export:
3572 format: Yaml
3573 path: /var/lib/cfgd/compliance/
3574"#;
3575 let config = parse_config(yaml, Path::new("cfgd.yaml")).unwrap();
3576 let compliance = config.spec.compliance.as_ref().unwrap();
3577 assert!(compliance.enabled);
3578 assert_eq!(compliance.interval, "30m");
3579 assert_eq!(compliance.retention, "90d");
3580 assert!(compliance.scope.files);
3581 assert!(!compliance.scope.packages);
3582 assert!(compliance.scope.system);
3583 assert!(!compliance.scope.secrets);
3584 assert_eq!(compliance.scope.watch_paths, vec!["/etc", "/usr/local"]);
3585 assert_eq!(compliance.scope.watch_package_managers, vec!["brew", "apt"]);
3586 assert_eq!(compliance.export.format, ComplianceFormat::Yaml);
3587 assert_eq!(compliance.export.path, "/var/lib/cfgd/compliance/");
3588 }
3589
3590 #[test]
3591 fn parse_compliance_defaults_from_enabled_only() {
3592 let yaml = r#"
3593apiVersion: cfgd.io/v1alpha1
3594kind: Config
3595metadata:
3596 name: test
3597spec:
3598 compliance:
3599 enabled: true
3600"#;
3601 let config = parse_config(yaml, Path::new("cfgd.yaml")).unwrap();
3602 let compliance = config.spec.compliance.as_ref().unwrap();
3603 assert!(compliance.enabled);
3604 assert_eq!(compliance.interval, "1h");
3605 assert_eq!(compliance.retention, "30d");
3606 assert!(compliance.scope.files);
3608 assert!(compliance.scope.packages);
3609 assert!(compliance.scope.system);
3610 assert!(compliance.scope.secrets);
3611 assert!(compliance.scope.watch_paths.is_empty());
3612 assert!(compliance.scope.watch_package_managers.is_empty());
3613 assert_eq!(compliance.export.format, ComplianceFormat::Json);
3615 assert_eq!(compliance.export.path, "~/.local/share/cfgd/compliance/");
3616 }
3617
3618 #[test]
3619 fn parse_compliance_watch_paths_and_managers() {
3620 let yaml = r#"
3621apiVersion: cfgd.io/v1alpha1
3622kind: Config
3623metadata:
3624 name: test
3625spec:
3626 compliance:
3627 enabled: true
3628 scope:
3629 watchPaths:
3630 - /home/user/.config
3631 watchPackageManagers:
3632 - cargo
3633 - npm
3634"#;
3635 let config = parse_config(yaml, Path::new("cfgd.yaml")).unwrap();
3636 let scope = &config.spec.compliance.as_ref().unwrap().scope;
3637 assert_eq!(scope.watch_paths, vec!["/home/user/.config"]);
3638 assert_eq!(scope.watch_package_managers, vec!["cargo", "npm"]);
3639 }
3640
3641 #[test]
3642 fn compliance_format_defaults_to_json() {
3643 let export = ComplianceExport::default();
3644 assert_eq!(export.format, ComplianceFormat::Json);
3645 }
3646
3647 #[test]
3648 fn parse_complete_cfgd_yaml_with_compliance() {
3649 let yaml = r#"
3650apiVersion: cfgd.io/v1alpha1
3651kind: Config
3652metadata:
3653 name: workstation
3654spec:
3655 profile: default
3656 compliance:
3657 enabled: true
3658 interval: 1h
3659 retention: 30d
3660 scope:
3661 files: true
3662 packages: true
3663 system: true
3664 secrets: true
3665 export:
3666 format: Json
3667 path: ~/.local/share/cfgd/compliance/
3668"#;
3669 let config = parse_config(yaml, Path::new("cfgd.yaml")).unwrap();
3670 assert_eq!(config.spec.profile.as_deref(), Some("default"));
3671 let compliance = config.spec.compliance.as_ref().unwrap();
3672 assert!(compliance.enabled);
3673 assert_eq!(compliance.interval, "1h");
3674 assert_eq!(compliance.retention, "30d");
3675 assert!(compliance.scope.files);
3676 assert!(compliance.scope.packages);
3677 assert!(compliance.scope.system);
3678 assert!(compliance.scope.secrets);
3679 assert_eq!(compliance.export.format, ComplianceFormat::Json);
3680 assert_eq!(compliance.export.path, "~/.local/share/cfgd/compliance/");
3681 }
3682
3683 #[test]
3684 fn compliance_absent_when_not_specified() {
3685 let yaml = r#"
3686apiVersion: cfgd.io/v1alpha1
3687kind: Config
3688metadata:
3689 name: test
3690spec:
3691 profile: default
3692"#;
3693 let config = parse_config(yaml, Path::new("cfgd.yaml")).unwrap();
3694 assert!(config.spec.compliance.is_none());
3695 }
3696
3697 #[test]
3698 fn yaml_anchor_limit_rejects_bomb() {
3699 let mut yaml = String::from("apiVersion: cfgd.io/v1alpha1\nkind: Config\n");
3701 for i in 0..300 {
3702 yaml.push_str(&format!("key{}: &anchor{} value{}\n", i, i, i));
3703 }
3704 let result = parse_config(&yaml, std::path::Path::new("bomb.yaml"));
3705 assert!(result.is_err());
3706 assert!(
3707 result
3708 .unwrap_err()
3709 .to_string()
3710 .contains("too many YAML anchors")
3711 );
3712 }
3713
3714 #[test]
3715 fn yaml_anchor_limit_accepts_normal_config() {
3716 let yaml = r#"
3718apiVersion: cfgd.io/v1alpha1
3719kind: Config
3720metadata:
3721 name: test
3722spec:
3723 profile: default
3724"#;
3725 let result = parse_config(yaml, std::path::Path::new("cfgd.yaml"));
3726 assert!(
3727 result.is_ok(),
3728 "normal config should parse: {:?}",
3729 result.err()
3730 );
3731 let config = result.unwrap();
3732 assert_eq!(config.metadata.name, "test");
3733 assert_eq!(config.spec.profile.as_deref(), Some("default"));
3734 assert_eq!(config.api_version, "cfgd.io/v1alpha1");
3735 assert_eq!(config.kind, "Config");
3736 assert!(config.spec.origin.is_empty(), "no origins configured");
3737 assert!(config.spec.sources.is_empty(), "no sources configured");
3738 }
3739
3740 #[test]
3741 fn env_var_rejects_invalid_name_at_deserialization() {
3742 let yaml = r#"
3743name: "MY_VAR; rm -rf /"
3744value: "safe"
3745"#;
3746 let result: std::result::Result<EnvVar, _> = serde_yaml::from_str(yaml);
3747 assert!(result.is_err());
3748 assert!(
3749 result
3750 .unwrap_err()
3751 .to_string()
3752 .contains("invalid env var name")
3753 );
3754 }
3755
3756 #[test]
3757 fn env_var_accepts_valid_name_at_deserialization() {
3758 let yaml = r#"
3759name: "MY_VAR_123"
3760value: "hello"
3761"#;
3762 let ev: EnvVar = serde_yaml::from_str(yaml).unwrap();
3763 assert_eq!(ev.name, "MY_VAR_123");
3764 assert_eq!(ev.value, "hello");
3765 }
3766
3767 #[test]
3768 fn alias_rejects_invalid_name_at_deserialization() {
3769 let yaml = r#"
3770name: "my alias; evil"
3771command: "ls -la"
3772"#;
3773 let result: std::result::Result<ShellAlias, _> = serde_yaml::from_str(yaml);
3774 assert!(result.is_err());
3775 assert!(
3776 result
3777 .unwrap_err()
3778 .to_string()
3779 .contains("invalid alias name")
3780 );
3781 }
3782
3783 #[test]
3784 fn alias_accepts_valid_name_at_deserialization() {
3785 let yaml = r#"
3786name: "ll"
3787command: "ls -la"
3788"#;
3789 let alias: ShellAlias = serde_yaml::from_str(yaml).unwrap();
3790 assert_eq!(alias.name, "ll");
3791 assert_eq!(alias.command, "ls -la");
3792 }
3793}