Skip to main content

cfgd_core/composition/
mod.rs

1// Composition — multi-source merge engine with policy enforcement
2// Dependency rules: depends only on config/, errors/. Must NOT import
3// files/, packages/, secrets/, reconciler/, daemon/, providers/.
4
5use std::collections::HashMap;
6use std::path::PathBuf;
7
8use crate::config::{
9    ConfigSourcePolicy, EnvVar, LayerPolicy, MergedProfile, PackagesSpec, PolicyItems,
10    ProfileLayer, ProfileSpec, ResolvedProfile, SourceConstraints, SourceSpec,
11    validate_secret_specs,
12};
13use crate::errors::{CompositionError, Result};
14use crate::{deep_merge_yaml, union_extend};
15
16/// Resolution record for conflict reporting.
17#[derive(Debug, Clone)]
18pub struct ConflictResolution {
19    pub resource_id: String,
20    pub resolution_type: ResolutionType,
21    pub winning_source: String,
22    pub details: String,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum ResolutionType {
27    Locked,
28    Required,
29    Override,
30    Rejected,
31    Default,
32}
33
34impl ResolutionType {
35    pub fn label(&self) -> &str {
36        match self {
37            ResolutionType::Locked => "LOCKED",
38            ResolutionType::Required => "REQUIRED",
39            ResolutionType::Override => "OVERRIDE",
40            ResolutionType::Rejected => "REJECTED",
41            ResolutionType::Default => "DEFAULT",
42        }
43    }
44}
45
46/// Input to the composition engine: a source with its resolved profile layers and policy.
47#[derive(Debug)]
48pub struct CompositionInput {
49    pub source_name: String,
50    pub priority: u32,
51    pub policy: ConfigSourcePolicy,
52    pub constraints: SourceConstraints,
53    pub layers: Vec<ProfileLayer>,
54    pub subscription: SubscriptionConfig,
55}
56
57/// Subscription config extracted from the user's cfgd.yaml for this source.
58#[derive(Debug, Clone, Default)]
59pub struct SubscriptionConfig {
60    pub accept_recommended: bool,
61    pub opt_in: Vec<String>,
62    pub overrides: serde_yaml::Value,
63    pub reject: serde_yaml::Value,
64}
65
66impl SubscriptionConfig {
67    pub fn from_spec(spec: &SourceSpec) -> Self {
68        Self {
69            accept_recommended: spec.subscription.accept_recommended,
70            opt_in: spec.subscription.opt_in.clone(),
71            overrides: spec.subscription.overrides.clone(),
72            reject: spec.subscription.reject.clone(),
73        }
74    }
75}
76
77/// Result of composition: merged profile + conflict report.
78#[derive(Debug)]
79pub struct CompositionResult {
80    pub resolved: ResolvedProfile,
81    pub conflicts: Vec<ConflictResolution>,
82    /// Per-source env var sets for template sandboxing.
83    /// Source templates must only access their own env vars + system facts,
84    /// NOT the subscriber's personal env vars.
85    pub source_env: HashMap<String, Vec<EnvVar>>,
86    /// Source name → commit hash, populated by the caller that has access to
87    /// `SourceManager` (not by `compose()` itself, which only sees layers).
88    pub source_commits: HashMap<String, String>,
89}
90
91/// Compose multiple source configs with a local resolved profile.
92/// Local config is always priority 1000. Sources are merged according to policy tiers.
93///
94/// The composition algorithm:
95/// 1. Start with local resolved profile
96/// 2. For each source (sorted by priority ascending):
97///    - Apply locked items unconditionally
98///    - Apply required items (union for packages, source wins for files/env)
99///    - Apply recommended items if accept_recommended && not rejected
100///    - Apply optional items only if opted in
101/// 3. Apply subscriber overrides on top
102/// 4. Validate security constraints
103pub fn compose(local: &ResolvedProfile, sources: &[CompositionInput]) -> Result<CompositionResult> {
104    let mut all_layers: Vec<ProfileLayer> = local.layers.clone();
105    let mut conflicts: Vec<ConflictResolution> = Vec::new();
106    let mut source_env: HashMap<String, Vec<EnvVar>> = HashMap::new();
107
108    // Sort sources by priority ascending (lower priority processed first, higher wins)
109    let mut sorted_sources: Vec<&CompositionInput> = sources.iter().collect();
110    sorted_sources.sort_by(|a, b| {
111        a.priority
112            .cmp(&b.priority)
113            .then(a.source_name.cmp(&b.source_name))
114    });
115
116    for input in &sorted_sources {
117        // Collect source-specific env for template sandboxing
118        let mut env: Vec<EnvVar> = Vec::new();
119        for layer in &input.layers {
120            crate::merge_env(&mut env, &layer.spec.env);
121        }
122        source_env.insert(input.source_name.clone(), env);
123
124        let source_layers = build_source_layers(input, &mut conflicts)?;
125        all_layers.extend(source_layers);
126    }
127
128    // Sort all layers by priority, then merge
129    all_layers.sort_by(|a, b| a.priority.cmp(&b.priority));
130
131    let mut merged = merge_with_policy(&all_layers, &mut conflicts)?;
132
133    // Tag files with their source origin for template sandboxing.
134    // Build a HashMap<target, source> respecting layer priority order (higher priority wins).
135    let mut file_origins: HashMap<PathBuf, String> = HashMap::new();
136    for layer in &all_layers {
137        if layer.source != "local"
138            && let Some(ref files) = layer.spec.files
139        {
140            for managed in &files.managed {
141                // Later (higher-priority) layers overwrite earlier entries
142                file_origins.insert(managed.target.clone(), layer.source.clone());
143            }
144        }
145    }
146    for merged_file in &mut merged.files.managed {
147        if merged_file.origin.is_none()
148            && let Some(source) = file_origins.get(&merged_file.target)
149        {
150            merged_file.origin = Some(source.clone());
151        }
152    }
153
154    // Validate secrets from all sources (catches invalid specs from ConfigSources)
155    validate_secret_specs(&merged.secrets)?;
156
157    Ok(CompositionResult {
158        resolved: ResolvedProfile {
159            layers: all_layers,
160            merged,
161        },
162        conflicts,
163        source_env,
164        source_commits: HashMap::new(),
165    })
166}
167
168/// Build profile layers from a source input, applying policy tier filtering.
169fn build_source_layers(
170    input: &CompositionInput,
171    conflicts: &mut Vec<ConflictResolution>,
172) -> Result<Vec<ProfileLayer>> {
173    let mut layers = Vec::new();
174    let policy = &input.policy;
175
176    // Locked items — unconditional, highest enforcement
177    if has_content(&policy.locked) {
178        let spec = policy_items_to_spec(&policy.locked);
179        layers.push(ProfileLayer {
180            source: input.source_name.clone(),
181            profile_name: format!("{}/locked", input.source_name),
182            priority: u32::MAX,            // Locked always wins
183            policy: LayerPolicy::Required, // Locked uses Required policy
184            spec,
185        });
186
187        record_policy_conflicts(
188            &input.source_name,
189            &policy.locked,
190            ResolutionType::Locked,
191            conflicts,
192        );
193    }
194
195    // Required items — subscriber cannot override or remove
196    if has_content(&policy.required) {
197        let spec = policy_items_to_spec(&policy.required);
198        layers.push(ProfileLayer {
199            source: input.source_name.clone(),
200            profile_name: format!("{}/required", input.source_name),
201            priority: input.priority + 1000, // Required beats normal priority
202            policy: LayerPolicy::Required,
203            spec,
204        });
205
206        record_policy_conflicts(
207            &input.source_name,
208            &policy.required,
209            ResolutionType::Required,
210            conflicts,
211        );
212    }
213
214    // Recommended items — applied if accepted and not rejected
215    if has_content(&policy.recommended) && input.subscription.accept_recommended {
216        let filtered = filter_rejected(&policy.recommended, &input.subscription.reject);
217        if has_content(&filtered) {
218            let spec = policy_items_to_spec(&filtered);
219            layers.push(ProfileLayer {
220                source: input.source_name.clone(),
221                profile_name: format!("{}/recommended", input.source_name),
222                priority: input.priority,
223                policy: LayerPolicy::Recommended,
224                spec,
225            });
226        }
227
228        // Record rejections
229        record_rejections(
230            &input.source_name,
231            &policy.recommended,
232            &input.subscription.reject,
233            conflicts,
234        );
235    }
236
237    // Optional profiles — only if opted in
238    for opt_profile in &input.subscription.opt_in {
239        if policy.optional.profiles.contains(opt_profile) {
240            // Find the profile in the source's layers
241            for source_layer in &input.layers {
242                if source_layer.profile_name == *opt_profile {
243                    layers.push(ProfileLayer {
244                        source: input.source_name.clone(),
245                        profile_name: source_layer.profile_name.clone(),
246                        priority: input.priority,
247                        policy: LayerPolicy::Optional,
248                        spec: source_layer.spec.clone(),
249                    });
250                }
251            }
252        }
253    }
254
255    // Standard source profile layers (non-policy)
256    for source_layer in &input.layers {
257        // Skip if already added via opt-in
258        let already_added = layers
259            .iter()
260            .any(|l| l.profile_name == source_layer.profile_name);
261        if !already_added {
262            layers.push(ProfileLayer {
263                source: input.source_name.clone(),
264                profile_name: source_layer.profile_name.clone(),
265                priority: input.priority,
266                policy: LayerPolicy::Recommended,
267                spec: source_layer.spec.clone(),
268            });
269        }
270    }
271
272    Ok(layers)
273}
274
275/// Track which source provided a file and its policy tier.
276struct FileOwner {
277    source: String,
278    policy: LayerPolicy,
279    priority: u32,
280}
281
282/// Merge layers respecting policy priorities.
283/// This extends the standard merge algorithm with policy-aware conflict resolution.
284fn merge_with_policy(
285    layers: &[ProfileLayer],
286    conflicts: &mut Vec<ConflictResolution>,
287) -> std::result::Result<MergedProfile, CompositionError> {
288    let mut merged = MergedProfile::default();
289    // Track file ownership for conflict detection
290    let mut file_owners: HashMap<std::path::PathBuf, FileOwner> = HashMap::new();
291
292    for layer in layers {
293        let spec = &layer.spec;
294
295        // Env: later overrides earlier by name (respecting priority ordering)
296        crate::merge_env(&mut merged.env, &spec.env);
297
298        // Aliases: later overrides earlier by name
299        crate::merge_aliases(&mut merged.aliases, &spec.aliases);
300
301        // Packages: union
302        if let Some(ref pkgs) = spec.packages {
303            merge_packages(&mut merged.packages, pkgs);
304        }
305
306        // Files: overlay with conflict and required-resource checking
307        if let Some(ref files) = spec.files {
308            for managed in &files.managed {
309                // Check Required-tier protection (bidirectional):
310                // 1. If a Required source already owns this file, no other source can override it.
311                // 2. If *this* layer is Required and another source already placed a file here, error.
312                if let Some(owner) = file_owners.get(&managed.target) {
313                    let cross_source = layer.source != owner.source;
314                    if cross_source
315                        && (owner.policy == LayerPolicy::Required
316                            || layer.policy == LayerPolicy::Required)
317                    {
318                        return Err(CompositionError::RequiredResource {
319                            source_name: if owner.policy == LayerPolicy::Required {
320                                layer.source.clone()
321                            } else {
322                                owner.source.clone()
323                            },
324                            resource: managed.target.to_string_lossy().to_string(),
325                        });
326                    }
327                    // Detect conflict between two non-local sources
328                    if owner.source != "local"
329                        && layer.source != "local"
330                        && owner.source != layer.source
331                    {
332                        // Same priority = unresolvable (no deterministic winner)
333                        if layer.priority == owner.priority {
334                            return Err(CompositionError::UnresolvableConflict {
335                                resource: managed.target.to_string_lossy().to_string(),
336                                source_names: vec![owner.source.clone(), layer.source.clone()],
337                            });
338                        }
339                        // Different priorities: higher priority wins, record override
340                        conflicts.push(ConflictResolution {
341                            resource_id: managed.target.to_string_lossy().to_string(),
342                            resolution_type: ResolutionType::Override,
343                            winning_source: layer.source.clone(),
344                            details: format!(
345                                "file '{}' overridden: {} (priority {}) replaces {}",
346                                managed.target.display(),
347                                layer.source,
348                                layer.priority,
349                                owner.source
350                            ),
351                        });
352                    }
353                }
354
355                if let Some(existing) = merged
356                    .files
357                    .managed
358                    .iter_mut()
359                    .find(|m| m.target == managed.target)
360                {
361                    existing.source = managed.source.clone();
362                } else {
363                    merged.files.managed.push(managed.clone());
364                }
365
366                file_owners.insert(
367                    managed.target.clone(),
368                    FileOwner {
369                        source: layer.source.clone(),
370                        policy: layer.policy.clone(),
371                        priority: layer.priority,
372                    },
373                );
374            }
375            for (path, mode) in &files.permissions {
376                merged.files.permissions.insert(path.clone(), mode.clone());
377            }
378        }
379
380        // System: deep merge at leaf level
381        for (key, value) in &spec.system {
382            deep_merge_yaml(
383                merged
384                    .system
385                    .entry(key.clone())
386                    .or_insert(serde_yaml::Value::Null),
387                value,
388            );
389        }
390
391        // Secrets: append, deduplicate by source
392        for secret in &spec.secrets {
393            if let Some(existing) = merged
394                .secrets
395                .iter_mut()
396                .find(|s| s.source == secret.source)
397            {
398                *existing = secret.clone();
399            } else {
400                merged.secrets.push(secret.clone());
401            }
402        }
403
404        // Scripts: append in order
405        if let Some(ref scripts) = spec.scripts {
406            merged.scripts.pre_apply.extend(scripts.pre_apply.clone());
407            merged.scripts.post_apply.extend(scripts.post_apply.clone());
408            merged
409                .scripts
410                .pre_reconcile
411                .extend(scripts.pre_reconcile.clone());
412            merged
413                .scripts
414                .post_reconcile
415                .extend(scripts.post_reconcile.clone());
416            merged.scripts.on_drift.extend(scripts.on_drift.clone());
417            merged.scripts.on_change.extend(scripts.on_change.clone());
418        }
419
420        // Modules: union (deduplicated)
421        union_extend(&mut merged.modules, &spec.modules);
422    }
423
424    Ok(merged)
425}
426
427/// Validate security constraints for a source's contribution to the composed profile.
428pub fn validate_constraints(
429    source_name: &str,
430    constraints: &SourceConstraints,
431    spec: &ProfileSpec,
432) -> Result<()> {
433    // Check script constraint
434    if constraints.no_scripts
435        && let Some(ref scripts) = spec.scripts
436        && (!scripts.pre_apply.is_empty()
437            || !scripts.post_apply.is_empty()
438            || !scripts.pre_reconcile.is_empty()
439            || !scripts.post_reconcile.is_empty()
440            || !scripts.on_drift.is_empty()
441            || !scripts.on_change.is_empty())
442    {
443        return Err(CompositionError::ScriptsNotAllowed {
444            source_name: source_name.to_string(),
445        }
446        .into());
447    }
448
449    // Check system change constraint
450    if !constraints.allow_system_changes && !spec.system.is_empty() {
451        let first_key = spec.system.keys().next().cloned().unwrap_or_default();
452        return Err(CompositionError::SystemChangeNotAllowed {
453            source_name: source_name.to_string(),
454            setting: first_key,
455        }
456        .into());
457    }
458
459    // Check path containment
460    if !constraints.allowed_target_paths.is_empty()
461        && let Some(ref files) = spec.files
462    {
463        for managed in &files.managed {
464            let target_str = managed.target.to_string_lossy();
465            if !path_matches_any(&target_str, &constraints.allowed_target_paths) {
466                return Err(CompositionError::PathNotAllowed {
467                    source_name: source_name.to_string(),
468                    path: target_str.to_string(),
469                }
470                .into());
471            }
472        }
473    }
474
475    // Check encryption.requiredTargets: every file whose target matches a required-encryption
476    // glob must have an encryption block, and if the constraint specifies a backend, it must
477    // match the file's encryption backend.
478    if let Some(ref enc_constraint) = constraints.encryption
479        && !enc_constraint.required_targets.is_empty()
480        && let Some(ref files) = spec.files
481    {
482        for managed in &files.managed {
483            let target_str = managed.target.to_string_lossy();
484            if let Some(matched_pattern) =
485                find_matching_pattern(&target_str, &enc_constraint.required_targets)
486            {
487                match managed.encryption.as_ref() {
488                    None => {
489                        return Err(CompositionError::EncryptionRequired {
490                            source_name: source_name.to_string(),
491                            path: target_str.to_string(),
492                            pattern: matched_pattern,
493                        }
494                        .into());
495                    }
496                    Some(enc_spec) => {
497                        if let Some(ref required_backend) = enc_constraint.backend
498                            && enc_spec.backend != *required_backend
499                        {
500                            return Err(CompositionError::EncryptionBackendMismatch {
501                                source_name: source_name.to_string(),
502                                path: target_str.to_string(),
503                                pattern: matched_pattern.clone(),
504                                actual_backend: enc_spec.backend.clone(),
505                                required_backend: required_backend.clone(),
506                            }
507                            .into());
508                        }
509                        if let Some(ref required_mode) = enc_constraint.mode
510                            && enc_spec.mode != *required_mode
511                        {
512                            return Err(CompositionError::EncryptionModeMismatch {
513                                source_name: source_name.to_string(),
514                                path: target_str.to_string(),
515                                pattern: matched_pattern,
516                                actual_mode: format!("{:?}", enc_spec.mode),
517                                required_mode: format!("{:?}", required_mode),
518                            }
519                            .into());
520                        }
521                    }
522                }
523            }
524        }
525    }
526
527    Ok(())
528}
529
530/// Check if a path matches any of the allowed patterns.
531/// Supports glob patterns and prefix matching.
532fn path_matches_any(path: &str, allowed: &[String]) -> bool {
533    find_matching_pattern(path, allowed).is_some()
534}
535
536/// Return the first pattern from `patterns` that matches `path`, or `None`.
537/// Uses the same matching logic as `path_matches_any`.
538fn find_matching_pattern(path: &str, patterns: &[String]) -> Option<String> {
539    for pattern in patterns {
540        if let Ok(glob_pattern) = glob::Pattern::new(pattern)
541            && glob_pattern.matches(path)
542        {
543            return Some(pattern.clone());
544        }
545        if pattern.ends_with('/') && path.starts_with(pattern.as_str()) {
546            return Some(pattern.clone());
547        }
548        if path == pattern {
549            return Some(pattern.clone());
550        }
551    }
552    None
553}
554
555/// Check if a subscriber is trying to override a locked resource.
556pub fn check_locked_violations(
557    source_name: &str,
558    locked: &PolicyItems,
559    local_merged: &MergedProfile,
560) -> Result<()> {
561    // Check locked files — local cannot override these targets
562    for locked_file in &locked.files {
563        for local_file in &local_merged.files.managed {
564            if local_file.target == locked_file.target && local_file.source != locked_file.source {
565                return Err(CompositionError::LockedResource {
566                    source_name: source_name.to_string(),
567                    resource: locked_file.target.to_string_lossy().to_string(),
568                }
569                .into());
570            }
571        }
572    }
573
574    Ok(())
575}
576
577// --- Helper functions ---
578
579fn has_content(items: &PolicyItems) -> bool {
580    items.packages.is_some()
581        || !items.files.is_empty()
582        || !items.env.is_empty()
583        || !items.aliases.is_empty()
584        || !items.system.is_empty()
585        || !items.profiles.is_empty()
586        || !items.modules.is_empty()
587        || !items.secrets.is_empty()
588}
589
590fn policy_items_to_spec(items: &PolicyItems) -> ProfileSpec {
591    ProfileSpec {
592        packages: items.packages.clone(),
593        files: if items.files.is_empty() {
594            None
595        } else {
596            Some(crate::config::FilesSpec {
597                managed: items.files.clone(),
598                permissions: HashMap::new(),
599            })
600        },
601        env: items.env.clone(),
602        aliases: items.aliases.clone(),
603        system: items.system.clone(),
604        modules: items.modules.clone(),
605        secrets: items.secrets.clone(),
606        ..Default::default()
607    }
608}
609
610/// Merge packages from `source` into `target`, unioning lists and applying
611/// later-wins for scalar fields (file paths, remotes, custom manager commands).
612pub fn merge_packages(target: &mut PackagesSpec, source: &PackagesSpec) {
613    if let Some(ref brew) = source.brew {
614        let target_brew = target.brew.get_or_insert_with(Default::default);
615        if brew.file.is_some() {
616            target_brew.file = brew.file.clone();
617        }
618        union_extend(&mut target_brew.taps, &brew.taps);
619        union_extend(&mut target_brew.formulae, &brew.formulae);
620        union_extend(&mut target_brew.casks, &brew.casks);
621    }
622    if let Some(ref apt) = source.apt {
623        let target_apt = target.apt.get_or_insert_with(Default::default);
624        if apt.file.is_some() {
625            target_apt.file = apt.file.clone();
626        }
627        union_extend(&mut target_apt.packages, &apt.packages);
628    }
629    if let Some(ref cargo) = source.cargo {
630        let target_cargo = target.cargo.get_or_insert_with(Default::default);
631        if cargo.file.is_some() {
632            target_cargo.file = cargo.file.clone();
633        }
634        union_extend(&mut target_cargo.packages, &cargo.packages);
635    }
636    if let Some(ref npm) = source.npm {
637        let target_npm = target.npm.get_or_insert_with(Default::default);
638        if npm.file.is_some() {
639            target_npm.file = npm.file.clone();
640        }
641        union_extend(&mut target_npm.global, &npm.global);
642    }
643    union_extend(&mut target.pipx, &source.pipx);
644    union_extend(&mut target.dnf, &source.dnf);
645    union_extend(&mut target.apk, &source.apk);
646    union_extend(&mut target.pacman, &source.pacman);
647    union_extend(&mut target.zypper, &source.zypper);
648    union_extend(&mut target.yum, &source.yum);
649    union_extend(&mut target.pkg, &source.pkg);
650    if let Some(ref snap) = source.snap {
651        let target_snap = target.snap.get_or_insert_with(Default::default);
652        union_extend(&mut target_snap.packages, &snap.packages);
653        union_extend(&mut target_snap.classic, &snap.classic);
654    }
655    if let Some(ref flatpak) = source.flatpak {
656        let target_flatpak = target.flatpak.get_or_insert_with(Default::default);
657        union_extend(&mut target_flatpak.packages, &flatpak.packages);
658        if flatpak.remote.is_some() {
659            target_flatpak.remote = flatpak.remote.clone();
660        }
661    }
662    union_extend(&mut target.nix, &source.nix);
663    union_extend(&mut target.go, &source.go);
664    union_extend(&mut target.winget, &source.winget);
665    union_extend(&mut target.chocolatey, &source.chocolatey);
666    union_extend(&mut target.scoop, &source.scoop);
667    // Custom managers: merge by name, union packages
668    for custom in &source.custom {
669        if let Some(existing) = target.custom.iter_mut().find(|c| c.name == custom.name) {
670            existing.check = custom.check.clone();
671            existing.list_installed = custom.list_installed.clone();
672            existing.install = custom.install.clone();
673            existing.uninstall = custom.uninstall.clone();
674            if custom.update.is_some() {
675                existing.update = custom.update.clone();
676            }
677            union_extend(&mut existing.packages, &custom.packages);
678        } else {
679            target.custom.push(custom.clone());
680        }
681    }
682}
683
684/// Filter rejected items from recommended policy items.
685fn filter_rejected(recommended: &PolicyItems, reject: &serde_yaml::Value) -> PolicyItems {
686    if reject.is_null() {
687        return recommended.clone();
688    }
689
690    let mut filtered = recommended.clone();
691
692    // Filter rejected packages
693    if let Some(reject_map) = reject.as_mapping() {
694        if let Some(pkg_val) = reject_map.get(serde_yaml::Value::String("packages".into()))
695            && let Some(ref mut pkgs) = filtered.packages
696        {
697            filter_rejected_packages(pkgs, pkg_val);
698        }
699
700        // Filter rejected env
701        if let Some(env_val) = reject_map.get(serde_yaml::Value::String("env".into()))
702            && let Some(env_map) = env_val.as_mapping()
703        {
704            for (key, _) in env_map {
705                if let Some(key_str) = key.as_str() {
706                    filtered.env.retain(|e| e.name != key_str);
707                }
708            }
709        }
710
711        // Filter rejected aliases
712        if let Some(alias_val) = reject_map.get(serde_yaml::Value::String("aliases".into()))
713            && let Some(alias_map) = alias_val.as_mapping()
714        {
715            for (key, _) in alias_map {
716                if let Some(key_str) = key.as_str() {
717                    filtered.aliases.retain(|a| a.name != key_str);
718                }
719            }
720        }
721
722        // Filter rejected modules
723        if let Some(mod_val) = reject_map.get(serde_yaml::Value::String("modules".into()))
724            && let Some(mod_seq) = mod_val.as_sequence()
725        {
726            let rejected: Vec<String> = mod_seq
727                .iter()
728                .filter_map(|v| v.as_str().map(|s| s.to_string()))
729                .collect();
730            filtered.modules.retain(|m| !rejected.contains(m));
731        }
732    }
733
734    filtered
735}
736
737fn filter_rejected_packages(packages: &mut PackagesSpec, reject: &serde_yaml::Value) {
738    if let Some(reject_map) = reject.as_mapping() {
739        if let Some(brew_val) = reject_map.get(serde_yaml::Value::String("brew".into()))
740            && let Some(ref mut brew) = packages.brew
741            && let Some(brew_map) = brew_val.as_mapping()
742        {
743            remove_rejected_list(
744                &mut brew.formulae,
745                brew_map.get(serde_yaml::Value::String("formulae".into())),
746            );
747            remove_rejected_list(
748                &mut brew.casks,
749                brew_map.get(serde_yaml::Value::String("casks".into())),
750            );
751            remove_rejected_list(
752                &mut brew.taps,
753                brew_map.get(serde_yaml::Value::String("taps".into())),
754            );
755        }
756        // Similar for other package managers
757        remove_rejected_from_mapping(reject_map, "apt", |val| {
758            if let Some(ref mut apt) = packages.apt
759                && let Some(apt_map) = val.as_mapping()
760            {
761                remove_rejected_list(
762                    &mut apt.packages,
763                    apt_map.get(serde_yaml::Value::String("packages".into())),
764                );
765            }
766        });
767        if let Some(ref mut cargo) = packages.cargo {
768            remove_rejected_from_seq(reject_map, "cargo", &mut cargo.packages);
769        }
770        remove_rejected_from_seq(reject_map, "pipx", &mut packages.pipx);
771        remove_rejected_from_seq(reject_map, "dnf", &mut packages.dnf);
772    }
773}
774
775fn remove_rejected_list(target: &mut Vec<String>, reject: Option<&serde_yaml::Value>) {
776    if let Some(val) = reject
777        && let Some(seq) = val.as_sequence()
778    {
779        let rejected: Vec<String> = seq
780            .iter()
781            .filter_map(|v| v.as_str().map(|s| s.to_string()))
782            .collect();
783        target.retain(|item| !rejected.contains(item));
784    }
785}
786
787fn remove_rejected_from_mapping(
788    reject_map: &serde_yaml::Mapping,
789    key: &str,
790    f: impl FnOnce(&serde_yaml::Value),
791) {
792    if let Some(val) = reject_map.get(serde_yaml::Value::String(key.into())) {
793        f(val);
794    }
795}
796
797fn remove_rejected_from_seq(reject_map: &serde_yaml::Mapping, key: &str, target: &mut Vec<String>) {
798    if let Some(val) = reject_map.get(serde_yaml::Value::String(key.into())) {
799        remove_rejected_list(target, Some(val));
800    }
801}
802
803fn record_policy_conflicts(
804    source_name: &str,
805    items: &PolicyItems,
806    resolution_type: ResolutionType,
807    conflicts: &mut Vec<ConflictResolution>,
808) {
809    // Record file conflicts
810    for file in &items.files {
811        conflicts.push(ConflictResolution {
812            resource_id: file.target.to_string_lossy().to_string(),
813            resolution_type: resolution_type.clone(),
814            winning_source: source_name.to_string(),
815            details: format!(
816                "{} {} <- {}",
817                resolution_type.label(),
818                file.target.display(),
819                source_name
820            ),
821        });
822    }
823
824    // Record package conflicts
825    if let Some(ref pkgs) = items.packages {
826        let all_packages = collect_package_names(pkgs);
827        for pkg in all_packages {
828            conflicts.push(ConflictResolution {
829                resource_id: pkg.clone(),
830                resolution_type: resolution_type.clone(),
831                winning_source: source_name.to_string(),
832                details: format!("{} {} <- {}", resolution_type.label(), pkg, source_name),
833            });
834        }
835    }
836
837    // Record env conflicts
838    for ev in &items.env {
839        conflicts.push(ConflictResolution {
840            resource_id: format!("env:{}", ev.name),
841            resolution_type: resolution_type.clone(),
842            winning_source: source_name.to_string(),
843            details: format!("{} {} <- {}", resolution_type.label(), ev.name, source_name),
844        });
845    }
846
847    // Record alias conflicts
848    for alias in &items.aliases {
849        conflicts.push(ConflictResolution {
850            resource_id: format!("alias:{}", alias.name),
851            resolution_type: resolution_type.clone(),
852            winning_source: source_name.to_string(),
853            details: format!(
854                "{} {} <- {}",
855                resolution_type.label(),
856                alias.name,
857                source_name
858            ),
859        });
860    }
861
862    // Record module conflicts
863    for module in &items.modules {
864        conflicts.push(ConflictResolution {
865            resource_id: format!("module:{}", module),
866            resolution_type: resolution_type.clone(),
867            winning_source: source_name.to_string(),
868            details: format!(
869                "{} module {} <- {}",
870                resolution_type.label(),
871                module,
872                source_name
873            ),
874        });
875    }
876
877    // Record secret conflicts
878    for secret in &items.secrets {
879        conflicts.push(ConflictResolution {
880            resource_id: format!("secret:{}", secret.source),
881            resolution_type: resolution_type.clone(),
882            winning_source: source_name.to_string(),
883            details: format!(
884                "{} {} <- {}",
885                resolution_type.label(),
886                secret.source,
887                source_name
888            ),
889        });
890    }
891}
892
893fn record_rejections(
894    source_name: &str,
895    _recommended: &PolicyItems,
896    reject: &serde_yaml::Value,
897    conflicts: &mut Vec<ConflictResolution>,
898) {
899    if reject.is_null() {
900        return;
901    }
902
903    if let Some(reject_map) = reject.as_mapping()
904        && let Some(pkg_val) = reject_map.get(serde_yaml::Value::String("packages".into()))
905        && let Some(pkg_map) = pkg_val.as_mapping()
906    {
907        for (_, list) in pkg_map {
908            if let Some(seq) = list.as_sequence() {
909                for item in seq {
910                    if let Some(name) = item.as_str() {
911                        conflicts.push(ConflictResolution {
912                            resource_id: name.to_string(),
913                            resolution_type: ResolutionType::Rejected,
914                            winning_source: "local".to_string(),
915                            details: format!(
916                                "REJECTED {} <- local rejected {} recommendation",
917                                name, source_name
918                            ),
919                        });
920                    }
921                }
922            }
923            // Handle mapping-style rejection (e.g. brew: {formulae: [x]})
924            if let Some(sub_map) = list.as_mapping() {
925                for (_, sub_list) in sub_map {
926                    if let Some(seq) = sub_list.as_sequence() {
927                        for item in seq {
928                            if let Some(name) = item.as_str() {
929                                conflicts.push(ConflictResolution {
930                                    resource_id: name.to_string(),
931                                    resolution_type: ResolutionType::Rejected,
932                                    winning_source: "local".to_string(),
933                                    details: format!(
934                                        "REJECTED {} <- local rejected {} recommendation",
935                                        name, source_name
936                                    ),
937                                });
938                            }
939                        }
940                    }
941                }
942            }
943        }
944    }
945
946    // Record rejected modules
947    if let Some(reject_map) = reject.as_mapping()
948        && let Some(mod_val) = reject_map.get(serde_yaml::Value::String("modules".into()))
949        && let Some(mod_seq) = mod_val.as_sequence()
950    {
951        for item in mod_seq {
952            if let Some(name) = item.as_str() {
953                conflicts.push(ConflictResolution {
954                    resource_id: format!("module:{}", name),
955                    resolution_type: ResolutionType::Rejected,
956                    winning_source: "local".to_string(),
957                    details: format!(
958                        "REJECTED module {} <- local rejected {} recommendation",
959                        name, source_name
960                    ),
961                });
962            }
963        }
964    }
965}
966
967fn collect_package_names(pkgs: &PackagesSpec) -> Vec<String> {
968    let mut names = Vec::new();
969    if let Some(ref brew) = pkgs.brew {
970        for f in &brew.formulae {
971            names.push(format!("{} (brew)", f));
972        }
973        for c in &brew.casks {
974            names.push(format!("{} (brew cask)", c));
975        }
976    }
977    if let Some(ref apt) = pkgs.apt {
978        for p in &apt.packages {
979            names.push(format!("{} (apt)", p));
980        }
981    }
982    if let Some(ref cargo) = pkgs.cargo {
983        for p in &cargo.packages {
984            names.push(format!("{} (cargo)", p));
985        }
986    }
987    if let Some(ref npm) = pkgs.npm {
988        for p in &npm.global {
989            names.push(format!("{} (npm)", p));
990        }
991    }
992    for p in &pkgs.pipx {
993        names.push(format!("{} (pipx)", p));
994    }
995    for p in &pkgs.dnf {
996        names.push(format!("{} (dnf)", p));
997    }
998    names
999}
1000
1001/// Describes a permission-expanding change detected between old and new composition results.
1002#[derive(Debug, Clone)]
1003pub struct PermissionChange {
1004    pub source: String,
1005    pub description: String,
1006}
1007
1008/// Compare old vs new composition results to detect permission-expanding changes.
1009/// Returns a list of changes that require explicit user consent.
1010pub fn detect_permission_changes(
1011    old_sources: &[CompositionInput],
1012    new_sources: &[CompositionInput],
1013) -> Vec<PermissionChange> {
1014    let mut changes = Vec::new();
1015
1016    let old_map: HashMap<&str, &CompositionInput> = old_sources
1017        .iter()
1018        .map(|s| (s.source_name.as_str(), s))
1019        .collect();
1020
1021    for new_src in new_sources {
1022        let name = &new_src.source_name;
1023        match old_map.get(name.as_str()) {
1024            None => {
1025                changes.push(PermissionChange {
1026                    source: name.clone(),
1027                    description: "New source added".to_string(),
1028                });
1029            }
1030            Some(old_src) => {
1031                // Check if locked items increased
1032                let old_locked = count_policy_tier_items(&old_src.policy.locked);
1033                let new_locked = count_policy_tier_items(&new_src.policy.locked);
1034                if new_locked > old_locked {
1035                    changes.push(PermissionChange {
1036                        source: name.clone(),
1037                        description: format!(
1038                            "Locked items increased from {} to {}",
1039                            old_locked, new_locked
1040                        ),
1041                    });
1042                }
1043
1044                // Check if required items increased
1045                let old_required = count_policy_tier_items(&old_src.policy.required);
1046                let new_required = count_policy_tier_items(&new_src.policy.required);
1047                if new_required > old_required {
1048                    changes.push(PermissionChange {
1049                        source: name.clone(),
1050                        description: format!(
1051                            "Required items increased from {} to {}",
1052                            old_required, new_required
1053                        ),
1054                    });
1055                }
1056
1057                // Check if constraints relaxed (scripts enabled, paths expanded)
1058                let old_c = &old_src.constraints;
1059                let new_c = &new_src.constraints;
1060                if old_c.no_scripts && !new_c.no_scripts {
1061                    changes.push(PermissionChange {
1062                        source: name.clone(),
1063                        description: "Scripts have been enabled".to_string(),
1064                    });
1065                }
1066                if new_c.allowed_target_paths.len() > old_c.allowed_target_paths.len() {
1067                    changes.push(PermissionChange {
1068                        source: name.clone(),
1069                        description: "Allowed target paths expanded".to_string(),
1070                    });
1071                }
1072            }
1073        }
1074    }
1075
1076    changes
1077}
1078
1079fn count_policy_tier_items(items: &PolicyItems) -> usize {
1080    let mut count = 0;
1081    if let Some(ref pkgs) = items.packages {
1082        if let Some(ref brew) = pkgs.brew {
1083            count += brew.formulae.len() + brew.casks.len() + brew.taps.len();
1084        }
1085        if let Some(ref apt) = pkgs.apt {
1086            count += apt.packages.len();
1087        }
1088        if let Some(ref cargo) = pkgs.cargo {
1089            count += cargo.packages.len();
1090        }
1091        count += pkgs.pipx.len() + pkgs.dnf.len();
1092        if let Some(ref npm) = pkgs.npm {
1093            count += npm.global.len();
1094        }
1095    }
1096    count += items.files.len();
1097    count += items.env.len();
1098    count += items.aliases.len();
1099    count += items.system.len();
1100    count += items.modules.len();
1101    count
1102}
1103
1104#[cfg(test)]
1105mod tests {
1106    use super::*;
1107    use crate::config::*;
1108
1109    fn make_local_profile() -> ResolvedProfile {
1110        ResolvedProfile {
1111            layers: vec![ProfileLayer {
1112                source: "local".into(),
1113                profile_name: "default".into(),
1114                priority: 1000,
1115                policy: LayerPolicy::Local,
1116                spec: ProfileSpec {
1117                    env: vec![EnvVar {
1118                        name: "editor".into(),
1119                        value: "vim".into(),
1120                    }],
1121                    packages: Some(PackagesSpec {
1122                        cargo: Some(CargoSpec {
1123                            file: None,
1124                            packages: vec!["bat".into()],
1125                        }),
1126                        ..Default::default()
1127                    }),
1128                    ..Default::default()
1129                },
1130            }],
1131            merged: MergedProfile {
1132                env: vec![EnvVar {
1133                    name: "editor".into(),
1134                    value: "vim".into(),
1135                }],
1136                packages: PackagesSpec {
1137                    cargo: Some(CargoSpec {
1138                        file: None,
1139                        packages: vec!["bat".into()],
1140                    }),
1141                    ..Default::default()
1142                },
1143                ..Default::default()
1144            },
1145        }
1146    }
1147
1148    fn make_source_input(name: &str, priority: u32) -> CompositionInput {
1149        CompositionInput {
1150            source_name: name.into(),
1151            priority,
1152            policy: ConfigSourcePolicy {
1153                required: PolicyItems {
1154                    packages: Some(PackagesSpec {
1155                        brew: Some(BrewSpec {
1156                            formulae: vec!["git-secrets".into()],
1157                            ..Default::default()
1158                        }),
1159                        ..Default::default()
1160                    }),
1161                    ..Default::default()
1162                },
1163                recommended: PolicyItems {
1164                    packages: Some(PackagesSpec {
1165                        brew: Some(BrewSpec {
1166                            formulae: vec!["k9s".into(), "stern".into()],
1167                            ..Default::default()
1168                        }),
1169                        ..Default::default()
1170                    }),
1171                    env: vec![EnvVar {
1172                        name: "EDITOR".into(),
1173                        value: "code --wait".into(),
1174                    }],
1175                    ..Default::default()
1176                },
1177                locked: PolicyItems {
1178                    files: vec![ManagedFileSpec {
1179                        source: "security/policy.yaml".into(),
1180                        target: "~/.config/company/security-policy.yaml".into(),
1181                        strategy: None,
1182                        private: false,
1183                        origin: None,
1184                        encryption: None,
1185                        permissions: None,
1186                    }],
1187                    ..Default::default()
1188                },
1189                ..Default::default()
1190            },
1191            constraints: SourceConstraints::default(),
1192            layers: vec![],
1193            subscription: SubscriptionConfig {
1194                accept_recommended: true,
1195                ..Default::default()
1196            },
1197        }
1198    }
1199
1200    #[test]
1201    fn compose_with_no_sources() {
1202        let local = make_local_profile();
1203        let result = compose(&local, &[]).unwrap();
1204        assert_eq!(
1205            result
1206                .resolved
1207                .merged
1208                .env
1209                .iter()
1210                .find(|e| e.name == "editor")
1211                .map(|e| &e.value),
1212            Some(&"vim".to_string())
1213        );
1214        assert!(result.conflicts.is_empty());
1215    }
1216
1217    #[test]
1218    fn compose_applies_required_packages() {
1219        let local = make_local_profile();
1220        let input = make_source_input("acme", 500);
1221        let result = compose(&local, &[input]).unwrap();
1222
1223        let brew = result.resolved.merged.packages.brew.as_ref().unwrap();
1224        assert!(brew.formulae.contains(&"git-secrets".into()));
1225    }
1226
1227    #[test]
1228    fn compose_applies_recommended_when_accepted() {
1229        let local = make_local_profile();
1230        let input = make_source_input("acme", 500);
1231        let result = compose(&local, &[input]).unwrap();
1232
1233        let brew = result.resolved.merged.packages.brew.as_ref().unwrap();
1234        assert!(brew.formulae.contains(&"k9s".into()));
1235        assert!(brew.formulae.contains(&"stern".into()));
1236    }
1237
1238    #[test]
1239    fn compose_skips_recommended_when_not_accepted() {
1240        let local = make_local_profile();
1241        let mut input = make_source_input("acme", 500);
1242        input.subscription.accept_recommended = false;
1243        let result = compose(&local, &[input]).unwrap();
1244
1245        // k9s should NOT be present (recommended not accepted)
1246        let has_k9s = result
1247            .resolved
1248            .merged
1249            .packages
1250            .brew
1251            .as_ref()
1252            .map(|b| b.formulae.contains(&"k9s".into()))
1253            .unwrap_or(false);
1254        assert!(!has_k9s);
1255    }
1256
1257    #[test]
1258    fn compose_rejects_recommended_packages() {
1259        let local = make_local_profile();
1260        let mut input = make_source_input("acme", 500);
1261
1262        // Reject kubectx from recommended
1263        let reject_yaml: serde_yaml::Value = serde_yaml::from_str(
1264            r#"
1265            packages:
1266              brew:
1267                formulae:
1268                  - stern
1269            "#,
1270        )
1271        .unwrap();
1272        input.subscription.reject = reject_yaml;
1273
1274        let result = compose(&local, &[input]).unwrap();
1275        let brew = result.resolved.merged.packages.brew.as_ref().unwrap();
1276        assert!(brew.formulae.contains(&"k9s".into()));
1277        assert!(!brew.formulae.contains(&"stern".into()));
1278    }
1279
1280    #[test]
1281    fn compose_records_locked_conflicts() {
1282        let local = make_local_profile();
1283        let input = make_source_input("acme", 500);
1284        let result = compose(&local, &[input]).unwrap();
1285
1286        let locked_conflicts: Vec<_> = result
1287            .conflicts
1288            .iter()
1289            .filter(|c| c.resolution_type == ResolutionType::Locked)
1290            .collect();
1291        assert!(!locked_conflicts.is_empty());
1292    }
1293
1294    #[test]
1295    fn compose_is_deterministic() {
1296        let local = make_local_profile();
1297        let input1 = make_source_input("acme", 500);
1298        let input2 = make_source_input("acme", 500);
1299
1300        let result1 = compose(&local, &[input1]).unwrap();
1301        let result2 = compose(&local, &[input2]).unwrap();
1302
1303        // Same packages in same order
1304        assert_eq!(
1305            result1.resolved.merged.packages.cargo,
1306            result2.resolved.merged.packages.cargo
1307        );
1308    }
1309
1310    #[test]
1311    fn validate_constraints_scripts_blocked() {
1312        let constraints = SourceConstraints {
1313            no_scripts: true,
1314            ..Default::default()
1315        };
1316        let spec = ProfileSpec {
1317            scripts: Some(ScriptSpec {
1318                pre_reconcile: vec![ScriptEntry::Simple("setup.sh".to_string())],
1319                ..Default::default()
1320            }),
1321            ..Default::default()
1322        };
1323        let err = validate_constraints("acme", &constraints, &spec).unwrap_err();
1324        let msg = err.to_string();
1325        assert!(
1326            msg.contains("acme") && msg.contains("scripts"),
1327            "error should mention source name and scripts: {msg}"
1328        );
1329    }
1330
1331    #[test]
1332    fn validate_constraints_scripts_blocked_all_hooks() {
1333        // Verify ALL script hook types are checked, not just pre_reconcile
1334        let constraints = SourceConstraints {
1335            no_scripts: true,
1336            ..Default::default()
1337        };
1338        for (label, spec) in [
1339            (
1340                "post_apply",
1341                ProfileSpec {
1342                    scripts: Some(ScriptSpec {
1343                        post_apply: vec![ScriptEntry::Simple("hook.sh".into())],
1344                        ..Default::default()
1345                    }),
1346                    ..Default::default()
1347                },
1348            ),
1349            (
1350                "on_drift",
1351                ProfileSpec {
1352                    scripts: Some(ScriptSpec {
1353                        on_drift: vec![ScriptEntry::Simple("drift.sh".into())],
1354                        ..Default::default()
1355                    }),
1356                    ..Default::default()
1357                },
1358            ),
1359            (
1360                "on_change",
1361                ProfileSpec {
1362                    scripts: Some(ScriptSpec {
1363                        on_change: vec![ScriptEntry::Simple("change.sh".into())],
1364                        ..Default::default()
1365                    }),
1366                    ..Default::default()
1367                },
1368            ),
1369        ] {
1370            assert!(
1371                validate_constraints("src", &constraints, &spec).is_err(),
1372                "no_scripts should block {label} hooks"
1373            );
1374        }
1375    }
1376
1377    #[test]
1378    fn validate_constraints_scripts_empty_allowed() {
1379        // no_scripts=true but spec has Scripts with all empty vecs — should pass
1380        let constraints = SourceConstraints {
1381            no_scripts: true,
1382            ..Default::default()
1383        };
1384        let spec = ProfileSpec {
1385            scripts: Some(ScriptSpec::default()),
1386            ..Default::default()
1387        };
1388        assert!(
1389            validate_constraints("acme", &constraints, &spec).is_ok(),
1390            "no_scripts with empty script lists should pass"
1391        );
1392    }
1393
1394    #[test]
1395    fn validate_constraints_scripts_allowed() {
1396        let constraints = SourceConstraints {
1397            no_scripts: false,
1398            ..Default::default()
1399        };
1400        let spec = ProfileSpec {
1401            scripts: Some(ScriptSpec {
1402                pre_reconcile: vec![ScriptEntry::Simple("setup.sh".to_string())],
1403                ..Default::default()
1404            }),
1405            ..Default::default()
1406        };
1407        assert!(validate_constraints("acme", &constraints, &spec).is_ok());
1408    }
1409
1410    #[test]
1411    fn validate_constraints_path_containment() {
1412        let constraints = SourceConstraints {
1413            allowed_target_paths: vec!["~/.config/acme/".into()],
1414            ..Default::default()
1415        };
1416        let spec = ProfileSpec {
1417            files: Some(FilesSpec {
1418                managed: vec![ManagedFileSpec {
1419                    source: "evil.sh".into(),
1420                    target: "/etc/sudoers".into(),
1421                    strategy: None,
1422                    private: false,
1423                    origin: None,
1424                    encryption: None,
1425                    permissions: None,
1426                }],
1427                ..Default::default()
1428            }),
1429            ..Default::default()
1430        };
1431        let err = validate_constraints("acme", &constraints, &spec).unwrap_err();
1432        let msg = err.to_string();
1433        assert!(
1434            msg.contains("/etc/sudoers") && msg.contains("acme"),
1435            "error should mention the offending path and source: {msg}"
1436        );
1437    }
1438
1439    #[test]
1440    fn validate_constraints_path_allowed() {
1441        let constraints = SourceConstraints {
1442            allowed_target_paths: vec!["~/.config/acme/*".into()],
1443            ..Default::default()
1444        };
1445        let spec = ProfileSpec {
1446            files: Some(FilesSpec {
1447                managed: vec![ManagedFileSpec {
1448                    source: "config.yaml".into(),
1449                    target: "~/.config/acme/config.yaml".into(),
1450                    strategy: None,
1451                    private: false,
1452                    origin: None,
1453                    encryption: None,
1454                    permissions: None,
1455                }],
1456                ..Default::default()
1457            }),
1458            ..Default::default()
1459        };
1460        assert!(validate_constraints("acme", &constraints, &spec).is_ok());
1461    }
1462
1463    #[test]
1464    fn validate_constraints_system_changes_blocked() {
1465        let constraints = SourceConstraints {
1466            allow_system_changes: false,
1467            ..Default::default()
1468        };
1469        let spec = ProfileSpec {
1470            system: HashMap::from([("shell".into(), serde_yaml::Value::String("/bin/zsh".into()))]),
1471            ..Default::default()
1472        };
1473        let err = validate_constraints("acme", &constraints, &spec).unwrap_err();
1474        let msg = err.to_string();
1475        assert!(
1476            msg.contains("acme") && msg.contains("system setting") && msg.contains("shell"),
1477            "error should name source, mention system setting, and name the offending key: {msg}"
1478        );
1479    }
1480
1481    #[test]
1482    fn validate_constraints_system_changes_allowed() {
1483        // allow_system_changes defaults to true
1484        let constraints = SourceConstraints {
1485            allow_system_changes: true,
1486            ..Default::default()
1487        };
1488        let spec = ProfileSpec {
1489            system: HashMap::from([("shell".into(), serde_yaml::Value::String("/bin/zsh".into()))]),
1490            ..Default::default()
1491        };
1492        assert!(validate_constraints("acme", &constraints, &spec).is_ok());
1493    }
1494
1495    #[test]
1496    fn path_matches_glob_pattern() {
1497        assert!(path_matches_any(
1498            "~/.config/acme/config.yaml",
1499            &["~/.config/acme/*".into()]
1500        ));
1501        assert!(!path_matches_any(
1502            "/etc/sudoers",
1503            &["~/.config/acme/*".into()]
1504        ));
1505    }
1506
1507    #[test]
1508    fn path_matches_prefix() {
1509        assert!(path_matches_any(
1510            "~/.config/acme/deep/file.yaml",
1511            &["~/.config/acme/".into()]
1512        ));
1513    }
1514
1515    #[test]
1516    fn path_matches_exact() {
1517        assert!(path_matches_any(
1518            "~/.eslintrc.json",
1519            &["~/.eslintrc.json".into()]
1520        ));
1521    }
1522
1523    #[test]
1524    fn filter_rejected_removes_packages() {
1525        let recommended = PolicyItems {
1526            packages: Some(PackagesSpec {
1527                brew: Some(BrewSpec {
1528                    formulae: vec!["k9s".into(), "stern".into(), "kubectx".into()],
1529                    ..Default::default()
1530                }),
1531                ..Default::default()
1532            }),
1533            ..Default::default()
1534        };
1535
1536        let reject: serde_yaml::Value = serde_yaml::from_str(
1537            r#"
1538            packages:
1539              brew:
1540                formulae:
1541                  - kubectx
1542            "#,
1543        )
1544        .unwrap();
1545
1546        let filtered = filter_rejected(&recommended, &reject);
1547        let brew = filtered.packages.unwrap().brew.unwrap();
1548        assert!(brew.formulae.contains(&"k9s".into()));
1549        assert!(brew.formulae.contains(&"stern".into()));
1550        assert!(!brew.formulae.contains(&"kubectx".into()));
1551    }
1552
1553    #[test]
1554    fn filter_rejected_noop_on_null() {
1555        let recommended = PolicyItems {
1556            env: vec![EnvVar {
1557                name: "EDITOR".into(),
1558                value: "code".into(),
1559            }],
1560            ..Default::default()
1561        };
1562
1563        let filtered = filter_rejected(&recommended, &serde_yaml::Value::Null);
1564        assert_eq!(filtered.env.len(), 1);
1565    }
1566
1567    #[test]
1568    fn multiple_sources_priority_ordering() {
1569        let local = make_local_profile();
1570        let source_a = CompositionInput {
1571            source_name: "alpha".into(),
1572            priority: 300,
1573            policy: ConfigSourcePolicy {
1574                recommended: PolicyItems {
1575                    env: vec![EnvVar {
1576                        name: "theme".into(),
1577                        value: "dark".into(),
1578                    }],
1579                    ..Default::default()
1580                },
1581                ..Default::default()
1582            },
1583            constraints: Default::default(),
1584            layers: vec![],
1585            subscription: SubscriptionConfig {
1586                accept_recommended: true,
1587                ..Default::default()
1588            },
1589        };
1590        let source_b = CompositionInput {
1591            source_name: "beta".into(),
1592            priority: 700,
1593            policy: ConfigSourcePolicy {
1594                recommended: PolicyItems {
1595                    env: vec![EnvVar {
1596                        name: "theme".into(),
1597                        value: "light".into(),
1598                    }],
1599                    ..Default::default()
1600                },
1601                ..Default::default()
1602            },
1603            constraints: Default::default(),
1604            layers: vec![],
1605            subscription: SubscriptionConfig {
1606                accept_recommended: true,
1607                ..Default::default()
1608            },
1609        };
1610
1611        let result = compose(&local, &[source_a, source_b]).unwrap();
1612        // Local (1000) wins over both sources, so "editor" = "vim" still
1613        assert_eq!(
1614            result
1615                .resolved
1616                .merged
1617                .env
1618                .iter()
1619                .find(|e| e.name == "editor")
1620                .map(|e| &e.value),
1621            Some(&"vim".to_string())
1622        );
1623        // Between sources, local wins (priority 1000), but for "theme" which is only
1624        // in sources, higher priority (beta=700) processed after lower priority (alpha=300)
1625        // Local layers are priority 1000 so processed last, but "theme" isn't in local
1626        // so beta's value should remain
1627        assert_eq!(
1628            result
1629                .resolved
1630                .merged
1631                .env
1632                .iter()
1633                .find(|e| e.name == "theme")
1634                .map(|e| &e.value),
1635            Some(&"light".to_string())
1636        );
1637    }
1638
1639    #[test]
1640    fn required_resource_cannot_be_overridden() {
1641        let local = make_local_profile();
1642        let source = CompositionInput {
1643            source_name: "corp".into(),
1644            priority: 500,
1645            policy: ConfigSourcePolicy {
1646                required: PolicyItems {
1647                    files: vec![ManagedFileSpec {
1648                        source: "corp/policy.yaml".into(),
1649                        target: "~/.config/policy.yaml".into(),
1650                        strategy: None,
1651                        private: false,
1652                        origin: None,
1653                        encryption: None,
1654                        permissions: None,
1655                    }],
1656                    ..Default::default()
1657                },
1658                ..Default::default()
1659            },
1660            constraints: Default::default(),
1661            layers: vec![],
1662            subscription: SubscriptionConfig {
1663                accept_recommended: true,
1664                ..Default::default()
1665            },
1666        };
1667        // Second source tries to override the same file
1668        let source_b = CompositionInput {
1669            source_name: "rogue".into(),
1670            priority: 600,
1671            policy: ConfigSourcePolicy {
1672                recommended: PolicyItems {
1673                    files: vec![ManagedFileSpec {
1674                        source: "rogue/policy.yaml".into(),
1675                        target: "~/.config/policy.yaml".into(),
1676                        strategy: None,
1677                        private: false,
1678                        origin: None,
1679                        encryption: None,
1680                        permissions: None,
1681                    }],
1682                    ..Default::default()
1683                },
1684                ..Default::default()
1685            },
1686            constraints: Default::default(),
1687            layers: vec![],
1688            subscription: SubscriptionConfig {
1689                accept_recommended: true,
1690                ..Default::default()
1691            },
1692        };
1693
1694        let result = compose(&local, &[source, source_b]);
1695        assert!(result.is_err());
1696        let err = result.unwrap_err().to_string();
1697        assert!(err.contains("required resource"));
1698        assert!(err.contains("policy.yaml"));
1699    }
1700
1701    #[test]
1702    fn file_conflict_between_sources_records_resolution() {
1703        let local = make_local_profile();
1704        let source_a = CompositionInput {
1705            source_name: "alpha".into(),
1706            priority: 300,
1707            policy: ConfigSourcePolicy {
1708                recommended: PolicyItems {
1709                    files: vec![ManagedFileSpec {
1710                        source: "alpha/tool.conf".into(),
1711                        target: "~/.config/tool.conf".into(),
1712                        strategy: None,
1713                        private: false,
1714                        origin: None,
1715                        encryption: None,
1716                        permissions: None,
1717                    }],
1718                    ..Default::default()
1719                },
1720                ..Default::default()
1721            },
1722            constraints: Default::default(),
1723            layers: vec![],
1724            subscription: SubscriptionConfig {
1725                accept_recommended: true,
1726                ..Default::default()
1727            },
1728        };
1729        let source_b = CompositionInput {
1730            source_name: "beta".into(),
1731            priority: 700,
1732            policy: ConfigSourcePolicy {
1733                recommended: PolicyItems {
1734                    files: vec![ManagedFileSpec {
1735                        source: "beta/tool.conf".into(),
1736                        target: "~/.config/tool.conf".into(),
1737                        strategy: None,
1738                        private: false,
1739                        origin: None,
1740                        encryption: None,
1741                        permissions: None,
1742                    }],
1743                    ..Default::default()
1744                },
1745                ..Default::default()
1746            },
1747            constraints: Default::default(),
1748            layers: vec![],
1749            subscription: SubscriptionConfig {
1750                accept_recommended: true,
1751                ..Default::default()
1752            },
1753        };
1754
1755        let result = compose(&local, &[source_a, source_b]).unwrap();
1756        // Beta wins (higher priority)
1757        let file = result
1758            .resolved
1759            .merged
1760            .files
1761            .managed
1762            .iter()
1763            .find(|f| f.target.to_string_lossy().contains("tool.conf"))
1764            .unwrap();
1765        assert_eq!(file.source, "beta/tool.conf");
1766        // Conflict recorded
1767        let conflict = result
1768            .conflicts
1769            .iter()
1770            .find(|c| c.resource_id.contains("tool.conf"));
1771        assert!(conflict.is_some());
1772        assert_eq!(conflict.unwrap().winning_source, "beta");
1773    }
1774
1775    #[test]
1776    fn equal_priority_file_conflict_is_unresolvable() {
1777        let local = make_local_profile();
1778        let source_a = CompositionInput {
1779            source_name: "team-a".into(),
1780            priority: 500,
1781            policy: ConfigSourcePolicy {
1782                recommended: PolicyItems {
1783                    files: vec![ManagedFileSpec {
1784                        source: "team-a/settings.json".into(),
1785                        target: "~/.config/settings.json".into(),
1786                        strategy: None,
1787                        private: false,
1788                        origin: None,
1789                        encryption: None,
1790                        permissions: None,
1791                    }],
1792                    ..Default::default()
1793                },
1794                ..Default::default()
1795            },
1796            constraints: Default::default(),
1797            layers: vec![],
1798            subscription: SubscriptionConfig {
1799                accept_recommended: true,
1800                ..Default::default()
1801            },
1802        };
1803        let source_b = CompositionInput {
1804            source_name: "team-b".into(),
1805            priority: 500,
1806            policy: ConfigSourcePolicy {
1807                recommended: PolicyItems {
1808                    files: vec![ManagedFileSpec {
1809                        source: "team-b/settings.json".into(),
1810                        target: "~/.config/settings.json".into(),
1811                        strategy: None,
1812                        private: false,
1813                        origin: None,
1814                        encryption: None,
1815                        permissions: None,
1816                    }],
1817                    ..Default::default()
1818                },
1819                ..Default::default()
1820            },
1821            constraints: Default::default(),
1822            layers: vec![],
1823            subscription: SubscriptionConfig {
1824                accept_recommended: true,
1825                ..Default::default()
1826            },
1827        };
1828
1829        let result = compose(&local, &[source_a, source_b]);
1830        assert!(result.is_err());
1831        let err = result.unwrap_err().to_string();
1832        assert!(err.contains("conflict"));
1833        assert!(err.contains("settings.json"));
1834    }
1835
1836    #[test]
1837    fn required_modules_always_included() {
1838        let local = make_local_profile();
1839        let mut source = make_source_input("acme", 500);
1840        source.policy.required.modules = vec!["corp-vpn".into(), "corp-certs".into()];
1841        let result = compose(&local, &[source]).unwrap();
1842        assert!(
1843            result
1844                .resolved
1845                .merged
1846                .modules
1847                .contains(&"corp-vpn".to_string())
1848        );
1849        assert!(
1850            result
1851                .resolved
1852                .merged
1853                .modules
1854                .contains(&"corp-certs".to_string())
1855        );
1856    }
1857
1858    #[test]
1859    fn recommended_modules_included_when_accepted() {
1860        let local = make_local_profile();
1861        let mut source = make_source_input("acme", 500);
1862        source.policy.recommended.modules = vec!["editor".into()];
1863        source.subscription.accept_recommended = true;
1864        let result = compose(&local, &[source]).unwrap();
1865        assert!(
1866            result
1867                .resolved
1868                .merged
1869                .modules
1870                .contains(&"editor".to_string())
1871        );
1872    }
1873
1874    #[test]
1875    fn recommended_modules_rejected() {
1876        let local = make_local_profile();
1877        let mut source = make_source_input("acme", 500);
1878        source.policy.recommended.modules = vec!["editor".into()];
1879        source.subscription.accept_recommended = true;
1880        source.subscription.reject = serde_yaml::from_str("modules: [editor]").unwrap();
1881        let result = compose(&local, &[source]).unwrap();
1882        assert!(
1883            !result
1884                .resolved
1885                .merged
1886                .modules
1887                .contains(&"editor".to_string())
1888        );
1889        // Verify rejection is recorded in conflicts
1890        assert!(
1891            result
1892                .conflicts
1893                .iter()
1894                .any(|c| c.resource_id == "module:editor"
1895                    && c.resolution_type == ResolutionType::Rejected)
1896        );
1897    }
1898
1899    #[test]
1900    fn module_policy_conflicts_recorded() {
1901        let local = make_local_profile();
1902        let mut source = make_source_input("acme", 500);
1903        source.policy.required.modules = vec!["corp-vpn".into()];
1904        let result = compose(&local, &[source]).unwrap();
1905        assert!(
1906            result
1907                .conflicts
1908                .iter()
1909                .any(|c| c.resource_id == "module:corp-vpn"
1910                    && c.resolution_type == ResolutionType::Required)
1911        );
1912    }
1913
1914    #[test]
1915    fn local_modules_and_source_modules_union() {
1916        let mut local = make_local_profile();
1917        local.layers[0].spec.modules = vec!["nvim".into()];
1918        local.merged.modules = vec!["nvim".into()];
1919        let mut source = make_source_input("acme", 500);
1920        source.policy.required.modules = vec!["corp-vpn".into()];
1921        let result = compose(&local, &[source]).unwrap();
1922        assert!(result.resolved.merged.modules.contains(&"nvim".to_string()));
1923        assert!(
1924            result
1925                .resolved
1926                .merged
1927                .modules
1928                .contains(&"corp-vpn".to_string())
1929        );
1930    }
1931
1932    #[test]
1933    fn count_policy_tier_items_includes_modules() {
1934        let items = PolicyItems {
1935            modules: vec!["a".into(), "b".into()],
1936            ..Default::default()
1937        };
1938        assert_eq!(count_policy_tier_items(&items), 2);
1939    }
1940
1941    // --- Encryption constraint tests ---
1942
1943    fn make_encryption_constraint(patterns: &[&str], backend: Option<&str>) -> SourceConstraints {
1944        SourceConstraints {
1945            encryption: Some(crate::config::EncryptionConstraint {
1946                required_targets: patterns.iter().map(|s| s.to_string()).collect(),
1947                backend: backend.map(|s| s.to_string()),
1948                mode: None,
1949            }),
1950            ..Default::default()
1951        }
1952    }
1953
1954    fn make_file_spec_with_encryption(target: &str, backend: Option<&str>) -> ProfileSpec {
1955        ProfileSpec {
1956            files: Some(FilesSpec {
1957                managed: vec![ManagedFileSpec {
1958                    source: "source/file".into(),
1959                    target: target.into(),
1960                    strategy: None,
1961                    private: false,
1962                    origin: None,
1963                    encryption: backend.map(|b| crate::config::EncryptionSpec {
1964                        backend: b.to_string(),
1965                        mode: crate::config::EncryptionMode::InRepo,
1966                    }),
1967                    permissions: None,
1968                }],
1969                ..Default::default()
1970            }),
1971            ..Default::default()
1972        }
1973    }
1974
1975    #[test]
1976    fn encryption_required_target_without_encryption_is_error() {
1977        let constraints = make_encryption_constraint(&["~/.ssh/*"], None);
1978        let spec = make_file_spec_with_encryption("~/.ssh/id_rsa", None);
1979        let result = validate_constraints("corp", &constraints, &spec);
1980        assert!(result.is_err());
1981        let msg = result.unwrap_err().to_string();
1982        assert!(msg.contains("~/.ssh/id_rsa"), "msg: {msg}");
1983        assert!(msg.contains("~/.ssh/*"), "msg: {msg}");
1984        assert!(msg.contains("encryption"), "msg: {msg}");
1985    }
1986
1987    #[test]
1988    fn encryption_required_target_with_encryption_passes() {
1989        let constraints = make_encryption_constraint(&["~/.ssh/*"], None);
1990        let spec = make_file_spec_with_encryption("~/.ssh/id_rsa", Some("sops"));
1991        assert!(validate_constraints("corp", &constraints, &spec).is_ok());
1992    }
1993
1994    #[test]
1995    fn encryption_non_matching_target_without_encryption_passes() {
1996        let constraints = make_encryption_constraint(&["~/.ssh/*"], None);
1997        // ~/.zshrc does not match ~/.ssh/* — no enforcement
1998        let spec = make_file_spec_with_encryption("~/.zshrc", None);
1999        assert!(validate_constraints("corp", &constraints, &spec).is_ok());
2000    }
2001
2002    #[test]
2003    fn encryption_empty_required_targets_no_enforcement() {
2004        let constraints = SourceConstraints {
2005            encryption: Some(crate::config::EncryptionConstraint {
2006                required_targets: vec![],
2007                backend: Some("sops".into()),
2008                mode: None,
2009            }),
2010            ..Default::default()
2011        };
2012        // Even though a backend is specified, empty requiredTargets means no enforcement
2013        let spec = make_file_spec_with_encryption("~/.ssh/id_rsa", None);
2014        assert!(validate_constraints("corp", &constraints, &spec).is_ok());
2015    }
2016
2017    #[test]
2018    fn encryption_no_constraint_field_no_enforcement() {
2019        let constraints = SourceConstraints::default();
2020        // No encryption constraint at all
2021        let spec = make_file_spec_with_encryption("~/.ssh/id_rsa", None);
2022        assert!(validate_constraints("corp", &constraints, &spec).is_ok());
2023    }
2024
2025    #[test]
2026    fn encryption_wrong_backend_is_error() {
2027        let constraints = make_encryption_constraint(&["~/.aws/*"], Some("sops"));
2028        let spec = make_file_spec_with_encryption("~/.aws/credentials", Some("age"));
2029        let result = validate_constraints("corp", &constraints, &spec);
2030        assert!(result.is_err());
2031        let msg = result.unwrap_err().to_string();
2032        assert!(msg.contains("~/.aws/credentials"), "msg: {msg}");
2033        assert!(msg.contains("age"), "msg: {msg}");
2034        assert!(msg.contains("sops"), "msg: {msg}");
2035    }
2036
2037    #[test]
2038    fn encryption_correct_backend_passes() {
2039        let constraints = make_encryption_constraint(&["~/.aws/*"], Some("sops"));
2040        let spec = make_file_spec_with_encryption("~/.aws/credentials", Some("sops"));
2041        assert!(validate_constraints("corp", &constraints, &spec).is_ok());
2042    }
2043
2044    #[test]
2045    fn encryption_constraint_matches_exact_path() {
2046        let constraints = make_encryption_constraint(&["~/.gnupg/secring.gpg"], None);
2047        // Exact path match
2048        let spec = make_file_spec_with_encryption("~/.gnupg/secring.gpg", None);
2049        let result = validate_constraints("corp", &constraints, &spec);
2050        assert!(result.is_err());
2051        assert!(
2052            result
2053                .unwrap_err()
2054                .to_string()
2055                .contains("~/.gnupg/secring.gpg")
2056        );
2057    }
2058
2059    // --- Determinism: full merged output comparison ---
2060
2061    #[test]
2062    fn compose_is_deterministic_full_output() {
2063        let local = make_local_profile();
2064        let input1 = make_source_input("acme", 500);
2065        let input2 = make_source_input("acme", 500);
2066
2067        let result1 = compose(&local, &[input1]).unwrap();
2068        let result2 = compose(&local, &[input2]).unwrap();
2069
2070        // Serialize both merged profiles and compare the full output
2071        let yaml1 = serde_yaml::to_string(&result1.resolved.merged).unwrap();
2072        let yaml2 = serde_yaml::to_string(&result2.resolved.merged).unwrap();
2073        assert_eq!(
2074            yaml1, yaml2,
2075            "Full merged output must be identical across runs"
2076        );
2077
2078        // Also check conflict counts and types match
2079        assert_eq!(result1.conflicts.len(), result2.conflicts.len());
2080        for (c1, c2) in result1.conflicts.iter().zip(result2.conflicts.iter()) {
2081            assert_eq!(c1.resource_id, c2.resource_id);
2082            assert_eq!(c1.resolution_type, c2.resolution_type);
2083            assert_eq!(c1.winning_source, c2.winning_source);
2084        }
2085    }
2086
2087    #[test]
2088    fn compose_deterministic_with_multiple_sources() {
2089        let local = make_local_profile();
2090        // Run twice with same sources in same order
2091        let mk = || {
2092            vec![
2093                CompositionInput {
2094                    source_name: "alpha".into(),
2095                    priority: 300,
2096                    policy: ConfigSourcePolicy {
2097                        recommended: PolicyItems {
2098                            packages: Some(PackagesSpec {
2099                                brew: Some(BrewSpec {
2100                                    formulae: vec!["ripgrep".into(), "fd".into()],
2101                                    ..Default::default()
2102                                }),
2103                                ..Default::default()
2104                            }),
2105                            env: vec![EnvVar {
2106                                name: "PAGER".into(),
2107                                value: "less".into(),
2108                            }],
2109                            ..Default::default()
2110                        },
2111                        ..Default::default()
2112                    },
2113                    constraints: Default::default(),
2114                    layers: vec![],
2115                    subscription: SubscriptionConfig {
2116                        accept_recommended: true,
2117                        ..Default::default()
2118                    },
2119                },
2120                CompositionInput {
2121                    source_name: "beta".into(),
2122                    priority: 700,
2123                    policy: ConfigSourcePolicy {
2124                        recommended: PolicyItems {
2125                            packages: Some(PackagesSpec {
2126                                brew: Some(BrewSpec {
2127                                    formulae: vec!["jq".into()],
2128                                    ..Default::default()
2129                                }),
2130                                ..Default::default()
2131                            }),
2132                            env: vec![EnvVar {
2133                                name: "PAGER".into(),
2134                                value: "bat".into(),
2135                            }],
2136                            ..Default::default()
2137                        },
2138                        ..Default::default()
2139                    },
2140                    constraints: Default::default(),
2141                    layers: vec![],
2142                    subscription: SubscriptionConfig {
2143                        accept_recommended: true,
2144                        ..Default::default()
2145                    },
2146                },
2147            ]
2148        };
2149
2150        let r1 = compose(&local, &mk()).unwrap();
2151        let r2 = compose(&local, &mk()).unwrap();
2152        let yaml1 = serde_yaml::to_string(&r1.resolved.merged).unwrap();
2153        let yaml2 = serde_yaml::to_string(&r2.resolved.merged).unwrap();
2154        assert_eq!(yaml1, yaml2);
2155    }
2156
2157    // --- Conflict resolution: env var priority ---
2158
2159    #[test]
2160    fn higher_priority_source_wins_env_var() {
2161        let local = ResolvedProfile {
2162            layers: vec![ProfileLayer {
2163                source: "local".into(),
2164                profile_name: "default".into(),
2165                priority: 1000,
2166                policy: LayerPolicy::Local,
2167                spec: ProfileSpec::default(),
2168            }],
2169            merged: MergedProfile::default(),
2170        };
2171        let low = CompositionInput {
2172            source_name: "low".into(),
2173            priority: 200,
2174            policy: ConfigSourcePolicy {
2175                recommended: PolicyItems {
2176                    env: vec![EnvVar {
2177                        name: "THEME".into(),
2178                        value: "solarized".into(),
2179                    }],
2180                    ..Default::default()
2181                },
2182                ..Default::default()
2183            },
2184            constraints: Default::default(),
2185            layers: vec![],
2186            subscription: SubscriptionConfig {
2187                accept_recommended: true,
2188                ..Default::default()
2189            },
2190        };
2191        let high = CompositionInput {
2192            source_name: "high".into(),
2193            priority: 800,
2194            policy: ConfigSourcePolicy {
2195                recommended: PolicyItems {
2196                    env: vec![EnvVar {
2197                        name: "THEME".into(),
2198                        value: "dracula".into(),
2199                    }],
2200                    ..Default::default()
2201                },
2202                ..Default::default()
2203            },
2204            constraints: Default::default(),
2205            layers: vec![],
2206            subscription: SubscriptionConfig {
2207                accept_recommended: true,
2208                ..Default::default()
2209            },
2210        };
2211
2212        let result = compose(&local, &[low, high]).unwrap();
2213        let theme = result
2214            .resolved
2215            .merged
2216            .env
2217            .iter()
2218            .find(|e| e.name == "THEME")
2219            .expect("THEME env var must exist");
2220        // Higher priority (800) source processed after lower (200), so it overwrites
2221        assert_eq!(theme.value, "dracula");
2222    }
2223
2224    #[test]
2225    fn local_env_wins_over_source_env_at_same_name() {
2226        let local = ResolvedProfile {
2227            layers: vec![ProfileLayer {
2228                source: "local".into(),
2229                profile_name: "default".into(),
2230                priority: 1000,
2231                policy: LayerPolicy::Local,
2232                spec: ProfileSpec {
2233                    env: vec![EnvVar {
2234                        name: "EDITOR".into(),
2235                        value: "nvim".into(),
2236                    }],
2237                    ..Default::default()
2238                },
2239            }],
2240            merged: MergedProfile {
2241                env: vec![EnvVar {
2242                    name: "EDITOR".into(),
2243                    value: "nvim".into(),
2244                }],
2245                ..Default::default()
2246            },
2247        };
2248        let source = CompositionInput {
2249            source_name: "corp".into(),
2250            priority: 500,
2251            policy: ConfigSourcePolicy {
2252                recommended: PolicyItems {
2253                    env: vec![EnvVar {
2254                        name: "EDITOR".into(),
2255                        value: "code --wait".into(),
2256                    }],
2257                    ..Default::default()
2258                },
2259                ..Default::default()
2260            },
2261            constraints: Default::default(),
2262            layers: vec![],
2263            subscription: SubscriptionConfig {
2264                accept_recommended: true,
2265                ..Default::default()
2266            },
2267        };
2268
2269        let result = compose(&local, &[source]).unwrap();
2270        let editor = result
2271            .resolved
2272            .merged
2273            .env
2274            .iter()
2275            .find(|e| e.name == "EDITOR")
2276            .expect("EDITOR env var must exist");
2277        // Local priority 1000 > source priority 500
2278        assert_eq!(editor.value, "nvim");
2279    }
2280
2281    // --- Conflict resolution: files with different content ---
2282
2283    #[test]
2284    fn higher_priority_source_wins_file_content() {
2285        let local = ResolvedProfile {
2286            layers: vec![ProfileLayer {
2287                source: "local".into(),
2288                profile_name: "default".into(),
2289                priority: 1000,
2290                policy: LayerPolicy::Local,
2291                spec: ProfileSpec::default(),
2292            }],
2293            merged: MergedProfile::default(),
2294        };
2295        let low = CompositionInput {
2296            source_name: "low-src".into(),
2297            priority: 200,
2298            policy: ConfigSourcePolicy {
2299                recommended: PolicyItems {
2300                    files: vec![ManagedFileSpec {
2301                        source: "low/gitconfig".into(),
2302                        target: "~/.gitconfig".into(),
2303                        strategy: None,
2304                        private: false,
2305                        origin: None,
2306                        encryption: None,
2307                        permissions: None,
2308                    }],
2309                    ..Default::default()
2310                },
2311                ..Default::default()
2312            },
2313            constraints: Default::default(),
2314            layers: vec![],
2315            subscription: SubscriptionConfig {
2316                accept_recommended: true,
2317                ..Default::default()
2318            },
2319        };
2320        let high = CompositionInput {
2321            source_name: "high-src".into(),
2322            priority: 800,
2323            policy: ConfigSourcePolicy {
2324                recommended: PolicyItems {
2325                    files: vec![ManagedFileSpec {
2326                        source: "high/gitconfig".into(),
2327                        target: "~/.gitconfig".into(),
2328                        strategy: None,
2329                        private: false,
2330                        origin: None,
2331                        encryption: None,
2332                        permissions: None,
2333                    }],
2334                    ..Default::default()
2335                },
2336                ..Default::default()
2337            },
2338            constraints: Default::default(),
2339            layers: vec![],
2340            subscription: SubscriptionConfig {
2341                accept_recommended: true,
2342                ..Default::default()
2343            },
2344        };
2345
2346        let result = compose(&local, &[low, high]).unwrap();
2347        let file = result
2348            .resolved
2349            .merged
2350            .files
2351            .managed
2352            .iter()
2353            .find(|f| f.target.to_string_lossy().contains("gitconfig"))
2354            .expect("gitconfig file must exist in merged output");
2355        // Higher priority source's file content wins
2356        assert_eq!(file.source, "high/gitconfig");
2357    }
2358
2359    // --- Merging with an empty source ---
2360
2361    #[test]
2362    fn merging_with_empty_source_does_not_affect_result() {
2363        let local = make_local_profile();
2364        let result_no_sources = compose(&local, &[]).unwrap();
2365
2366        let empty_source = CompositionInput {
2367            source_name: "empty".into(),
2368            priority: 500,
2369            policy: ConfigSourcePolicy::default(),
2370            constraints: Default::default(),
2371            layers: vec![],
2372            subscription: SubscriptionConfig::default(),
2373        };
2374        let result_with_empty = compose(&local, &[empty_source]).unwrap();
2375
2376        let yaml_without = serde_yaml::to_string(&result_no_sources.resolved.merged).unwrap();
2377        let yaml_with = serde_yaml::to_string(&result_with_empty.resolved.merged).unwrap();
2378        assert_eq!(
2379            yaml_without, yaml_with,
2380            "Empty source must not change the merged output"
2381        );
2382    }
2383
2384    // --- Edge case: single source ---
2385
2386    #[test]
2387    fn single_source_merges_correctly() {
2388        let local = ResolvedProfile {
2389            layers: vec![ProfileLayer {
2390                source: "local".into(),
2391                profile_name: "default".into(),
2392                priority: 1000,
2393                policy: LayerPolicy::Local,
2394                spec: ProfileSpec {
2395                    packages: Some(PackagesSpec {
2396                        brew: Some(BrewSpec {
2397                            formulae: vec!["git".into()],
2398                            ..Default::default()
2399                        }),
2400                        ..Default::default()
2401                    }),
2402                    ..Default::default()
2403                },
2404            }],
2405            merged: MergedProfile {
2406                packages: PackagesSpec {
2407                    brew: Some(BrewSpec {
2408                        formulae: vec!["git".into()],
2409                        ..Default::default()
2410                    }),
2411                    ..Default::default()
2412                },
2413                ..Default::default()
2414            },
2415        };
2416        let source = CompositionInput {
2417            source_name: "tools".into(),
2418            priority: 500,
2419            policy: ConfigSourcePolicy {
2420                recommended: PolicyItems {
2421                    packages: Some(PackagesSpec {
2422                        brew: Some(BrewSpec {
2423                            formulae: vec!["fzf".into()],
2424                            ..Default::default()
2425                        }),
2426                        ..Default::default()
2427                    }),
2428                    env: vec![EnvVar {
2429                        name: "FZF_DEFAULT_OPTS".into(),
2430                        value: "--height 40%".into(),
2431                    }],
2432                    ..Default::default()
2433                },
2434                ..Default::default()
2435            },
2436            constraints: Default::default(),
2437            layers: vec![],
2438            subscription: SubscriptionConfig {
2439                accept_recommended: true,
2440                ..Default::default()
2441            },
2442        };
2443
2444        let result = compose(&local, &[source]).unwrap();
2445        let brew = result.resolved.merged.packages.brew.as_ref().unwrap();
2446        assert!(brew.formulae.contains(&"git".to_string()));
2447        assert!(brew.formulae.contains(&"fzf".to_string()));
2448        assert!(
2449            result
2450                .resolved
2451                .merged
2452                .env
2453                .iter()
2454                .any(|e| e.name == "FZF_DEFAULT_OPTS")
2455        );
2456    }
2457
2458    // --- Edge case: overlapping packages across sources ---
2459
2460    #[test]
2461    fn overlapping_packages_are_unioned_not_duplicated() {
2462        let local = ResolvedProfile {
2463            layers: vec![ProfileLayer {
2464                source: "local".into(),
2465                profile_name: "default".into(),
2466                priority: 1000,
2467                policy: LayerPolicy::Local,
2468                spec: ProfileSpec {
2469                    packages: Some(PackagesSpec {
2470                        brew: Some(BrewSpec {
2471                            formulae: vec!["git".into(), "curl".into()],
2472                            ..Default::default()
2473                        }),
2474                        pipx: vec!["black".into()],
2475                        ..Default::default()
2476                    }),
2477                    ..Default::default()
2478                },
2479            }],
2480            merged: MergedProfile {
2481                packages: PackagesSpec {
2482                    brew: Some(BrewSpec {
2483                        formulae: vec!["git".into(), "curl".into()],
2484                        ..Default::default()
2485                    }),
2486                    pipx: vec!["black".into()],
2487                    ..Default::default()
2488                },
2489                ..Default::default()
2490            },
2491        };
2492        let source_a = CompositionInput {
2493            source_name: "alpha".into(),
2494            priority: 300,
2495            policy: ConfigSourcePolicy {
2496                recommended: PolicyItems {
2497                    packages: Some(PackagesSpec {
2498                        brew: Some(BrewSpec {
2499                            formulae: vec!["git".into(), "ripgrep".into()],
2500                            ..Default::default()
2501                        }),
2502                        pipx: vec!["ruff".into(), "black".into()],
2503                        ..Default::default()
2504                    }),
2505                    ..Default::default()
2506                },
2507                ..Default::default()
2508            },
2509            constraints: Default::default(),
2510            layers: vec![],
2511            subscription: SubscriptionConfig {
2512                accept_recommended: true,
2513                ..Default::default()
2514            },
2515        };
2516        let source_b = CompositionInput {
2517            source_name: "beta".into(),
2518            priority: 700,
2519            policy: ConfigSourcePolicy {
2520                recommended: PolicyItems {
2521                    packages: Some(PackagesSpec {
2522                        brew: Some(BrewSpec {
2523                            formulae: vec!["ripgrep".into(), "jq".into()],
2524                            ..Default::default()
2525                        }),
2526                        ..Default::default()
2527                    }),
2528                    ..Default::default()
2529                },
2530                ..Default::default()
2531            },
2532            constraints: Default::default(),
2533            layers: vec![],
2534            subscription: SubscriptionConfig {
2535                accept_recommended: true,
2536                ..Default::default()
2537            },
2538        };
2539
2540        let result = compose(&local, &[source_a, source_b]).unwrap();
2541        let brew = result.resolved.merged.packages.brew.as_ref().unwrap();
2542
2543        // All unique formulae present
2544        assert!(brew.formulae.contains(&"git".to_string()));
2545        assert!(brew.formulae.contains(&"curl".to_string()));
2546        assert!(brew.formulae.contains(&"ripgrep".to_string()));
2547        assert!(brew.formulae.contains(&"jq".to_string()));
2548
2549        // No duplicates: count occurrences of "git" and "ripgrep"
2550        assert_eq!(
2551            brew.formulae.iter().filter(|f| *f == "git").count(),
2552            1,
2553            "git must appear exactly once"
2554        );
2555        assert_eq!(
2556            brew.formulae.iter().filter(|f| *f == "ripgrep").count(),
2557            1,
2558            "ripgrep must appear exactly once"
2559        );
2560
2561        // pipx: black should not be duplicated
2562        let pipx = &result.resolved.merged.packages.pipx;
2563        assert!(pipx.contains(&"black".to_string()));
2564        assert!(pipx.contains(&"ruff".to_string()));
2565        assert_eq!(
2566            pipx.iter().filter(|p| *p == "black").count(),
2567            1,
2568            "black must appear exactly once"
2569        );
2570    }
2571
2572    // --- Edge case: source_env populated correctly ---
2573
2574    #[test]
2575    fn source_env_tracks_per_source_env_vars() {
2576        let local = make_local_profile();
2577        let source = CompositionInput {
2578            source_name: "corp".into(),
2579            priority: 500,
2580            policy: ConfigSourcePolicy {
2581                recommended: PolicyItems {
2582                    env: vec![EnvVar {
2583                        name: "CORP_VAR".into(),
2584                        value: "corp-value".into(),
2585                    }],
2586                    ..Default::default()
2587                },
2588                ..Default::default()
2589            },
2590            constraints: Default::default(),
2591            layers: vec![ProfileLayer {
2592                source: "corp".into(),
2593                profile_name: "corp/base".into(),
2594                priority: 500,
2595                policy: LayerPolicy::Recommended,
2596                spec: ProfileSpec {
2597                    env: vec![EnvVar {
2598                        name: "LAYER_VAR".into(),
2599                        value: "from-layer".into(),
2600                    }],
2601                    ..Default::default()
2602                },
2603            }],
2604            subscription: SubscriptionConfig {
2605                accept_recommended: true,
2606                ..Default::default()
2607            },
2608        };
2609
2610        let result = compose(&local, &[source]).unwrap();
2611        let corp_env = result.source_env.get("corp").expect("corp env must exist");
2612        // source_env is built from input.layers (not policy), so it should contain LAYER_VAR
2613        assert!(corp_env.iter().any(|e| e.name == "LAYER_VAR"));
2614    }
2615
2616    // --- Edge case: compose with empty local profile ---
2617
2618    #[test]
2619    fn compose_with_empty_local_and_no_sources() {
2620        let local = ResolvedProfile {
2621            layers: vec![],
2622            merged: MergedProfile::default(),
2623        };
2624        let result = compose(&local, &[]).unwrap();
2625        assert!(result.resolved.merged.env.is_empty());
2626        assert!(result.resolved.merged.modules.is_empty());
2627        assert!(result.resolved.merged.files.managed.is_empty());
2628        assert!(result.conflicts.is_empty());
2629    }
2630
2631    // --- Edge case: aliases with same name across priorities ---
2632
2633    #[test]
2634    fn higher_priority_source_wins_alias() {
2635        let local = ResolvedProfile {
2636            layers: vec![ProfileLayer {
2637                source: "local".into(),
2638                profile_name: "default".into(),
2639                priority: 1000,
2640                policy: LayerPolicy::Local,
2641                spec: ProfileSpec {
2642                    aliases: vec![ShellAlias {
2643                        name: "ll".into(),
2644                        command: "ls -la".into(),
2645                    }],
2646                    ..Default::default()
2647                },
2648            }],
2649            merged: MergedProfile {
2650                aliases: vec![ShellAlias {
2651                    name: "ll".into(),
2652                    command: "ls -la".into(),
2653                }],
2654                ..Default::default()
2655            },
2656        };
2657        let source = CompositionInput {
2658            source_name: "corp".into(),
2659            priority: 500,
2660            policy: ConfigSourcePolicy {
2661                recommended: PolicyItems {
2662                    aliases: vec![ShellAlias {
2663                        name: "ll".into(),
2664                        command: "exa -la".into(),
2665                    }],
2666                    ..Default::default()
2667                },
2668                ..Default::default()
2669            },
2670            constraints: Default::default(),
2671            layers: vec![],
2672            subscription: SubscriptionConfig {
2673                accept_recommended: true,
2674                ..Default::default()
2675            },
2676        };
2677
2678        let result = compose(&local, &[source]).unwrap();
2679        let ll = result
2680            .resolved
2681            .merged
2682            .aliases
2683            .iter()
2684            .find(|a| a.name == "ll")
2685            .expect("ll alias must exist");
2686        // Local (priority 1000) processed after source (500), so local wins
2687        assert_eq!(ll.command, "ls -la");
2688    }
2689
2690    // --- Additional coverage tests ---
2691
2692    #[test]
2693    fn has_content_all_empty() {
2694        let items = PolicyItems::default();
2695        assert!(!has_content(&items));
2696    }
2697
2698    #[test]
2699    fn has_content_with_packages() {
2700        let items = PolicyItems {
2701            packages: Some(PackagesSpec::default()),
2702            ..Default::default()
2703        };
2704        assert!(has_content(&items));
2705    }
2706
2707    #[test]
2708    fn has_content_with_env() {
2709        let items = PolicyItems {
2710            env: vec![EnvVar {
2711                name: "A".into(),
2712                value: "1".into(),
2713            }],
2714            ..Default::default()
2715        };
2716        assert!(has_content(&items));
2717    }
2718
2719    #[test]
2720    fn has_content_with_aliases() {
2721        let items = PolicyItems {
2722            aliases: vec![ShellAlias {
2723                name: "g".into(),
2724                command: "git".into(),
2725            }],
2726            ..Default::default()
2727        };
2728        assert!(has_content(&items));
2729    }
2730
2731    #[test]
2732    fn has_content_with_secrets() {
2733        let items = PolicyItems {
2734            secrets: vec![crate::config::SecretSpec {
2735                source: "vault://test".into(),
2736                target: None,
2737                template: None,
2738                backend: None,
2739                envs: None,
2740            }],
2741            ..Default::default()
2742        };
2743        assert!(has_content(&items));
2744    }
2745
2746    #[test]
2747    fn has_content_with_system() {
2748        let items = PolicyItems {
2749            system: std::collections::HashMap::from([(
2750                "shell".into(),
2751                serde_yaml::Value::String("/bin/zsh".into()),
2752            )]),
2753            ..Default::default()
2754        };
2755        assert!(has_content(&items));
2756    }
2757
2758    #[test]
2759    fn has_content_with_profiles() {
2760        let items = PolicyItems {
2761            profiles: vec!["base".into()],
2762            ..Default::default()
2763        };
2764        assert!(has_content(&items));
2765    }
2766
2767    #[test]
2768    fn policy_items_to_spec_converts_all_fields() {
2769        let items = PolicyItems {
2770            packages: Some(PackagesSpec {
2771                brew: Some(BrewSpec {
2772                    formulae: vec!["git".into()],
2773                    ..Default::default()
2774                }),
2775                ..Default::default()
2776            }),
2777            files: vec![ManagedFileSpec {
2778                source: "f.yaml".into(),
2779                target: "~/.config/f.yaml".into(),
2780                strategy: None,
2781                private: false,
2782                origin: None,
2783                encryption: None,
2784                permissions: None,
2785            }],
2786            env: vec![EnvVar {
2787                name: "A".into(),
2788                value: "1".into(),
2789            }],
2790            aliases: vec![ShellAlias {
2791                name: "g".into(),
2792                command: "git".into(),
2793            }],
2794            modules: vec!["nvim".into()],
2795            secrets: vec![crate::config::SecretSpec {
2796                source: "vault://test".into(),
2797                target: None,
2798                template: None,
2799                backend: None,
2800                envs: None,
2801            }],
2802            ..Default::default()
2803        };
2804
2805        let spec = policy_items_to_spec(&items);
2806        assert!(spec.packages.is_some());
2807        assert!(spec.files.is_some());
2808        assert_eq!(spec.files.unwrap().managed.len(), 1);
2809        assert_eq!(spec.env.len(), 1);
2810        assert_eq!(spec.aliases.len(), 1);
2811        assert_eq!(spec.modules.len(), 1);
2812        assert_eq!(spec.secrets.len(), 1);
2813    }
2814
2815    #[test]
2816    fn policy_items_to_spec_empty_files_means_no_files_spec() {
2817        let items = PolicyItems {
2818            files: vec![],
2819            ..Default::default()
2820        };
2821        let spec = policy_items_to_spec(&items);
2822        assert!(spec.files.is_none());
2823    }
2824
2825    #[test]
2826    fn check_locked_violations_no_conflict() {
2827        let locked = PolicyItems::default();
2828        let merged = MergedProfile::default();
2829        assert!(check_locked_violations("src", &locked, &merged).is_ok());
2830    }
2831
2832    #[test]
2833    fn check_locked_violations_detects_override() {
2834        let locked = PolicyItems {
2835            files: vec![ManagedFileSpec {
2836                source: "corp/policy.yaml".into(),
2837                target: "~/.config/policy.yaml".into(),
2838                strategy: None,
2839                private: false,
2840                origin: None,
2841                encryption: None,
2842                permissions: None,
2843            }],
2844            ..Default::default()
2845        };
2846        let mut merged = MergedProfile::default();
2847        merged.files.managed.push(ManagedFileSpec {
2848            source: "local/override.yaml".into(),   // different source
2849            target: "~/.config/policy.yaml".into(), // same target
2850            strategy: None,
2851            private: false,
2852            origin: None,
2853            encryption: None,
2854            permissions: None,
2855        });
2856        let result = check_locked_violations("corp", &locked, &merged);
2857        assert!(result.is_err());
2858        let err = result.unwrap_err().to_string();
2859        assert!(err.contains("locked"));
2860        assert!(err.contains("policy.yaml"));
2861    }
2862
2863    #[test]
2864    fn check_locked_violations_same_source_is_ok() {
2865        let locked = PolicyItems {
2866            files: vec![ManagedFileSpec {
2867                source: "corp/policy.yaml".into(),
2868                target: "~/.config/policy.yaml".into(),
2869                strategy: None,
2870                private: false,
2871                origin: None,
2872                encryption: None,
2873                permissions: None,
2874            }],
2875            ..Default::default()
2876        };
2877        let mut merged = MergedProfile::default();
2878        merged.files.managed.push(ManagedFileSpec {
2879            source: "corp/policy.yaml".into(), // same source
2880            target: "~/.config/policy.yaml".into(),
2881            strategy: None,
2882            private: false,
2883            origin: None,
2884            encryption: None,
2885            permissions: None,
2886        });
2887        assert!(check_locked_violations("corp", &locked, &merged).is_ok());
2888    }
2889
2890    #[test]
2891    fn detect_permission_changes_new_source() {
2892        let old: Vec<CompositionInput> = vec![];
2893        let new = vec![CompositionInput {
2894            source_name: "new-src".into(),
2895            priority: 500,
2896            policy: ConfigSourcePolicy::default(),
2897            constraints: Default::default(),
2898            layers: vec![],
2899            subscription: SubscriptionConfig::default(),
2900        }];
2901        let changes = detect_permission_changes(&old, &new);
2902        assert_eq!(changes.len(), 1);
2903        assert_eq!(changes[0].source, "new-src");
2904        assert!(changes[0].description.contains("New source"));
2905    }
2906
2907    #[test]
2908    fn detect_permission_changes_locked_items_increased() {
2909        let old = vec![CompositionInput {
2910            source_name: "corp".into(),
2911            priority: 500,
2912            policy: ConfigSourcePolicy {
2913                locked: PolicyItems::default(), // 0 locked items
2914                ..Default::default()
2915            },
2916            constraints: Default::default(),
2917            layers: vec![],
2918            subscription: SubscriptionConfig::default(),
2919        }];
2920        let new = vec![CompositionInput {
2921            source_name: "corp".into(),
2922            priority: 500,
2923            policy: ConfigSourcePolicy {
2924                locked: PolicyItems {
2925                    files: vec![ManagedFileSpec {
2926                        source: "new-lock.yaml".into(),
2927                        target: "~/.lock".into(),
2928                        strategy: None,
2929                        private: false,
2930                        origin: None,
2931                        encryption: None,
2932                        permissions: None,
2933                    }],
2934                    ..Default::default()
2935                },
2936                ..Default::default()
2937            },
2938            constraints: Default::default(),
2939            layers: vec![],
2940            subscription: SubscriptionConfig::default(),
2941        }];
2942        let changes = detect_permission_changes(&old, &new);
2943        assert!(
2944            changes
2945                .iter()
2946                .any(|c| c.description.contains("Locked items increased"))
2947        );
2948    }
2949
2950    #[test]
2951    fn detect_permission_changes_scripts_enabled() {
2952        let old = vec![CompositionInput {
2953            source_name: "corp".into(),
2954            priority: 500,
2955            policy: ConfigSourcePolicy::default(),
2956            constraints: crate::config::SourceConstraints {
2957                no_scripts: true,
2958                ..Default::default()
2959            },
2960            layers: vec![],
2961            subscription: SubscriptionConfig::default(),
2962        }];
2963        let new = vec![CompositionInput {
2964            source_name: "corp".into(),
2965            priority: 500,
2966            policy: ConfigSourcePolicy::default(),
2967            constraints: crate::config::SourceConstraints {
2968                no_scripts: false,
2969                ..Default::default()
2970            },
2971            layers: vec![],
2972            subscription: SubscriptionConfig::default(),
2973        }];
2974        let changes = detect_permission_changes(&old, &new);
2975        assert!(
2976            changes
2977                .iter()
2978                .any(|c| c.description.contains("Scripts have been enabled"))
2979        );
2980    }
2981
2982    #[test]
2983    fn detect_permission_changes_paths_expanded() {
2984        let old = vec![CompositionInput {
2985            source_name: "corp".into(),
2986            priority: 500,
2987            policy: ConfigSourcePolicy::default(),
2988            constraints: crate::config::SourceConstraints {
2989                allowed_target_paths: vec!["~/.config/corp/".into()],
2990                ..Default::default()
2991            },
2992            layers: vec![],
2993            subscription: SubscriptionConfig::default(),
2994        }];
2995        let new = vec![CompositionInput {
2996            source_name: "corp".into(),
2997            priority: 500,
2998            policy: ConfigSourcePolicy::default(),
2999            constraints: crate::config::SourceConstraints {
3000                allowed_target_paths: vec!["~/.config/corp/".into(), "~/.config/extra/".into()],
3001                ..Default::default()
3002            },
3003            layers: vec![],
3004            subscription: SubscriptionConfig::default(),
3005        }];
3006        let changes = detect_permission_changes(&old, &new);
3007        assert!(
3008            changes
3009                .iter()
3010                .any(|c| c.description.contains("target paths expanded"))
3011        );
3012    }
3013
3014    #[test]
3015    fn detect_permission_changes_no_changes() {
3016        let mk = || CompositionInput {
3017            source_name: "corp".into(),
3018            priority: 500,
3019            policy: ConfigSourcePolicy::default(),
3020            constraints: Default::default(),
3021            layers: vec![],
3022            subscription: SubscriptionConfig::default(),
3023        };
3024        // Same old and new - no changes
3025        let changes = detect_permission_changes(&[mk()], &[mk()]);
3026        assert!(changes.is_empty());
3027    }
3028
3029    #[test]
3030    fn count_policy_tier_items_comprehensive() {
3031        let items = PolicyItems {
3032            packages: Some(PackagesSpec {
3033                brew: Some(BrewSpec {
3034                    formulae: vec!["a".into(), "b".into()],
3035                    casks: vec!["c".into()],
3036                    taps: vec!["t".into()],
3037                    ..Default::default()
3038                }),
3039                apt: Some(crate::config::AptSpec {
3040                    file: None,
3041                    packages: vec!["d".into()],
3042                }),
3043                cargo: Some(crate::config::CargoSpec {
3044                    file: None,
3045                    packages: vec!["e".into()],
3046                }),
3047                pipx: vec!["f".into()],
3048                dnf: vec!["g".into()],
3049                npm: Some(crate::config::NpmSpec {
3050                    file: None,
3051                    global: vec!["h".into()],
3052                }),
3053                ..Default::default()
3054            }),
3055            files: vec![ManagedFileSpec {
3056                source: "s".into(),
3057                target: "t".into(),
3058                strategy: None,
3059                private: false,
3060                origin: None,
3061                encryption: None,
3062                permissions: None,
3063            }],
3064            env: vec![EnvVar {
3065                name: "A".into(),
3066                value: "1".into(),
3067            }],
3068            aliases: vec![ShellAlias {
3069                name: "g".into(),
3070                command: "git".into(),
3071            }],
3072            system: HashMap::from([("shell".into(), serde_yaml::Value::Null)]),
3073            modules: vec!["mod1".into(), "mod2".into()],
3074            ..Default::default()
3075        };
3076        // brew: 2 formulae + 1 cask + 1 tap = 4
3077        // apt: 1, cargo: 1, pipx: 1, dnf: 1, npm: 1 = 5
3078        // files: 1, env: 1, aliases: 1, system: 1, modules: 2
3079        assert_eq!(count_policy_tier_items(&items), 4 + 5 + 1 + 1 + 1 + 1 + 2);
3080    }
3081
3082    #[test]
3083    fn filter_rejected_removes_env() {
3084        let recommended = PolicyItems {
3085            env: vec![
3086                EnvVar {
3087                    name: "EDITOR".into(),
3088                    value: "code".into(),
3089                },
3090                EnvVar {
3091                    name: "PAGER".into(),
3092                    value: "less".into(),
3093                },
3094            ],
3095            ..Default::default()
3096        };
3097        let reject: serde_yaml::Value = serde_yaml::from_str("env:\n  EDITOR: ~").unwrap();
3098        let filtered = filter_rejected(&recommended, &reject);
3099        assert_eq!(filtered.env.len(), 1);
3100        assert_eq!(filtered.env[0].name, "PAGER");
3101    }
3102
3103    #[test]
3104    fn filter_rejected_removes_aliases() {
3105        let recommended = PolicyItems {
3106            aliases: vec![
3107                ShellAlias {
3108                    name: "vim".into(),
3109                    command: "nvim".into(),
3110                },
3111                ShellAlias {
3112                    name: "ll".into(),
3113                    command: "ls -la".into(),
3114                },
3115            ],
3116            ..Default::default()
3117        };
3118        let reject: serde_yaml::Value = serde_yaml::from_str("aliases:\n  vim: ~").unwrap();
3119        let filtered = filter_rejected(&recommended, &reject);
3120        assert_eq!(filtered.aliases.len(), 1);
3121        assert_eq!(filtered.aliases[0].name, "ll");
3122    }
3123
3124    #[test]
3125    fn filter_rejected_removes_modules() {
3126        let recommended = PolicyItems {
3127            modules: vec!["nvim".into(), "tmux".into(), "rust".into()],
3128            ..Default::default()
3129        };
3130        let reject: serde_yaml::Value =
3131            serde_yaml::from_str("modules:\n  - tmux\n  - rust").unwrap();
3132        let filtered = filter_rejected(&recommended, &reject);
3133        assert_eq!(filtered.modules, vec!["nvim".to_string()]);
3134    }
3135
3136    #[test]
3137    fn filter_rejected_removes_apt_packages() {
3138        let recommended = PolicyItems {
3139            packages: Some(PackagesSpec {
3140                apt: Some(crate::config::AptSpec {
3141                    file: None,
3142                    packages: vec!["curl".into(), "wget".into()],
3143                }),
3144                ..Default::default()
3145            }),
3146            ..Default::default()
3147        };
3148        let reject: serde_yaml::Value =
3149            serde_yaml::from_str("packages:\n  apt:\n    packages:\n      - curl").unwrap();
3150        let filtered = filter_rejected(&recommended, &reject);
3151        let apt = filtered.packages.unwrap().apt.unwrap();
3152        assert_eq!(apt.packages, vec!["wget".to_string()]);
3153    }
3154
3155    #[test]
3156    fn filter_rejected_removes_pipx_packages() {
3157        let recommended = PolicyItems {
3158            packages: Some(PackagesSpec {
3159                pipx: vec!["black".into(), "ruff".into()],
3160                ..Default::default()
3161            }),
3162            ..Default::default()
3163        };
3164        let reject: serde_yaml::Value =
3165            serde_yaml::from_str("packages:\n  pipx:\n    - black").unwrap();
3166        let filtered = filter_rejected(&recommended, &reject);
3167        assert_eq!(filtered.packages.unwrap().pipx, vec!["ruff".to_string()]);
3168    }
3169
3170    #[test]
3171    fn merge_packages_snap_and_flatpak() {
3172        let mut target = PackagesSpec::default();
3173        let source = PackagesSpec {
3174            snap: Some(crate::config::SnapSpec {
3175                packages: vec!["firefox".into()],
3176                classic: vec!["code".into()],
3177            }),
3178            flatpak: Some(crate::config::FlatpakSpec {
3179                packages: vec!["org.gimp.GIMP".into()],
3180                remote: Some("flathub".into()),
3181            }),
3182            ..Default::default()
3183        };
3184        merge_packages(&mut target, &source);
3185        let snap = target.snap.unwrap();
3186        assert_eq!(snap.packages, vec!["firefox".to_string()]);
3187        assert_eq!(snap.classic, vec!["code".to_string()]);
3188        let flatpak = target.flatpak.unwrap();
3189        assert_eq!(flatpak.packages, vec!["org.gimp.GIMP".to_string()]);
3190        assert_eq!(flatpak.remote, Some("flathub".to_string()));
3191    }
3192
3193    #[test]
3194    fn merge_packages_nix_go_winget() {
3195        let mut target = PackagesSpec {
3196            nix: vec!["existing".into()],
3197            ..Default::default()
3198        };
3199        let source = PackagesSpec {
3200            nix: vec!["new-nix".into(), "existing".into()],
3201            go: vec!["gopls".into()],
3202            winget: vec!["Git.Git".into()],
3203            chocolatey: vec!["cmake".into()],
3204            scoop: vec!["gcc".into()],
3205            ..Default::default()
3206        };
3207        merge_packages(&mut target, &source);
3208        assert!(target.nix.contains(&"existing".to_string()));
3209        assert!(target.nix.contains(&"new-nix".to_string()));
3210        // No duplicates
3211        assert_eq!(target.nix.iter().filter(|n| *n == "existing").count(), 1);
3212        assert_eq!(target.go, vec!["gopls".to_string()]);
3213        assert_eq!(target.winget, vec!["Git.Git".to_string()]);
3214        assert_eq!(target.chocolatey, vec!["cmake".to_string()]);
3215        assert_eq!(target.scoop, vec!["gcc".to_string()]);
3216    }
3217
3218    #[test]
3219    fn merge_packages_custom_managers() {
3220        let mut target = PackagesSpec {
3221            custom: vec![crate::config::CustomManagerSpec {
3222                name: "mise".into(),
3223                check: "mise --version".into(),
3224                list_installed: "mise list".into(),
3225                install: "mise install {package}".into(),
3226                uninstall: "mise remove {package}".into(),
3227                update: None,
3228                packages: vec!["node".into()],
3229            }],
3230            ..Default::default()
3231        };
3232        let source = PackagesSpec {
3233            custom: vec![crate::config::CustomManagerSpec {
3234                name: "mise".into(),
3235                check: "mise --version".into(),
3236                list_installed: "mise list".into(),
3237                install: "mise install {package}".into(),
3238                uninstall: "mise remove {package}".into(),
3239                update: Some("mise upgrade {package}".into()),
3240                packages: vec!["python".into(), "node".into()],
3241            }],
3242            ..Default::default()
3243        };
3244        merge_packages(&mut target, &source);
3245        assert_eq!(target.custom.len(), 1);
3246        let mise = &target.custom[0];
3247        assert!(mise.packages.contains(&"node".to_string()));
3248        assert!(mise.packages.contains(&"python".to_string()));
3249        // No duplicate "node"
3250        assert_eq!(mise.packages.iter().filter(|p| *p == "node").count(), 1);
3251        // update was merged from source
3252        assert!(mise.update.is_some());
3253    }
3254
3255    #[test]
3256    fn compose_scripts_appended_in_order() {
3257        let local = ResolvedProfile {
3258            layers: vec![ProfileLayer {
3259                source: "local".into(),
3260                profile_name: "default".into(),
3261                priority: 1000,
3262                policy: LayerPolicy::Local,
3263                spec: ProfileSpec {
3264                    scripts: Some(ScriptSpec {
3265                        pre_apply: vec![ScriptEntry::Simple("local-pre.sh".into())],
3266                        ..Default::default()
3267                    }),
3268                    ..Default::default()
3269                },
3270            }],
3271            merged: MergedProfile {
3272                scripts: ScriptSpec {
3273                    pre_apply: vec![ScriptEntry::Simple("local-pre.sh".into())],
3274                    ..Default::default()
3275                },
3276                ..Default::default()
3277            },
3278        };
3279        let source = CompositionInput {
3280            source_name: "corp".into(),
3281            priority: 500,
3282            policy: ConfigSourcePolicy {
3283                recommended: PolicyItems {
3284                    ..Default::default()
3285                },
3286                ..Default::default()
3287            },
3288            constraints: Default::default(),
3289            layers: vec![ProfileLayer {
3290                source: "corp".into(),
3291                profile_name: "corp/base".into(),
3292                priority: 500,
3293                policy: LayerPolicy::Recommended,
3294                spec: ProfileSpec {
3295                    scripts: Some(ScriptSpec {
3296                        pre_apply: vec![ScriptEntry::Simple("corp-pre.sh".into())],
3297                        ..Default::default()
3298                    }),
3299                    ..Default::default()
3300                },
3301            }],
3302            subscription: SubscriptionConfig {
3303                accept_recommended: true,
3304                ..Default::default()
3305            },
3306        };
3307
3308        let result = compose(&local, &[source]).unwrap();
3309        let scripts = &result.resolved.merged.scripts.pre_apply;
3310        assert_eq!(scripts.len(), 2);
3311        // corp processed first (lower priority), then local
3312        assert_eq!(scripts[0].run_str(), "corp-pre.sh");
3313        assert_eq!(scripts[1].run_str(), "local-pre.sh");
3314    }
3315
3316    #[test]
3317    fn compose_secrets_deduplicated_by_source() {
3318        let local = ResolvedProfile {
3319            layers: vec![ProfileLayer {
3320                source: "local".into(),
3321                profile_name: "default".into(),
3322                priority: 1000,
3323                policy: LayerPolicy::Local,
3324                spec: ProfileSpec {
3325                    secrets: vec![crate::config::SecretSpec {
3326                        source: "vault://secret/data/token".into(),
3327                        target: Some("/tmp/token".into()),
3328                        template: None,
3329                        backend: None,
3330                        envs: None,
3331                    }],
3332                    ..Default::default()
3333                },
3334            }],
3335            merged: MergedProfile {
3336                secrets: vec![crate::config::SecretSpec {
3337                    source: "vault://secret/data/token".into(),
3338                    target: Some("/tmp/token".into()),
3339                    template: None,
3340                    backend: None,
3341                    envs: None,
3342                }],
3343                ..Default::default()
3344            },
3345        };
3346        let source = CompositionInput {
3347            source_name: "corp".into(),
3348            priority: 500,
3349            policy: ConfigSourcePolicy::default(),
3350            constraints: Default::default(),
3351            layers: vec![ProfileLayer {
3352                source: "corp".into(),
3353                profile_name: "corp/base".into(),
3354                priority: 500,
3355                policy: LayerPolicy::Recommended,
3356                spec: ProfileSpec {
3357                    secrets: vec![crate::config::SecretSpec {
3358                        source: "vault://secret/data/token".into(),
3359                        target: Some("/tmp/token-corp".into()),
3360                        template: None,
3361                        backend: None,
3362                        envs: None,
3363                    }],
3364                    ..Default::default()
3365                },
3366            }],
3367            subscription: SubscriptionConfig {
3368                accept_recommended: true,
3369                ..Default::default()
3370            },
3371        };
3372
3373        let result = compose(&local, &[source]).unwrap();
3374        // Same source key — should be deduplicated (local wins, later layer)
3375        let secrets = &result.resolved.merged.secrets;
3376        let vault_secrets: Vec<_> = secrets
3377            .iter()
3378            .filter(|s| s.source.contains("vault://secret/data/token"))
3379            .collect();
3380        assert_eq!(
3381            vault_secrets.len(),
3382            1,
3383            "secrets with same source should deduplicate"
3384        );
3385        // Local (higher priority, processed last) should win
3386        assert_eq!(vault_secrets[0].target, Some("/tmp/token".into()));
3387    }
3388
3389    #[test]
3390    fn compose_system_deep_merges() {
3391        let local = ResolvedProfile {
3392            layers: vec![ProfileLayer {
3393                source: "local".into(),
3394                profile_name: "default".into(),
3395                priority: 1000,
3396                policy: LayerPolicy::Local,
3397                spec: ProfileSpec {
3398                    system: HashMap::from([(
3399                        "shell".into(),
3400                        serde_yaml::Value::String("/bin/zsh".into()),
3401                    )]),
3402                    ..Default::default()
3403                },
3404            }],
3405            merged: MergedProfile {
3406                system: HashMap::from([(
3407                    "shell".into(),
3408                    serde_yaml::Value::String("/bin/zsh".into()),
3409                )]),
3410                ..Default::default()
3411            },
3412        };
3413        let source = CompositionInput {
3414            source_name: "corp".into(),
3415            priority: 500,
3416            policy: ConfigSourcePolicy::default(),
3417            constraints: crate::config::SourceConstraints {
3418                allow_system_changes: true,
3419                ..Default::default()
3420            },
3421            layers: vec![ProfileLayer {
3422                source: "corp".into(),
3423                profile_name: "corp/base".into(),
3424                priority: 500,
3425                policy: LayerPolicy::Recommended,
3426                spec: ProfileSpec {
3427                    system: HashMap::from([(
3428                        "sysctl".into(),
3429                        serde_yaml::Value::String("value".into()),
3430                    )]),
3431                    ..Default::default()
3432                },
3433            }],
3434            subscription: SubscriptionConfig {
3435                accept_recommended: true,
3436                ..Default::default()
3437            },
3438        };
3439
3440        let result = compose(&local, &[source]).unwrap();
3441        assert!(result.resolved.merged.system.contains_key("shell"));
3442        assert!(result.resolved.merged.system.contains_key("sysctl"));
3443    }
3444
3445    #[test]
3446    fn validate_constraints_encryption_mode_mismatch() {
3447        let constraints = crate::config::SourceConstraints {
3448            encryption: Some(crate::config::EncryptionConstraint {
3449                required_targets: vec!["~/.ssh/*".into()],
3450                backend: None,
3451                mode: Some(crate::config::EncryptionMode::Always),
3452            }),
3453            ..Default::default()
3454        };
3455        // File has InRepo mode but constraint requires Always
3456        let spec = ProfileSpec {
3457            files: Some(FilesSpec {
3458                managed: vec![ManagedFileSpec {
3459                    source: "key".into(),
3460                    target: "~/.ssh/id_rsa".into(),
3461                    strategy: None,
3462                    private: false,
3463                    origin: None,
3464                    encryption: Some(crate::config::EncryptionSpec {
3465                        backend: "sops".into(),
3466                        mode: crate::config::EncryptionMode::InRepo,
3467                    }),
3468                    permissions: None,
3469                }],
3470                ..Default::default()
3471            }),
3472            ..Default::default()
3473        };
3474        let result = validate_constraints("corp", &constraints, &spec);
3475        assert!(result.is_err());
3476        let msg = result.unwrap_err().to_string();
3477        assert!(
3478            msg.contains("mode"),
3479            "expected mode mismatch error, got: {msg}"
3480        );
3481    }
3482
3483    #[test]
3484    fn check_locked_violations_empty_locked_files() {
3485        // Locked items with no files but other content should not trigger violations
3486        let locked = PolicyItems {
3487            modules: vec!["corp-vpn".into()],
3488            ..Default::default()
3489        };
3490        let merged = MergedProfile {
3491            modules: vec!["corp-vpn".into()],
3492            ..Default::default()
3493        };
3494        // No file conflict — locked only has modules, not files
3495        assert!(check_locked_violations("corp", &locked, &merged).is_ok());
3496    }
3497
3498    #[test]
3499    fn compose_file_origins_tagged_for_source_files() {
3500        let local = ResolvedProfile {
3501            layers: vec![ProfileLayer {
3502                source: "local".into(),
3503                profile_name: "default".into(),
3504                priority: 1000,
3505                policy: LayerPolicy::Local,
3506                spec: ProfileSpec::default(),
3507            }],
3508            merged: MergedProfile::default(),
3509        };
3510        let source = CompositionInput {
3511            source_name: "corp".into(),
3512            priority: 500,
3513            policy: ConfigSourcePolicy::default(),
3514            constraints: Default::default(),
3515            layers: vec![ProfileLayer {
3516                source: "corp".into(),
3517                profile_name: "corp/base".into(),
3518                priority: 500,
3519                policy: LayerPolicy::Recommended,
3520                spec: ProfileSpec {
3521                    files: Some(FilesSpec {
3522                        managed: vec![ManagedFileSpec {
3523                            source: "corp/tool.conf".into(),
3524                            target: "~/.config/tool.conf".into(),
3525                            strategy: None,
3526                            private: false,
3527                            origin: None,
3528                            encryption: None,
3529                            permissions: None,
3530                        }],
3531                        ..Default::default()
3532                    }),
3533                    ..Default::default()
3534                },
3535            }],
3536            subscription: SubscriptionConfig {
3537                accept_recommended: true,
3538                ..Default::default()
3539            },
3540        };
3541
3542        let result = compose(&local, &[source]).unwrap();
3543        let file = result
3544            .resolved
3545            .merged
3546            .files
3547            .managed
3548            .iter()
3549            .find(|f| f.target.to_string_lossy().contains("tool.conf"))
3550            .unwrap();
3551        // File origin should be tagged with source name
3552        assert_eq!(file.origin, Some("corp".to_string()));
3553    }
3554
3555    #[test]
3556    fn record_rejections_with_modules() {
3557        let recommended = PolicyItems::default();
3558        let reject: serde_yaml::Value =
3559            serde_yaml::from_str("modules:\n  - bad-mod\n  - other-mod").unwrap();
3560        let mut conflicts = Vec::new();
3561        record_rejections("corp", &recommended, &reject, &mut conflicts);
3562        assert_eq!(conflicts.len(), 2);
3563        assert!(
3564            conflicts
3565                .iter()
3566                .all(|c| c.resolution_type == ResolutionType::Rejected)
3567        );
3568        assert!(conflicts.iter().any(|c| c.resource_id == "module:bad-mod"));
3569        assert!(
3570            conflicts
3571                .iter()
3572                .any(|c| c.resource_id == "module:other-mod")
3573        );
3574    }
3575
3576    #[test]
3577    fn record_rejections_null_does_nothing() {
3578        let recommended = PolicyItems::default();
3579        let mut conflicts = Vec::new();
3580        record_rejections(
3581            "corp",
3582            &recommended,
3583            &serde_yaml::Value::Null,
3584            &mut conflicts,
3585        );
3586        assert!(conflicts.is_empty());
3587    }
3588
3589    #[test]
3590    fn record_policy_conflicts_secrets_and_aliases() {
3591        let items = PolicyItems {
3592            aliases: vec![ShellAlias {
3593                name: "g".into(),
3594                command: "git".into(),
3595            }],
3596            secrets: vec![crate::config::SecretSpec {
3597                source: "vault://test".into(),
3598                target: None,
3599                template: None,
3600                backend: None,
3601                envs: None,
3602            }],
3603            ..Default::default()
3604        };
3605        let mut conflicts = Vec::new();
3606        record_policy_conflicts("corp", &items, ResolutionType::Required, &mut conflicts);
3607        assert_eq!(conflicts.len(), 2);
3608        assert!(conflicts.iter().any(|c| c.resource_id == "alias:g"));
3609        assert!(
3610            conflicts
3611                .iter()
3612                .any(|c| c.resource_id == "secret:vault://test")
3613        );
3614    }
3615
3616    #[test]
3617    fn collect_package_names_all_managers() {
3618        let pkgs = PackagesSpec {
3619            brew: Some(BrewSpec {
3620                formulae: vec!["git".into()],
3621                casks: vec!["firefox".into()],
3622                ..Default::default()
3623            }),
3624            apt: Some(crate::config::AptSpec {
3625                file: None,
3626                packages: vec!["curl".into()],
3627            }),
3628            cargo: Some(crate::config::CargoSpec {
3629                file: None,
3630                packages: vec!["bat".into()],
3631            }),
3632            npm: Some(crate::config::NpmSpec {
3633                file: None,
3634                global: vec!["prettier".into()],
3635            }),
3636            pipx: vec!["black".into()],
3637            dnf: vec!["vim".into()],
3638            ..Default::default()
3639        };
3640        let names = collect_package_names(&pkgs);
3641        assert_eq!(names.len(), 7);
3642        assert!(
3643            names
3644                .iter()
3645                .any(|n| n.contains("git") && n.contains("brew"))
3646        );
3647        assert!(
3648            names
3649                .iter()
3650                .any(|n| n.contains("firefox") && n.contains("brew cask"))
3651        );
3652        assert!(
3653            names
3654                .iter()
3655                .any(|n| n.contains("curl") && n.contains("apt"))
3656        );
3657        assert!(
3658            names
3659                .iter()
3660                .any(|n| n.contains("bat") && n.contains("cargo"))
3661        );
3662        assert!(
3663            names
3664                .iter()
3665                .any(|n| n.contains("prettier") && n.contains("npm"))
3666        );
3667        assert!(
3668            names
3669                .iter()
3670                .any(|n| n.contains("black") && n.contains("pipx"))
3671        );
3672        assert!(names.iter().any(|n| n.contains("vim") && n.contains("dnf")));
3673    }
3674
3675    #[test]
3676    fn build_source_layers_optional_opt_in() {
3677        let input = CompositionInput {
3678            source_name: "corp".into(),
3679            priority: 500,
3680            policy: ConfigSourcePolicy {
3681                optional: PolicyItems {
3682                    profiles: vec!["extra".into()],
3683                    ..Default::default()
3684                },
3685                ..Default::default()
3686            },
3687            constraints: Default::default(),
3688            layers: vec![ProfileLayer {
3689                source: "corp".into(),
3690                profile_name: "extra".into(),
3691                priority: 500,
3692                policy: LayerPolicy::Optional,
3693                spec: ProfileSpec {
3694                    env: vec![EnvVar {
3695                        name: "EXTRA".into(),
3696                        value: "yes".into(),
3697                    }],
3698                    ..Default::default()
3699                },
3700            }],
3701            subscription: SubscriptionConfig {
3702                accept_recommended: false,
3703                opt_in: vec!["extra".into()],
3704                ..Default::default()
3705            },
3706        };
3707        let mut conflicts = Vec::new();
3708        let layers = build_source_layers(&input, &mut conflicts).unwrap();
3709        // Should include the "extra" layer via opt-in
3710        assert!(
3711            layers.iter().any(|l| l.profile_name == "extra"),
3712            "opt-in profile should be included"
3713        );
3714    }
3715
3716    #[test]
3717    fn resolution_type_labels() {
3718        assert_eq!(ResolutionType::Locked.label(), "LOCKED");
3719        assert_eq!(ResolutionType::Required.label(), "REQUIRED");
3720        assert_eq!(ResolutionType::Override.label(), "OVERRIDE");
3721        assert_eq!(ResolutionType::Rejected.label(), "REJECTED");
3722        assert_eq!(ResolutionType::Default.label(), "DEFAULT");
3723    }
3724
3725    #[test]
3726    fn find_matching_pattern_prefix() {
3727        let result = find_matching_pattern(
3728            "~/.config/corp/deep/nested/file.yaml",
3729            &["~/.config/corp/".into()],
3730        );
3731        assert!(result.is_some());
3732        assert_eq!(result.unwrap(), "~/.config/corp/");
3733    }
3734
3735    #[test]
3736    fn find_matching_pattern_no_match() {
3737        let result =
3738            find_matching_pattern("/etc/sudoers", &["~/.config/*".into(), "~/.local/*".into()]);
3739        assert!(result.is_none());
3740    }
3741
3742    #[test]
3743    fn encryption_module_file_matching_required_target_without_encryption_is_error() {
3744        // Module files come through as ProfileSpec files just like profile files;
3745        // validate_constraints sees them the same way.
3746        let constraints = make_encryption_constraint(&["~/.config/secrets/*"], None);
3747        let spec = ProfileSpec {
3748            files: Some(FilesSpec {
3749                managed: vec![
3750                    // First file does NOT match the pattern — OK
3751                    ManagedFileSpec {
3752                        source: "files/.zshrc".into(),
3753                        target: "~/.zshrc".into(),
3754                        strategy: None,
3755                        private: false,
3756                        origin: None,
3757                        encryption: None,
3758                        permissions: None,
3759                    },
3760                    // Second file MATCHES the pattern and has no encryption — error
3761                    ManagedFileSpec {
3762                        source: "files/api-key".into(),
3763                        target: "~/.config/secrets/api-key".into(),
3764                        strategy: None,
3765                        private: false,
3766                        origin: None,
3767                        encryption: None,
3768                        permissions: None,
3769                    },
3770                ],
3771                ..Default::default()
3772            }),
3773            ..Default::default()
3774        };
3775        let result = validate_constraints("corp", &constraints, &spec);
3776        assert!(result.is_err());
3777        let msg = result.unwrap_err().to_string();
3778        assert!(msg.contains("~/.config/secrets/api-key"), "msg: {msg}");
3779    }
3780}