1use 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#[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#[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#[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#[derive(Debug)]
79pub struct CompositionResult {
80 pub resolved: ResolvedProfile,
81 pub conflicts: Vec<ConflictResolution>,
82 pub source_env: HashMap<String, Vec<EnvVar>>,
86 pub source_commits: HashMap<String, String>,
89}
90
91pub 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 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 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 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 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 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_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
168fn 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 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, policy: LayerPolicy::Required, spec,
185 });
186
187 record_policy_conflicts(
188 &input.source_name,
189 &policy.locked,
190 ResolutionType::Locked,
191 conflicts,
192 );
193 }
194
195 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, 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 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(
230 &input.source_name,
231 &policy.recommended,
232 &input.subscription.reject,
233 conflicts,
234 );
235 }
236
237 for opt_profile in &input.subscription.opt_in {
239 if policy.optional.profiles.contains(opt_profile) {
240 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 for source_layer in &input.layers {
257 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
275struct FileOwner {
277 source: String,
278 policy: LayerPolicy,
279 priority: u32,
280}
281
282fn merge_with_policy(
285 layers: &[ProfileLayer],
286 conflicts: &mut Vec<ConflictResolution>,
287) -> std::result::Result<MergedProfile, CompositionError> {
288 let mut merged = MergedProfile::default();
289 let mut file_owners: HashMap<std::path::PathBuf, FileOwner> = HashMap::new();
291
292 for layer in layers {
293 let spec = &layer.spec;
294
295 crate::merge_env(&mut merged.env, &spec.env);
297
298 crate::merge_aliases(&mut merged.aliases, &spec.aliases);
300
301 if let Some(ref pkgs) = spec.packages {
303 merge_packages(&mut merged.packages, pkgs);
304 }
305
306 if let Some(ref files) = spec.files {
308 for managed in &files.managed {
309 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 if owner.source != "local"
329 && layer.source != "local"
330 && owner.source != layer.source
331 {
332 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 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 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 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 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 union_extend(&mut merged.modules, &spec.modules);
422 }
423
424 Ok(merged)
425}
426
427pub fn validate_constraints(
429 source_name: &str,
430 constraints: &SourceConstraints,
431 spec: &ProfileSpec,
432) -> Result<()> {
433 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 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 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 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
530fn path_matches_any(path: &str, allowed: &[String]) -> bool {
533 find_matching_pattern(path, allowed).is_some()
534}
535
536fn 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
555pub fn check_locked_violations(
557 source_name: &str,
558 locked: &PolicyItems,
559 local_merged: &MergedProfile,
560) -> Result<()> {
561 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
577fn 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
610pub 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 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
684fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone)]
1003pub struct PermissionChange {
1004 pub source: String,
1005 pub description: String,
1006}
1007
1008pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 #[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 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 assert_eq!(editor.value, "nvim");
2279 }
2280
2281 #[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 assert_eq!(file.source, "high/gitconfig");
2357 }
2358
2359 #[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 #[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 #[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 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 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 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 #[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 assert!(corp_env.iter().any(|e| e.name == "LAYER_VAR"));
2614 }
2615
2616 #[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 #[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 assert_eq!(ll.command, "ls -la");
2688 }
2689
2690 #[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(), target: "~/.config/policy.yaml".into(), 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(), 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(), ..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 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 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 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 assert_eq!(mise.packages.iter().filter(|p| *p == "node").count(), 1);
3251 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 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 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 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 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 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 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 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 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 let constraints = make_encryption_constraint(&["~/.config/secrets/*"], None);
3747 let spec = ProfileSpec {
3748 files: Some(FilesSpec {
3749 managed: vec![
3750 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 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}