Skip to main content

cfgd_core/config/
mod.rs

1// Config types, profile resolution, and multi-source prep
2
3use 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// --- Root Config (cfgd.yaml) ---
13
14#[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    /// Returns the active profile name, or an error if no profile is configured.
25    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    /// Module configuration: registries and security.
66    #[serde(default)]
67    pub modules: Option<ModulesConfig>,
68
69    /// Global default file deployment strategy. Per-file overrides take precedence.
70    #[serde(default)]
71    pub file_strategy: FileStrategy,
72
73    /// Security settings for source signature verification.
74    #[serde(default)]
75    pub security: Option<SecurityConfig>,
76
77    /// CLI aliases: map of alias name → command string.
78    /// Built-in defaults (add, remove) can be overridden or extended.
79    #[serde(default)]
80    pub aliases: HashMap<String, String>,
81
82    /// AI assistant configuration: provider, model, and API key env var.
83    #[serde(default)]
84    pub ai: Option<AiConfig>,
85
86    /// Compliance snapshot configuration.
87    #[serde(default)]
88    pub compliance: Option<ComplianceConfig>,
89}
90
91/// Build a minimal CfgdConfig for module-only operations that don't have cfgd.yaml.
92pub 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    /// Allow unsigned source content even when the source requires signed commits.
138    /// Intended for development/testing environments.
139    #[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    /// Module registries — git repos containing modules in a prescribed directory structure.
147    #[serde(default)]
148    pub registries: Vec<ModuleRegistryEntry>,
149
150    /// Module security settings.
151    #[serde(default)]
152    pub security: Option<ModuleSecurityConfig>,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156#[serde(rename_all = "camelCase")]
157pub struct ModuleSecurityConfig {
158    /// Require GPG/SSH signatures on all remote module tags.
159    /// When true, unsigned modules are rejected unless `--allow-unsigned` is passed.
160    #[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
186// Accept both `theme: "dracula"` (string) and `theme: { name: dracula, overrides: ... }` (struct)
187impl<'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
275// Custom deserialization: origin can be a single object or an array
276// Internally always Vec<OriginSpec> with primary at index 0
277impl 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    /// SSH `StrictHostKeyChecking` policy for git operations.
294    /// `AcceptNew` (default): accept first-seen keys, reject changed keys.
295    /// `Yes`: require keys to already exist in known_hosts (high-security).
296    /// `No`: accept any key (insecure, not recommended).
297    #[serde(default)]
298    pub ssh_strict_host_key_checking: SshHostKeyPolicy,
299}
300
301/// SSH `StrictHostKeyChecking` policy for git operations over SSH.
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
303pub enum SshHostKeyPolicy {
304    /// Accept first-seen keys, reject changed keys (safe default for automation).
305    #[default]
306    AcceptNew,
307    /// Require keys to already exist in known_hosts (high-security environments).
308    Yes,
309    /// Accept any key without verification (insecure, not recommended).
310    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    /// Policy for daemon auto-reconciliation of detected drift.
358    /// `Auto` = silently apply (must opt-in), `NotifyOnly` = notify but don't
359    /// apply (safe default), `Prompt` = future interactive approval.
360    #[serde(default)]
361    pub drift_policy: DriftPolicy,
362    /// Per-module or per-profile reconcile overrides (kustomize-style patches).
363    /// Each patch targets a specific Module or Profile by name and overrides
364    /// individual reconcile fields. Precedence: Module patch > Profile patch > global.
365    #[serde(default)]
366    pub patches: Vec<ReconcilePatch>,
367}
368
369/// A kustomize-style reconcile patch targeting a specific module or profile.
370/// When `name` is omitted, the patch applies to all entities of the given kind.
371#[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/// Daemon drift reconciliation policy. PascalCase values match K8s enum conventions.
392#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
393pub enum DriftPolicy {
394    /// Apply drift corrections automatically (current behavior, now opt-in).
395    Auto,
396    /// Notify and record drift, but do not apply. User must run `cfgd apply`.
397    #[default]
398    NotifyOnly,
399    /// Future: notify with actionable prompt.
400    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// --- Multi-source config management ---
505
506#[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// --- ConfigSource manifest (published by team, lives in source repo as cfgd-source.yaml) ---
577
578#[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/// Detailed profile entry in a ConfigSource manifest.
620/// When present, provides richer info than the flat `profiles` list.
621#[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    /// Require that the HEAD commit in this source's git repo has a valid
735    /// GPG or SSH signature. Subscribers can bypass with `security.allow-unsigned`.
736    #[serde(default)]
737    pub require_signed_commits: bool,
738    /// Encryption requirements imposed on files delivered by this source.
739    #[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// ---------------------------------------------------------------------------
761// Compliance configuration
762// ---------------------------------------------------------------------------
763
764#[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
845/// Maximum number of YAML anchors (`&name`) allowed in a single document.
846/// Prevents billion-laughs-style anchor/alias expansion attacks (CVE-2019-11253).
847const MAX_YAML_ANCHORS: usize = 256;
848
849/// Pre-parse check for YAML anchor/alias bomb attacks.
850/// Counts anchor definitions (`&name`) and rejects documents exceeding the limit.
851fn check_yaml_anchor_limit(contents: &str, context: &Path) -> Result<()> {
852    // Count lines containing YAML anchor definitions (& followed by an identifier).
853    // This is a conservative heuristic — it may count `&` in strings, but that's
854    // acceptable since legitimate configs rarely have hundreds of anchors.
855    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
875/// Parse a ConfigSource manifest from YAML content.
876pub 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// --- Module ---
891
892#[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    /// System configurator settings contributed by this module.
931    /// Deep-merged into the profile system map; module values override profile values at leaf level.
932    #[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    /// Per-file deployment strategy override. If None, uses the global default.
967    #[serde(default, skip_serializing_if = "Option::is_none")]
968    pub strategy: Option<FileStrategy>,
969    /// When true, the source file is local-only: auto-added to .gitignore,
970    /// silently skipped on machines where it doesn't exist.
971    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
972    pub private: bool,
973    /// Encryption settings for this module file.
974    #[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        /// Kill the script if it produces no stdout/stderr output for this duration.
987        /// Prevents scripts from silently hanging on unresponsive resources.
988        /// Format: "30s", "2m", etc. If unset, no idle timeout is enforced.
989        #[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    /// Extract the run command string from any variant.
1006    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// --- Module Lockfile ---
1021
1022/// Lockfile recording pinned remote modules with integrity hashes.
1023/// Stored at `<config_dir>/modules.lock`.
1024#[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/// A single locked remote module.
1032#[derive(Debug, Clone, Serialize, Deserialize)]
1033#[serde(rename_all = "camelCase")]
1034pub struct ModuleLockEntry {
1035    /// Module name (matches metadata.name in the module spec).
1036    pub name: String,
1037    /// Git URL of the remote module repository.
1038    pub url: String,
1039    /// Pinned git ref — tag or commit SHA (branches not allowed for remote modules).
1040    pub pinned_ref: String,
1041    /// Resolved commit SHA at the time of locking.
1042    pub commit: String,
1043    /// SHA-256 hash of the module directory contents for integrity verification.
1044    pub integrity: String,
1045    /// Subdirectory within the repo containing the module.
1046    #[serde(default, skip_serializing_if = "Option::is_none")]
1047    pub subdir: Option<String>,
1048}
1049
1050// --- Module Registries ---
1051
1052/// A module registry — a git repo containing modules in `modules/<name>/module.yaml` structure.
1053#[derive(Debug, Clone, Serialize, Deserialize)]
1054#[serde(rename_all = "camelCase")]
1055pub struct ModuleRegistryEntry {
1056    /// Short name / alias for this source (defaults to GitHub org name).
1057    pub name: String,
1058    /// Git URL of the source repository.
1059    pub url: String,
1060}
1061
1062/// Parse a Module document from YAML content.
1063pub 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// --- Profile ---
1078
1079#[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    /// Return a mutable reference to the package list for a simple `Vec<String>` manager.
1170    /// Returns `None` for managers that use struct wrappers (brew, apt, cargo, npm, snap, flatpak)
1171    /// or for unknown manager names.
1172    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    /// Return a reference to the package list for a simple `Vec<String>` manager.
1191    /// Returns `None` for struct-wrapper managers or unknown names.
1192    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    /// Return all non-empty simple-list managers as `(name, packages)` pairs.
1211    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/// Cargo package spec. Supports both list form (`cargo: [bat, ripgrep]`)
1269/// and object form (`cargo: { file: Cargo.toml, packages: [...] }`).
1270#[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        // Try to deserialize as either a list of strings or a map with file/packages
1295        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/// File deployment strategy.
1376#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1377pub enum FileStrategy {
1378    /// Create a symbolic link from target to source (default).
1379    #[default]
1380    Symlink,
1381    /// Copy source content to target.
1382    Copy,
1383    /// Render a Tera template and write the output (auto-selected for .tera files).
1384    Template,
1385    /// Create a hard link from target to source.
1386    Hardlink,
1387}
1388
1389/// Controls when encryption is required for a managed file.
1390#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1391pub enum EncryptionMode {
1392    /// File must be encrypted when stored in the repository.
1393    #[default]
1394    InRepo,
1395    /// File must always be encrypted, including at rest on disk.
1396    Always,
1397}
1398
1399/// Encryption settings for a managed file.
1400#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1401#[serde(rename_all = "camelCase")]
1402pub struct EncryptionSpec {
1403    /// The encryption backend to use (e.g. "sops", "age").
1404    pub backend: String,
1405    /// When encryption must be enforced. Defaults to `InRepo`.
1406    #[serde(default)]
1407    pub mode: EncryptionMode,
1408}
1409
1410/// Encryption constraint applied to files from a config source.
1411#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1412#[serde(rename_all = "camelCase")]
1413pub struct EncryptionConstraint {
1414    /// Glob patterns or explicit paths that must be encrypted.
1415    #[serde(default)]
1416    pub required_targets: Vec<String>,
1417    /// If set, restrict which backend is acceptable.
1418    #[serde(default, skip_serializing_if = "Option::is_none")]
1419    pub backend: Option<String>,
1420    /// If set, restrict which encryption mode is acceptable.
1421    #[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    /// Per-file deployment strategy override. If None, uses the global default.
1431    #[serde(default, skip_serializing_if = "Option::is_none")]
1432    pub strategy: Option<FileStrategy>,
1433    /// When true, the source file is local-only: auto-added to .gitignore,
1434    /// silently skipped on machines where it doesn't exist.
1435    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1436    pub private: bool,
1437    /// Which source this file came from (None = local config).
1438    /// Used by the template sandbox to restrict variable access.
1439    #[serde(skip)]
1440    pub origin: Option<String>,
1441    /// Encryption settings for this file.
1442    #[serde(default, skip_serializing_if = "Option::is_none")]
1443    pub encryption: Option<EncryptionSpec>,
1444    /// Unix permission bits (e.g. "600", "644") to apply after deployment.
1445    #[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
1463/// Validate that each secret has at least one delivery target (`target` or `envs`).
1464pub 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// --- Profile Resolution ---
1497
1498#[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
1533/// Load and parse the root cfgd.yaml config file
1534pub 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    // Reject excessively large config files to prevent memory exhaustion (YAML bomb defense)
1543    const MAX_CONFIG_SIZE: u64 = 50 * 1024 * 1024; // 50 MB
1544    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
1565/// Parse config from string, supporting both YAML and TOML based on file extension
1566pub 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    // Normalize origin to Vec
1579    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// Internal raw types for flexible origin deserialization
1607#[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
1652/// Load a profile document from a YAML file
1653pub 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
1670/// Find a profile file by name, checking `.yaml` then `.yml` extensions.
1671fn 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    // Fall back to .yaml so load_profile produces the expected error
1681    yaml_path
1682}
1683
1684/// Resolve a profile by loading it and its full inheritance chain, then merging.
1685pub 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
1715/// Recursively resolve the inheritance order (depth-first, left-to-right).
1716/// Returns profiles in resolution order: earliest ancestor first, active profile last.
1717fn 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
1756/// Merge profile layers according to merge rules:
1757/// - packages: union
1758/// - files: overlay (later overrides earlier for same target)
1759/// - env: override (later replaces earlier for same name)
1760/// - secrets: append (deduplicated by target)
1761/// - scripts: append in order
1762/// - system: deep merge (later overrides at leaf level)
1763fn merge_layers(layers: &[ProfileLayer]) -> MergedProfile {
1764    let mut merged = MergedProfile::default();
1765
1766    for layer in layers {
1767        let spec = &layer.spec;
1768
1769        // Modules: union
1770        union_extend(&mut merged.modules, &spec.modules);
1771
1772        // Env: later layer overrides earlier by name
1773        crate::merge_env(&mut merged.env, &spec.env);
1774
1775        // Aliases: later layer overrides earlier by name
1776        crate::merge_aliases(&mut merged.aliases, &spec.aliases);
1777
1778        // Packages: union (delegated to composition::merge_packages)
1779        if let Some(ref pkgs) = spec.packages {
1780            crate::composition::merge_packages(&mut merged.packages, pkgs);
1781        }
1782
1783        // Files: overlay (later layer overrides earlier for same target)
1784        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        // System: deep merge at leaf level
1803        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        // Secrets: append, deduplicate by source (later layer overrides)
1814        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        // Scripts: append in order
1827        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
1846/// Get the list of desired packages for a specific package manager from a merged profile.
1847pub 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            // Check custom managers
1915            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// --- Platform Detection ---
1926
1927/// Detected platform information for matching source `platform-profiles`.
1928#[derive(Debug, Clone)]
1929pub struct PlatformInfo {
1930    pub os: String,
1931    pub distro: Option<String>,
1932    pub distro_version: Option<String>,
1933}
1934
1935/// Detect the current platform OS, distro, and version.
1936pub 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
1953/// Match platform info against a source's `platform-profiles` map.
1954/// Tries exact distro match first, then OS-level match.
1955pub fn match_platform_profile(
1956    platform: &PlatformInfo,
1957    platform_profiles: &HashMap<String, String>,
1958) -> Option<String> {
1959    // Try exact distro match first (e.g., "debian", "ubuntu", "fedora")
1960    if let Some(ref distro) = platform.distro
1961        && let Some(path) = platform_profiles.get(distro)
1962    {
1963        return Some(path.clone());
1964    }
1965    // Fall back to OS-level match (e.g., "macos", "linux")
1966    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
1982/// Get the list of profile names from a ConfigSource manifest.
1983/// Prefers `profile_details` if populated, falls back to `profiles`.
1984pub 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        // Create base profile
2227        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        // Create work profile inheriting base
2246        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        // editor should be overridden by work
2273        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        // packages should be unioned
2283        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        // file from base preserved (layer2 didn't override)
2539        assert_eq!(brew.file.as_deref(), Some("Brewfile"));
2540        // formulae unioned
2541        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        // No "arch" key, falls back to "linux"
2654        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        // profile_details takes precedence
2695        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        // Kind-wide patch (no name)
2767        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    // --- desired_packages_for_spec ---
2780
2781    #[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    // --- load_config filesystem ---
2954
2955    #[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    // --- resolve_profile deeper inheritance ---
2991
2992    #[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        // Should have all three env vars merged
3059        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    // --- Encryption types ---
3143
3144    #[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        // Simulate what plan_system does: deep-merge module system over profile system.
3467        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        // Apply module system on top of profile system (same logic as plan_system)
3483        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        // Module overrides: user.name updated
3494        assert_eq!(
3495            git["user.name"],
3496            serde_yaml::Value::String("New Name".into())
3497        );
3498        // Module adds: user.email
3499        assert_eq!(
3500            git["user.email"],
3501            serde_yaml::Value::String("new@example.com".into())
3502        );
3503        // Profile value preserved when module doesn't mention it
3504        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    // ---------------------------------------------------------------------------
3545    // ComplianceConfig tests
3546    // ---------------------------------------------------------------------------
3547
3548    #[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        // scope bools default to true
3607        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        // export defaults
3614        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        // Generate a YAML document with excessive anchors
3700        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        // A normal config with a few anchors should be fine
3717        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}