1use std::collections::{HashMap, HashSet};
2use std::path::PathBuf;
3use std::str::FromStr;
4
5use secrecy::ExposeSecret;
6use serde::Serialize;
7
8use crate::config::{MergedProfile, ResolvedProfile, ScriptEntry, ScriptSpec};
9use crate::errors::{ConfigError, Result};
10use crate::expand_tilde;
11use crate::modules::ResolvedModule;
12use crate::output::Printer;
13use crate::providers::{FileAction, PackageAction, ProviderRegistry, SecretAction};
14use crate::state::{ApplyStatus, StateStore};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum ReconcileContext {
19 Apply,
20 Reconcile,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
25pub enum PhaseName {
26 PreScripts,
27 Env,
28 Modules,
29 Packages,
30 System,
31 Files,
32 Secrets,
33 PostScripts,
34}
35
36impl PhaseName {
37 pub fn as_str(&self) -> &str {
38 match self {
39 PhaseName::PreScripts => "pre-scripts",
40 PhaseName::Env => "env",
41 PhaseName::Modules => "modules",
42 PhaseName::Packages => "packages",
43 PhaseName::System => "system",
44 PhaseName::Files => "files",
45 PhaseName::Secrets => "secrets",
46 PhaseName::PostScripts => "post-scripts",
47 }
48 }
49
50 pub fn display_name(&self) -> &str {
51 match self {
52 PhaseName::PreScripts => "Pre-Scripts",
53 PhaseName::Env => "Environment",
54 PhaseName::Modules => "Modules",
55 PhaseName::Packages => "Packages",
56 PhaseName::System => "System",
57 PhaseName::Files => "Files",
58 PhaseName::Secrets => "Secrets",
59 PhaseName::PostScripts => "Post-Scripts",
60 }
61 }
62}
63
64impl FromStr for PhaseName {
65 type Err = String;
66
67 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
68 match s {
69 "pre-scripts" => Ok(PhaseName::PreScripts),
70 "env" => Ok(PhaseName::Env),
71 "modules" => Ok(PhaseName::Modules),
72 "system" => Ok(PhaseName::System),
73 "packages" => Ok(PhaseName::Packages),
74 "files" => Ok(PhaseName::Files),
75 "secrets" => Ok(PhaseName::Secrets),
76 "post-scripts" => Ok(PhaseName::PostScripts),
77 _ => Err(format!("unknown phase: {}", s)),
78 }
79 }
80}
81
82#[derive(Debug, Serialize)]
84pub enum EnvAction {
85 WriteEnvFile {
87 path: std::path::PathBuf,
88 content: String,
89 },
90 InjectSourceLine {
92 rc_path: std::path::PathBuf,
93 line: String,
94 },
95}
96
97#[derive(Debug, Serialize)]
99pub enum Action {
100 File(FileAction),
101 Package(PackageAction),
102 Secret(SecretAction),
103 System(SystemAction),
104 Script(ScriptAction),
105 Module(ModuleAction),
106 Env(EnvAction),
107}
108
109#[derive(Debug, Serialize)]
111pub struct ModuleAction {
112 pub module_name: String,
113 pub kind: ModuleActionKind,
114}
115
116#[derive(Debug, Serialize)]
118pub enum ModuleActionKind {
119 InstallPackages {
121 resolved: Vec<crate::modules::ResolvedPackage>,
122 },
123 DeployFiles {
125 files: Vec<crate::modules::ResolvedFile>,
126 },
127 RunScript {
129 script: ScriptEntry,
130 phase: ScriptPhase,
131 },
132 Skip { reason: String },
134}
135
136#[derive(Debug, Serialize)]
138pub enum SystemAction {
139 SetValue {
140 configurator: String,
141 key: String,
142 desired: String,
143 current: String,
144 origin: String,
145 },
146 Skip {
147 configurator: String,
148 reason: String,
149 origin: String,
150 },
151}
152
153#[derive(Debug, Serialize)]
155pub enum ScriptAction {
156 Run {
157 entry: ScriptEntry,
158 phase: ScriptPhase,
159 origin: String,
160 },
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
165pub enum ScriptPhase {
166 PreApply,
167 PostApply,
168 PreReconcile,
169 PostReconcile,
170 OnDrift,
171 OnChange,
172}
173
174impl ScriptPhase {
175 pub fn display_name(&self) -> &'static str {
176 match self {
177 ScriptPhase::PreApply => "preApply",
178 ScriptPhase::PostApply => "postApply",
179 ScriptPhase::PreReconcile => "preReconcile",
180 ScriptPhase::PostReconcile => "postReconcile",
181 ScriptPhase::OnDrift => "onDrift",
182 ScriptPhase::OnChange => "onChange",
183 }
184 }
185}
186
187#[derive(Debug, Serialize)]
189pub struct Phase {
190 pub name: PhaseName,
191 pub actions: Vec<Action>,
192}
193
194#[derive(Debug, Serialize)]
196pub struct Plan {
197 pub phases: Vec<Phase>,
198 #[serde(skip_serializing_if = "Vec::is_empty")]
200 pub warnings: Vec<String>,
201}
202
203impl Plan {
204 pub fn total_actions(&self) -> usize {
205 self.phases.iter().map(|p| p.actions.len()).sum()
206 }
207
208 pub fn is_empty(&self) -> bool {
209 self.phases.iter().all(|p| p.actions.is_empty())
210 }
211
212 pub fn to_hash_string(&self) -> String {
216 let mut parts = Vec::new();
217 for phase in &self.phases {
218 for action in &phase.actions {
219 if let Ok(json) = serde_json::to_string(action) {
220 parts.push(json);
221 }
222 }
223 }
224 parts.join("|")
225 }
226}
227
228#[derive(Debug, Serialize)]
230pub struct ActionResult {
231 pub phase: String,
232 pub description: String,
233 pub success: bool,
234 pub error: Option<String>,
235 pub changed: bool,
236}
237
238#[derive(Debug, Serialize)]
240pub struct ApplyResult {
241 pub action_results: Vec<ActionResult>,
242 pub status: ApplyStatus,
243 pub apply_id: i64,
245}
246
247#[derive(Debug, Serialize)]
249pub struct RollbackResult {
250 pub files_restored: usize,
251 pub files_removed: usize,
252 pub non_file_actions: Vec<String>,
254}
255
256impl ApplyResult {
257 pub fn succeeded(&self) -> usize {
258 self.action_results.iter().filter(|r| r.success).count()
259 }
260
261 pub fn failed(&self) -> usize {
262 self.action_results.iter().filter(|r| !r.success).count()
263 }
264}
265
266pub struct Reconciler<'a> {
268 registry: &'a ProviderRegistry,
269 state: &'a StateStore,
270}
271
272impl<'a> Reconciler<'a> {
273 pub fn new(registry: &'a ProviderRegistry, state: &'a StateStore) -> Self {
274 Self { registry, state }
275 }
276
277 pub fn plan(
279 &self,
280 resolved: &ResolvedProfile,
281 file_actions: Vec<FileAction>,
282 pkg_actions: Vec<PackageAction>,
283 module_actions: Vec<ResolvedModule>,
284 context: ReconcileContext,
285 ) -> Result<Plan> {
286 Self::detect_file_conflicts(&file_actions, &module_actions)?;
288
289 let mut phases = Vec::new();
290
291 let (pre_script_actions, post_script_actions) =
293 self.plan_scripts(&resolved.merged.scripts, context);
294 phases.push(Phase {
295 name: PhaseName::PreScripts,
296 actions: pre_script_actions,
297 });
298
299 let (env_actions, warnings) = Self::plan_env(
303 &resolved.merged.env,
304 &resolved.merged.aliases,
305 &module_actions,
306 &[], );
309 phases.push(Phase {
310 name: PhaseName::Env,
311 actions: env_actions,
312 });
313
314 let module_phase_actions = self.plan_modules(&module_actions, context);
319 phases.push(Phase {
320 name: PhaseName::Modules,
321 actions: module_phase_actions,
322 });
323
324 let package_actions = pkg_actions.into_iter().map(Action::Package).collect();
327 phases.push(Phase {
328 name: PhaseName::Packages,
329 actions: package_actions,
330 });
331
332 let system_actions = self.plan_system(&resolved.merged, &module_actions)?;
334 phases.push(Phase {
335 name: PhaseName::System,
336 actions: system_actions,
337 });
338
339 let fa = file_actions.into_iter().map(Action::File).collect();
341 phases.push(Phase {
342 name: PhaseName::Files,
343 actions: fa,
344 });
345
346 let secret_actions = self.plan_secrets(&resolved.merged);
348 phases.push(Phase {
349 name: PhaseName::Secrets,
350 actions: secret_actions,
351 });
352
353 phases.push(Phase {
355 name: PhaseName::PostScripts,
356 actions: post_script_actions,
357 });
358
359 Ok(Plan { phases, warnings })
360 }
361
362 fn detect_file_conflicts(
366 file_actions: &[FileAction],
367 modules: &[ResolvedModule],
368 ) -> Result<()> {
369 let mut targets: HashMap<PathBuf, (String, Option<String>)> = HashMap::new();
371
372 for action in file_actions {
374 let (source, target) = match action {
375 FileAction::Create { source, target, .. }
376 | FileAction::Update { source, target, .. } => (source, target),
377 _ => continue,
378 };
379 let hash = content_hash_if_exists(source);
380 let label = format!("profile:{}", source.display());
381 if let Some((existing_label, existing_hash)) = targets.get(target) {
382 if hash != *existing_hash {
383 return Err(crate::errors::FileError::Conflict {
384 target: target.clone(),
385 source_a: existing_label.clone(),
386 source_b: label,
387 }
388 .into());
389 }
390 } else {
391 targets.insert(target.clone(), (label, hash));
392 }
393 }
394
395 for module in modules {
397 for file in &module.files {
398 let target = expand_tilde(&file.target);
399 let hash = content_hash_if_exists(&file.source);
400 let label = format!("module:{}", module.name);
401 if let Some((existing_label, existing_hash)) = targets.get(&target) {
402 if hash != *existing_hash {
403 return Err(crate::errors::FileError::Conflict {
404 target,
405 source_a: existing_label.clone(),
406 source_b: label,
407 }
408 .into());
409 }
410 } else {
411 targets.insert(target, (label, hash));
412 }
413 }
414 }
415
416 Ok(())
417 }
418
419 fn plan_system(
420 &self,
421 profile: &MergedProfile,
422 modules: &[ResolvedModule],
423 ) -> Result<Vec<Action>> {
424 let mut system = profile.system.clone();
427 for module in modules {
428 for (key, value) in &module.system {
429 crate::deep_merge_yaml(
430 system.entry(key.clone()).or_insert(serde_yaml::Value::Null),
431 value,
432 );
433 }
434 }
435
436 let mut actions = Vec::new();
437
438 for configurator in self.registry.available_system_configurators() {
439 if let Some(desired) = system.get(configurator.name()) {
440 let drifts = configurator.diff(desired)?;
441 for drift in drifts {
442 actions.push(Action::System(SystemAction::SetValue {
443 configurator: configurator.name().to_string(),
444 key: drift.key,
445 desired: drift.expected,
446 current: drift.actual,
447 origin: "local".to_string(),
448 }));
449 }
450 }
451 }
452
453 for key in system.keys() {
455 let has_configurator = self
456 .registry
457 .available_system_configurators()
458 .iter()
459 .any(|c| c.name() == key);
460 if !has_configurator {
461 actions.push(Action::System(SystemAction::Skip {
462 configurator: key.clone(),
463 reason: format!("no configurator registered for '{}'", key),
464 origin: "local".to_string(),
465 }));
466 }
467 }
468
469 Ok(actions)
470 }
471
472 fn plan_env(
475 profile_env: &[crate::config::EnvVar],
476 profile_aliases: &[crate::config::ShellAlias],
477 modules: &[ResolvedModule],
478 secret_envs: &[(String, String)],
479 ) -> (Vec<Action>, Vec<String>) {
480 let home = crate::expand_tilde(std::path::Path::new("~"));
481 Self::plan_env_with_home(profile_env, profile_aliases, modules, secret_envs, &home)
482 }
483
484 fn plan_env_with_home(
485 profile_env: &[crate::config::EnvVar],
486 profile_aliases: &[crate::config::ShellAlias],
487 modules: &[ResolvedModule],
488 secret_envs: &[(String, String)],
489 home: &std::path::Path,
490 ) -> (Vec<Action>, Vec<String>) {
491 let (mut merged, merged_aliases) =
492 merge_module_env_aliases(profile_env, profile_aliases, modules);
493
494 for (name, value) in secret_envs {
497 merged.push(crate::config::EnvVar {
498 name: name.clone(),
499 value: value.clone(),
500 });
501 }
502
503 if merged.is_empty() && merged_aliases.is_empty() {
504 return (Vec::new(), Vec::new());
505 }
506
507 let mut actions = Vec::new();
508
509 let warnings = if cfg!(windows) {
510 let ps_path = home.join(".cfgd-env.ps1");
512 let ps_content = generate_powershell_env_content(&merged, &merged_aliases);
513 actions.push(Action::Env(EnvAction::WriteEnvFile {
514 path: ps_path,
515 content: ps_content,
516 }));
517
518 let ps_profile_dirs = [
520 home.join("Documents/PowerShell"),
521 home.join("Documents/WindowsPowerShell"),
522 ];
523 for profile_dir in &ps_profile_dirs {
524 let profile_path = profile_dir.join("Microsoft.PowerShell_profile.ps1");
525 actions.push(Action::Env(EnvAction::InjectSourceLine {
526 rc_path: profile_path,
527 line: ". ~/.cfgd-env.ps1".to_string(),
528 }));
529 }
530
531 if crate::command_available("sh") {
533 let bash_path = home.join(".cfgd.env");
534 let bash_content = generate_env_file_content(&merged, &merged_aliases);
535 actions.push(Action::Env(EnvAction::WriteEnvFile {
536 path: bash_path,
537 content: bash_content,
538 }));
539 let bashrc = home.join(".bashrc");
540 actions.push(Action::Env(EnvAction::InjectSourceLine {
541 rc_path: bashrc,
542 line: "[ -f ~/.cfgd.env ] && source ~/.cfgd.env".to_string(),
543 }));
544 }
545
546 Vec::new()
548 } else {
549 let env_path = home.join(".cfgd.env");
551 let content = generate_env_file_content(&merged, &merged_aliases);
552 actions.push(Action::Env(EnvAction::WriteEnvFile {
553 path: env_path.clone(),
554 content,
555 }));
556
557 let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
558 let rc_path = if shell.contains("zsh") {
559 home.join(".zshrc")
560 } else {
561 home.join(".bashrc")
562 };
563 actions.push(Action::Env(EnvAction::InjectSourceLine {
564 rc_path: rc_path.clone(),
565 line: "[ -f ~/.cfgd.env ] && source ~/.cfgd.env".to_string(),
566 }));
567
568 detect_rc_env_conflicts(&rc_path, &merged, &merged_aliases)
570 };
571
572 let fish_conf_d = home.join(".config/fish/conf.d");
574 let current_shell = std::env::var("SHELL").unwrap_or_default();
575 if current_shell.contains("fish") && fish_conf_d.exists() {
576 let fish_path = fish_conf_d.join("cfgd-env.fish");
577 let fish_content = generate_fish_env_content(&merged, &merged_aliases);
578 let existing_fish = std::fs::read_to_string(&fish_path).unwrap_or_default(); if existing_fish != fish_content {
580 actions.push(Action::Env(EnvAction::WriteEnvFile {
581 path: fish_path,
582 content: fish_content,
583 }));
584 }
585 }
586
587 (actions, warnings)
588 }
589
590 fn plan_secrets(&self, profile: &MergedProfile) -> Vec<Action> {
591 let mut actions = Vec::new();
592
593 let has_backend = self
594 .registry
595 .secret_backend
596 .as_ref()
597 .map(|b| b.is_available())
598 .unwrap_or(false);
599
600 for secret in &profile.secrets {
601 let has_envs = secret.envs.as_ref().is_some_and(|e| !e.is_empty());
602
603 if let Some((provider_name, reference)) =
605 crate::providers::parse_secret_reference(&secret.source)
606 {
607 let available = self
608 .registry
609 .secret_providers
610 .iter()
611 .any(|p| p.name() == provider_name && p.is_available());
612
613 if available {
614 if let Some(ref target) = secret.target {
616 actions.push(Action::Secret(SecretAction::Resolve {
617 provider: provider_name.to_string(),
618 reference: reference.to_string(),
619 target: target.clone(),
620 origin: "local".to_string(),
621 }));
622 }
623
624 if has_envs {
626 actions.push(Action::Secret(SecretAction::ResolveEnv {
627 provider: provider_name.to_string(),
628 reference: reference.to_string(),
629 envs: secret.envs.clone().unwrap_or_default(),
630 origin: "local".to_string(),
631 }));
632 }
633
634 if secret.target.is_none() && !has_envs {
636 actions.push(Action::Secret(SecretAction::Skip {
637 source: secret.source.clone(),
638 reason: "no target or envs specified".to_string(),
639 origin: "local".to_string(),
640 }));
641 }
642 } else {
643 actions.push(Action::Secret(SecretAction::Skip {
644 source: secret.source.clone(),
645 reason: format!("provider '{}' not available", provider_name),
646 origin: "local".to_string(),
647 }));
648 }
649 } else if secret.target.is_some() && has_backend {
650 let backend_name = secret
652 .backend
653 .as_deref()
654 .or_else(|| self.registry.secret_backend.as_ref().map(|b| b.name()))
655 .unwrap_or("sops")
656 .to_string();
657
658 actions.push(Action::Secret(SecretAction::Decrypt {
659 source: PathBuf::from(&secret.source),
660 target: secret.target.clone().unwrap_or_default(),
661 backend: backend_name,
662 origin: "local".to_string(),
663 }));
664
665 if has_envs {
666 actions.push(Action::Secret(SecretAction::Skip {
667 source: secret.source.clone(),
668 reason: "env injection requires a secret provider reference; SOPS file targets cannot inject env vars".to_string(),
669 origin: "local".to_string(),
670 }));
671 }
672 } else if secret.target.is_none() && has_envs && !has_backend {
673 actions.push(Action::Secret(SecretAction::Skip {
676 source: secret.source.clone(),
677 reason: "env injection requires a secret provider reference (e.g. 1password://, vault://)".to_string(),
678 origin: "local".to_string(),
679 }));
680 } else if !has_backend {
681 actions.push(Action::Secret(SecretAction::Skip {
682 source: secret.source.clone(),
683 reason: "no secret backend available".to_string(),
684 origin: "local".to_string(),
685 }));
686 }
687 }
688
689 actions
690 }
691
692 fn plan_scripts(
693 &self,
694 scripts: &ScriptSpec,
695 context: ReconcileContext,
696 ) -> (Vec<Action>, Vec<Action>) {
697 let (pre_entries, pre_phase, post_entries, post_phase) = match context {
698 ReconcileContext::Apply => (
699 &scripts.pre_apply,
700 ScriptPhase::PreApply,
701 &scripts.post_apply,
702 ScriptPhase::PostApply,
703 ),
704 ReconcileContext::Reconcile => (
705 &scripts.pre_reconcile,
706 ScriptPhase::PreReconcile,
707 &scripts.post_reconcile,
708 ScriptPhase::PostReconcile,
709 ),
710 };
711
712 let pre_actions = pre_entries
713 .iter()
714 .map(|entry| {
715 Action::Script(ScriptAction::Run {
716 entry: entry.clone(),
717 phase: pre_phase.clone(),
718 origin: "local".to_string(),
719 })
720 })
721 .collect();
722
723 let post_actions = post_entries
724 .iter()
725 .map(|entry| {
726 Action::Script(ScriptAction::Run {
727 entry: entry.clone(),
728 phase: post_phase.clone(),
729 origin: "local".to_string(),
730 })
731 })
732 .collect();
733
734 (pre_actions, post_actions)
735 }
736
737 fn plan_modules(&self, modules: &[ResolvedModule], context: ReconcileContext) -> Vec<Action> {
738 let mut actions = Vec::new();
739
740 for module in modules {
741 let (pre_scripts, pre_phase, post_scripts, post_phase) = match context {
743 ReconcileContext::Apply => (
744 &module.pre_apply_scripts,
745 ScriptPhase::PreApply,
746 &module.post_apply_scripts,
747 ScriptPhase::PostApply,
748 ),
749 ReconcileContext::Reconcile => (
750 &module.pre_reconcile_scripts,
751 ScriptPhase::PreReconcile,
752 &module.post_reconcile_scripts,
753 ScriptPhase::PostReconcile,
754 ),
755 };
756
757 for script in pre_scripts {
759 actions.push(Action::Module(ModuleAction {
760 module_name: module.name.clone(),
761 kind: ModuleActionKind::RunScript {
762 script: script.clone(),
763 phase: pre_phase.clone(),
764 },
765 }));
766 }
767
768 let mut by_manager: HashMap<String, Vec<crate::modules::ResolvedPackage>> =
770 HashMap::new();
771 for pkg in &module.packages {
772 by_manager
773 .entry(pkg.manager.clone())
774 .or_default()
775 .push(pkg.clone());
776 }
777
778 let mut manager_order: Vec<&String> = by_manager.keys().collect();
782 manager_order.sort_by_key(|mgr| {
783 match self
784 .registry
785 .package_managers
786 .iter()
787 .find(|m| m.name() == mgr.as_str())
788 {
789 Some(m) if m.is_available() => 0, Some(m) if m.can_bootstrap() => 1, _ => 2, }
793 });
794
795 for mgr_name in manager_order {
796 let resolved = &by_manager[mgr_name];
797 actions.push(Action::Module(ModuleAction {
798 module_name: module.name.clone(),
799 kind: ModuleActionKind::InstallPackages {
800 resolved: resolved.clone(),
801 },
802 }));
803 }
804
805 if !module.files.is_empty() {
807 let mut encryption_ok = true;
808 for file in &module.files {
809 if let Some(ref enc) = file.encryption {
810 let strategy = file.strategy.unwrap_or(self.registry.default_file_strategy);
811 if enc.mode == crate::config::EncryptionMode::Always
812 && matches!(
813 strategy,
814 crate::config::FileStrategy::Symlink
815 | crate::config::FileStrategy::Hardlink
816 )
817 {
818 actions.push(Action::Module(ModuleAction {
819 module_name: module.name.clone(),
820 kind: ModuleActionKind::Skip {
821 reason: format!(
822 "encryption mode Always incompatible with {:?} for {}",
823 strategy,
824 file.source.display()
825 ),
826 },
827 }));
828 encryption_ok = false;
829 break;
830 }
831 if file.source.exists() {
832 match crate::is_file_encrypted(&file.source, &enc.backend) {
833 Ok(true) => {}
834 Ok(false) => {
835 actions.push(Action::Module(ModuleAction {
836 module_name: module.name.clone(),
837 kind: ModuleActionKind::Skip {
838 reason: format!(
839 "file {} requires encryption (backend: {}) but is not encrypted",
840 file.source.display(),
841 enc.backend
842 ),
843 },
844 }));
845 encryption_ok = false;
846 break;
847 }
848 Err(e) => {
849 actions.push(Action::Module(ModuleAction {
850 module_name: module.name.clone(),
851 kind: ModuleActionKind::Skip {
852 reason: format!(
853 "encryption check failed for {}: {}",
854 file.source.display(),
855 e
856 ),
857 },
858 }));
859 encryption_ok = false;
860 break;
861 }
862 }
863 }
864 }
865 }
866 if encryption_ok {
867 actions.push(Action::Module(ModuleAction {
868 module_name: module.name.clone(),
869 kind: ModuleActionKind::DeployFiles {
870 files: module.files.clone(),
871 },
872 }));
873 }
874 }
875
876 for script in post_scripts {
878 actions.push(Action::Module(ModuleAction {
879 module_name: module.name.clone(),
880 kind: ModuleActionKind::RunScript {
881 script: script.clone(),
882 phase: post_phase.clone(),
883 },
884 }));
885 }
886 }
887
888 actions
889 }
890
891 fn update_module_state(
893 &self,
894 modules: &[ResolvedModule],
895 apply_id: i64,
896 results: &[ActionResult],
897 ) -> Result<()> {
898 for module in modules {
899 let module_prefix = format!("module:{}:", module.name);
901 let any_failed = results
902 .iter()
903 .any(|r| r.description.starts_with(&module_prefix) && !r.success);
904 let status = if any_failed { "error" } else { "installed" };
905
906 let packages_hash = {
908 let mut pkg_parts: Vec<String> = module
909 .packages
910 .iter()
911 .map(|p| {
912 format!(
913 "{}:{}:{}",
914 p.manager,
915 p.resolved_name,
916 p.version.as_deref().unwrap_or("")
917 )
918 })
919 .collect();
920 pkg_parts.sort();
921 crate::sha256_hex(pkg_parts.join("|").as_bytes())
922 };
923
924 let files_hash = {
926 let mut file_parts: Vec<String> = module
927 .files
928 .iter()
929 .map(|f| format!("{}:{}", f.source.display(), f.target.display()))
930 .collect();
931 file_parts.sort();
932 crate::sha256_hex(file_parts.join("|").as_bytes())
933 };
934
935 let git_sources: Vec<serde_json::Value> = module
937 .files
938 .iter()
939 .filter(|f| f.is_git_source)
940 .map(|f| {
941 serde_json::json!({
942 "source": f.source.display().to_string(),
943 "target": f.target.display().to_string(),
944 })
945 })
946 .collect();
947 let git_sources_json = if git_sources.is_empty() {
948 None
949 } else {
950 Some(serde_json::to_string(&git_sources).unwrap_or_default())
951 };
952
953 self.state.upsert_module_state(
954 &module.name,
955 Some(apply_id),
956 &packages_hash,
957 &files_hash,
958 git_sources_json.as_deref(),
959 status,
960 )?;
961 }
962 Ok(())
963 }
964
965 #[allow(clippy::too_many_arguments)]
968 pub fn apply(
969 &self,
970 plan: &Plan,
971 resolved: &ResolvedProfile,
972 config_dir: &std::path::Path,
973 printer: &Printer,
974 phase_filter: Option<&PhaseName>,
975 module_actions: &[ResolvedModule],
976 context: ReconcileContext,
977 skip_scripts: bool,
978 ) -> Result<ApplyResult> {
979 let plan_hash = crate::state::plan_hash(&plan.to_hash_string());
981 let profile_name = resolved
982 .layers
983 .last()
984 .map(|l| l.profile_name.as_str())
985 .unwrap_or("unknown");
986 let apply_id =
987 self.state
988 .record_apply(profile_name, &plan_hash, ApplyStatus::InProgress, None)?;
989
990 let mut results = Vec::new();
991 let mut action_index: usize = 0;
992 let mut secret_env_collector: Vec<(String, String)> = Vec::new();
993
994 for phase in &plan.phases {
995 if let Some(filter) = phase_filter
996 && &phase.name != filter
997 {
998 continue;
999 }
1000
1001 if phase.actions.is_empty() {
1002 continue;
1003 }
1004
1005 let total = phase.actions.len();
1006 for (action_idx, action) in phase.actions.iter().enumerate() {
1007 let desc_for_journal = format_action_description(action);
1008 let (action_type, resource_id) = parse_resource_from_description(&desc_for_journal);
1009
1010 if let Some(ref path) = action_target_path(action)
1012 && let Ok(Some(file_state)) = crate::capture_file_state(path)
1013 && let Err(e) = self.state.store_file_backup(
1014 apply_id,
1015 &path.display().to_string(),
1016 &file_state,
1017 )
1018 {
1019 tracing::warn!("failed to store file backup for {}: {}", path.display(), e);
1020 }
1021
1022 let journal_id = self
1024 .state
1025 .journal_begin(
1026 apply_id,
1027 action_index,
1028 phase.name.as_str(),
1029 &action_type,
1030 &resource_id,
1031 None,
1032 )
1033 .ok();
1034
1035 let result = self.apply_action(
1036 action,
1037 resolved,
1038 config_dir,
1039 printer,
1040 apply_id,
1041 context,
1042 module_actions,
1043 &mut secret_env_collector,
1044 );
1045
1046 let (desc, success, error, should_abort) = match result {
1047 Ok((desc, script_output)) => {
1048 if let Some(jid) = journal_id
1049 && let Err(e) =
1050 self.state
1051 .journal_complete(jid, None, script_output.as_deref())
1052 {
1053 tracing::warn!("failed to record journal completion: {e}");
1054 }
1055 (desc, true, None, false)
1056 }
1057 Err(e) => {
1058 let desc = format_action_description(action);
1059
1060 let continue_on_err = if let Action::Script(ScriptAction::Run {
1062 entry,
1063 phase: script_phase,
1064 ..
1065 }) = action
1066 {
1067 effective_continue_on_error(entry, script_phase)
1068 } else {
1069 false
1070 };
1071
1072 if continue_on_err {
1073 printer.warning(&format!(
1074 "[{}/{}] Script failed (continueOnError): {} — {}",
1075 action_idx + 1,
1076 total,
1077 desc,
1078 e
1079 ));
1080 } else {
1081 printer.error(&format!(
1082 "[{}/{}] Failed: {} — {}",
1083 action_idx + 1,
1084 total,
1085 desc,
1086 e
1087 ));
1088 }
1089 if let Some(jid) = journal_id
1090 && let Err(je) = self.state.journal_fail(jid, &e.to_string())
1091 {
1092 tracing::warn!("failed to record journal failure: {je}");
1093 }
1094 (desc, false, Some(e.to_string()), !continue_on_err)
1095 }
1096 };
1097
1098 let changed = success && !desc.contains(":skipped");
1099 results.push(ActionResult {
1100 phase: phase.name.as_str().to_string(),
1101 description: desc.clone(),
1102 success,
1103 error: error.clone(),
1104 changed,
1105 });
1106 action_index += 1;
1107
1108 let is_pre_script = matches!(
1110 action,
1111 Action::Script(ScriptAction::Run { phase: sp, .. })
1112 if matches!(sp, ScriptPhase::PreApply | ScriptPhase::PreReconcile)
1113 ) || matches!(
1114 action,
1115 Action::Module(ModuleAction {
1116 kind: ModuleActionKind::RunScript { phase: sp, .. },
1117 ..
1118 }) if matches!(sp, ScriptPhase::PreApply | ScriptPhase::PreReconcile)
1119 );
1120 if should_abort && is_pre_script {
1121 return Err(crate::errors::CfgdError::Config(ConfigError::Invalid {
1122 message: format!("pre-script failed, aborting apply: {}", desc),
1123 }));
1124 }
1125 }
1126 }
1127
1128 if !secret_env_collector.is_empty() {
1130 let (env_actions, _) = Self::plan_env(
1131 &resolved.merged.env,
1132 &resolved.merged.aliases,
1133 module_actions,
1134 &secret_env_collector,
1135 );
1136 for env_action in &env_actions {
1137 if let Action::Env(ea) = env_action {
1138 match Self::apply_env_action(ea, printer) {
1139 Ok(desc) => {
1140 let changed = !desc.contains(":skipped");
1141 results.push(ActionResult {
1142 phase: PhaseName::Secrets.as_str().to_string(),
1143 description: desc,
1144 success: true,
1145 error: None,
1146 changed,
1147 });
1148 }
1149 Err(e) => {
1150 printer.error(&format!("Failed to write secret env vars: {}", e));
1151 results.push(ActionResult {
1152 phase: PhaseName::Secrets.as_str().to_string(),
1153 description: "env:write:secret-envs".to_string(),
1154 success: false,
1155 error: Some(e.to_string()),
1156 changed: false,
1157 });
1158 }
1159 }
1160 }
1161 }
1162 }
1163
1164 let any_changed = results.iter().any(|r| r.changed);
1166 if any_changed && !skip_scripts && !resolved.merged.scripts.on_change.is_empty() {
1167 let profile_name = resolved
1168 .layers
1169 .last()
1170 .map(|l| l.profile_name.as_str())
1171 .unwrap_or("unknown");
1172 let env_vars = build_script_env(
1173 config_dir,
1174 profile_name,
1175 context,
1176 &ScriptPhase::OnChange,
1177 false,
1178 None,
1179 None,
1180 );
1181 for entry in &resolved.merged.scripts.on_change {
1182 match execute_script(
1183 entry,
1184 config_dir,
1185 &env_vars,
1186 crate::PROFILE_SCRIPT_TIMEOUT,
1187 printer,
1188 ) {
1189 Ok((desc, changed, _)) => {
1190 results.push(ActionResult {
1191 phase: "post-scripts".to_string(),
1192 description: desc,
1193 success: true,
1194 error: None,
1195 changed,
1196 });
1197 }
1198 Err(e) => {
1199 let continue_on_err =
1200 effective_continue_on_error(entry, &ScriptPhase::OnChange);
1201 results.push(ActionResult {
1202 phase: "post-scripts".to_string(),
1203 description: format!("onChange: {}", entry.run_str()),
1204 success: false,
1205 error: Some(format!("{}", e)),
1206 changed: false,
1207 });
1208 if !continue_on_err {
1209 return Err(e);
1210 }
1211 }
1212 }
1213 }
1214 }
1215
1216 if any_changed && !skip_scripts {
1218 let profile_name = resolved
1219 .layers
1220 .last()
1221 .map(|l| l.profile_name.as_str())
1222 .unwrap_or("unknown");
1223 for module in module_actions {
1224 if module.on_change_scripts.is_empty() {
1225 continue;
1226 }
1227 let prefix = format!("module:{}:", module.name);
1228 let module_changed = results
1229 .iter()
1230 .any(|r| r.changed && r.description.starts_with(&prefix));
1231 if !module_changed {
1232 continue;
1233 }
1234 let env_vars = build_script_env(
1235 config_dir,
1236 profile_name,
1237 context,
1238 &ScriptPhase::OnChange,
1239 false,
1240 Some(&module.name),
1241 Some(&module.dir),
1242 );
1243 let working = &module.dir;
1244 for entry in &module.on_change_scripts {
1245 match execute_script(entry, working, &env_vars, MODULE_SCRIPT_TIMEOUT, printer)
1246 {
1247 Ok((desc, changed, _)) => {
1248 results.push(ActionResult {
1249 phase: "modules".to_string(),
1250 description: desc,
1251 success: true,
1252 error: None,
1253 changed,
1254 });
1255 }
1256 Err(e) => {
1257 let continue_on_err =
1258 effective_continue_on_error(entry, &ScriptPhase::OnChange);
1259 results.push(ActionResult {
1260 phase: "modules".to_string(),
1261 description: format!(
1262 "module:{}:onChange: {}",
1263 module.name,
1264 entry.run_str()
1265 ),
1266 success: false,
1267 error: Some(format!("{}", e)),
1268 changed: false,
1269 });
1270 if !continue_on_err {
1271 return Err(e);
1272 }
1273 }
1274 }
1275 }
1276 }
1277 }
1278
1279 let total = results.len();
1280 let failed = results.iter().filter(|r| !r.success).count();
1281 let status = if failed == 0 {
1282 ApplyStatus::Success
1283 } else if failed == total {
1284 ApplyStatus::Failed
1285 } else {
1286 ApplyStatus::Partial
1287 };
1288
1289 let summary = serde_json::json!({
1291 "total": total,
1292 "succeeded": total - failed,
1293 "failed": failed,
1294 })
1295 .to_string();
1296 self.state
1297 .update_apply_status(apply_id, status.clone(), Some(&summary))?;
1298
1299 for result in &results {
1301 if result.success {
1302 let (rtype, rid) = parse_resource_from_description(&result.description);
1303 self.state
1304 .upsert_managed_resource(&rtype, &rid, "local", None, Some(apply_id))?;
1305 self.state.resolve_drift(apply_id, &rtype, &rid)?;
1306 }
1307 }
1308
1309 self.update_module_state(module_actions, apply_id, &results)?;
1311
1312 let mut snapshot_paths = std::collections::HashSet::new();
1317 for managed in &resolved.merged.files.managed {
1318 let target = crate::expand_tilde(&managed.target);
1319 let key = target.display().to_string();
1320 if snapshot_paths.contains(&key) {
1321 continue;
1322 }
1323 snapshot_paths.insert(key.clone());
1324 if let Ok(Some(state)) = crate::capture_file_resolved_state(&target)
1325 && let Err(e) = self.state.store_file_backup(apply_id, &key, &state)
1326 {
1327 tracing::debug!("post-apply snapshot for {}: {}", key, e);
1328 }
1329 }
1330 for module in module_actions {
1331 for file in &module.files {
1332 let target = crate::expand_tilde(&file.target);
1333 let key = target.display().to_string();
1334 if snapshot_paths.contains(&key) {
1335 continue;
1336 }
1337 snapshot_paths.insert(key.clone());
1338 if let Ok(Some(state)) = crate::capture_file_resolved_state(&target)
1339 && let Err(e) = self.state.store_file_backup(apply_id, &key, &state)
1340 {
1341 tracing::debug!("post-apply snapshot for {}: {}", key, e);
1342 }
1343 }
1344 }
1345
1346 Ok(ApplyResult {
1347 action_results: results,
1348 status,
1349 apply_id,
1350 })
1351 }
1352
1353 pub fn rollback_apply(&self, apply_id: i64, printer: &Printer) -> Result<RollbackResult> {
1359 let target_backups = self.state.get_apply_backups(apply_id)?;
1370 let after_backups = self.state.file_backups_after_apply(apply_id)?;
1371 let after_entries = self.state.journal_entries_after_apply(apply_id)?;
1372
1373 let mut target_snapshot: HashMap<String, &crate::state::FileBackupRecord> = HashMap::new();
1376 for bk in &target_backups {
1377 target_snapshot.insert(bk.file_path.clone(), bk);
1378 }
1379
1380 let mut files_restored = 0usize;
1381 let mut files_removed = 0usize;
1382 let mut non_file_actions = Vec::new();
1383
1384 for entry in &after_entries {
1386 let is_file = entry.phase == "files"
1387 || entry.action_type == "file"
1388 || entry.resource_id.starts_with("file:");
1389 if !is_file && !non_file_actions.contains(&entry.resource_id) {
1390 non_file_actions.push(entry.resource_id.clone());
1391 }
1392 }
1393
1394 let mut restored_paths = std::collections::HashSet::new();
1396
1397 for (path, bk) in &target_snapshot {
1399 restored_paths.insert(path.clone());
1400 let target = std::path::Path::new(path);
1401 let result = restore_file_from_backup(target, bk, printer);
1402 match result {
1403 RestoreOutcome::Restored => files_restored += 1,
1404 RestoreOutcome::Removed => files_removed += 1,
1405 RestoreOutcome::Skipped | RestoreOutcome::Failed => {}
1406 }
1407 }
1408
1409 for bk in &after_backups {
1411 if restored_paths.contains(&bk.file_path) {
1412 continue;
1413 }
1414 restored_paths.insert(bk.file_path.clone());
1415 let target = std::path::Path::new(&bk.file_path);
1416 let result = restore_file_from_backup(target, bk, printer);
1417 match result {
1418 RestoreOutcome::Restored => files_restored += 1,
1419 RestoreOutcome::Removed => files_removed += 1,
1420 RestoreOutcome::Skipped | RestoreOutcome::Failed => {}
1421 }
1422 }
1423
1424 for entry in &after_entries {
1426 let is_file = entry.phase == "files"
1427 || entry.action_type == "file"
1428 || entry.resource_id.starts_with("file:");
1429 if !is_file {
1430 continue;
1431 }
1432
1433 let actual_path = entry
1434 .resource_id
1435 .strip_prefix("file:create:")
1436 .or_else(|| entry.resource_id.strip_prefix("file:update:"))
1437 .or_else(|| entry.resource_id.strip_prefix("file:delete:"))
1438 .unwrap_or(&entry.resource_id);
1439
1440 if restored_paths.contains(actual_path) {
1441 continue;
1442 }
1443 restored_paths.insert(actual_path.to_string());
1444
1445 let target_entries = self.state.journal_completed_actions(apply_id)?;
1448 let target_had_file = target_entries.iter().any(|e| {
1449 let target_path = e
1450 .resource_id
1451 .strip_prefix("file:create:")
1452 .or_else(|| e.resource_id.strip_prefix("file:update:"))
1453 .or_else(|| e.resource_id.strip_prefix("file:delete:"))
1454 .unwrap_or(&e.resource_id);
1455 target_path == actual_path
1456 });
1457
1458 if !target_had_file && entry.resource_id.starts_with("file:create:") {
1459 let target = std::path::Path::new(actual_path);
1460 if target.exists() {
1461 if let Err(e) = std::fs::remove_file(target) {
1462 printer.warning(&format!(
1463 "rollback: failed to remove {}: {}",
1464 target.display(),
1465 e
1466 ));
1467 } else {
1468 files_removed += 1;
1469 }
1470 }
1471 }
1472 }
1473
1474 self.state.record_apply(
1476 "rollback",
1477 &format!("rollback-of-{}", apply_id),
1478 ApplyStatus::Success,
1479 Some(&format!(
1480 "{{\"rollback_of\":{},\"restored\":{},\"removed\":{}}}",
1481 apply_id, files_restored, files_removed
1482 )),
1483 )?;
1484
1485 Ok(RollbackResult {
1486 files_restored,
1487 files_removed,
1488 non_file_actions,
1489 })
1490 }
1491
1492 #[allow(clippy::too_many_arguments)]
1493 fn apply_action(
1494 &self,
1495 action: &Action,
1496 resolved: &ResolvedProfile,
1497 config_dir: &std::path::Path,
1498 printer: &Printer,
1499 apply_id: i64,
1500 context: ReconcileContext,
1501 module_actions: &[ResolvedModule],
1502 secret_env_collector: &mut Vec<(String, String)>,
1503 ) -> Result<(String, Option<String>)> {
1504 match action {
1505 Action::System(sys) => self
1506 .apply_system_action(sys, &resolved.merged, printer)
1507 .map(|d| (d, None)),
1508 Action::Package(pkg) => self.apply_package_action(pkg, printer).map(|d| (d, None)),
1509 Action::File(file) => self
1510 .apply_file_action(file, &resolved.merged, config_dir, printer)
1511 .map(|d| (d, None)),
1512 Action::Secret(secret) => self
1513 .apply_secret_action(secret, config_dir, printer, secret_env_collector)
1514 .map(|d| (d, None)),
1515 Action::Script(script) => {
1516 self.apply_script_action(script, resolved, config_dir, printer, context)
1517 }
1518 Action::Module(module) => self
1519 .apply_module_action(
1520 module,
1521 config_dir,
1522 printer,
1523 apply_id,
1524 context,
1525 resolved,
1526 module_actions,
1527 )
1528 .map(|d| (d, None)),
1529 Action::Env(env) => Self::apply_env_action(env, printer).map(|d| (d, None)),
1530 }
1531 }
1532
1533 fn apply_env_action(action: &EnvAction, printer: &Printer) -> Result<String> {
1534 match action {
1535 EnvAction::WriteEnvFile { path, content } => {
1536 let existing = match std::fs::read_to_string(path) {
1537 Ok(s) => s,
1538 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
1539 Err(e) => {
1540 tracing::warn!("cannot read {}: {e}", path.display());
1541 String::new()
1542 }
1543 };
1544 if existing == *content {
1545 return Ok(format!("env:write:{}:skipped", path.display()));
1546 }
1547 crate::atomic_write_str(path, content)?;
1548 printer.success(&format!("Wrote {}", path.display()));
1549 Ok(format!("env:write:{}", path.display()))
1550 }
1551 EnvAction::InjectSourceLine { rc_path, line } => {
1552 let existing = match std::fs::read_to_string(rc_path) {
1553 Ok(s) => s,
1554 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
1555 Err(e) => {
1556 tracing::warn!("cannot read {}: {e}", rc_path.display());
1557 String::new()
1558 }
1559 };
1560 if existing.contains(line) {
1561 return Ok(format!("env:inject:{}:skipped", rc_path.display()));
1563 }
1564 let mut content = existing;
1565 if !content.ends_with('\n') && !content.is_empty() {
1566 content.push('\n');
1567 }
1568 content.push_str(line);
1569 content.push('\n');
1570 crate::atomic_write_str(rc_path, &content)?;
1571 printer.success(&format!("Injected source line into {}", rc_path.display()));
1572 Ok(format!("env:inject:{}", rc_path.display()))
1573 }
1574 }
1575 }
1576
1577 fn apply_system_action(
1578 &self,
1579 action: &SystemAction,
1580 profile: &MergedProfile,
1581 printer: &Printer,
1582 ) -> Result<String> {
1583 match action {
1584 SystemAction::SetValue {
1585 configurator,
1586 key,
1587 desired,
1588 current,
1589 ..
1590 } => {
1591 if let Some(desired_value) = profile.system.get(configurator.as_str()) {
1592 for sc in self.registry.available_system_configurators() {
1593 if sc.name() == configurator {
1594 sc.apply(desired_value, printer)?;
1595 return Ok(format!(
1596 "system:{}.{} ({} → {})",
1597 configurator, key, current, desired
1598 ));
1599 }
1600 }
1601 }
1602 Ok(format!("system:{}.{}", configurator, key))
1603 }
1604 SystemAction::Skip {
1605 configurator,
1606 reason,
1607 ..
1608 } => {
1609 printer.warning(&format!("{}: {}", configurator, reason));
1610 Ok(format!("system:{} (skipped)", configurator))
1611 }
1612 }
1613 }
1614
1615 fn apply_package_action(&self, action: &PackageAction, printer: &Printer) -> Result<String> {
1616 match action {
1617 PackageAction::Bootstrap { manager, .. } => {
1618 for pm in &self.registry.package_managers {
1620 if pm.name() == manager {
1621 pm.bootstrap(printer)?;
1622 if !pm.is_available() {
1623 return Err(crate::errors::PackageError::BootstrapFailed {
1624 manager: manager.clone(),
1625 message: format!("{} still not available after bootstrap", manager),
1626 }
1627 .into());
1628 }
1629 return Ok(format!("package:{}:bootstrap", manager));
1630 }
1631 }
1632 Err(crate::errors::PackageError::ManagerNotFound {
1633 manager: manager.clone(),
1634 }
1635 .into())
1636 }
1637 PackageAction::Install {
1638 manager, packages, ..
1639 } => {
1640 for pm in self.registry.available_package_managers() {
1641 if pm.name() == manager {
1642 pm.install(packages, printer)?;
1643 return Ok(format!(
1644 "package:{}:install:{}",
1645 manager,
1646 packages.join(",")
1647 ));
1648 }
1649 }
1650 Err(crate::errors::PackageError::ManagerNotFound {
1651 manager: manager.clone(),
1652 }
1653 .into())
1654 }
1655 PackageAction::Uninstall {
1656 manager, packages, ..
1657 } => {
1658 for pm in self.registry.available_package_managers() {
1659 if pm.name() == manager {
1660 pm.uninstall(packages, printer)?;
1661 return Ok(format!(
1662 "package:{}:uninstall:{}",
1663 manager,
1664 packages.join(",")
1665 ));
1666 }
1667 }
1668 Err(crate::errors::PackageError::ManagerNotFound {
1669 manager: manager.clone(),
1670 }
1671 .into())
1672 }
1673 PackageAction::Skip {
1674 manager, reason, ..
1675 } => {
1676 printer.warning(&format!("{}: {}", manager, reason));
1677 Ok(format!("package:{}:skip", manager))
1678 }
1679 }
1680 }
1681
1682 fn apply_file_action(
1683 &self,
1684 action: &FileAction,
1685 profile: &MergedProfile,
1686 config_dir: &std::path::Path,
1687 printer: &Printer,
1688 ) -> Result<String> {
1689 if let Some(ref fm) = self.registry.file_manager {
1690 fm.apply(&[action.clone_action()], printer)?;
1691 } else {
1692 apply_file_action_direct(action, config_dir, profile)?;
1694 }
1695
1696 match action {
1697 FileAction::Create { target, .. } => Ok(format!("file:create:{}", target.display())),
1698 FileAction::Update { target, .. } => Ok(format!("file:update:{}", target.display())),
1699 FileAction::Delete { target, .. } => Ok(format!("file:delete:{}", target.display())),
1700 FileAction::SetPermissions { target, mode, .. } => {
1701 Ok(format!("file:chmod:{:#o}:{}", mode, target.display()))
1702 }
1703 FileAction::Skip { target, .. } => Ok(format!("file:skip:{}", target.display())),
1704 }
1705 }
1706
1707 fn apply_secret_action(
1708 &self,
1709 action: &SecretAction,
1710 config_dir: &std::path::Path,
1711 printer: &Printer,
1712 secret_env_collector: &mut Vec<(String, String)>,
1713 ) -> Result<String> {
1714 match action {
1715 SecretAction::Decrypt {
1716 source,
1717 target,
1718 backend: _,
1719 ..
1720 } => {
1721 let backend = self
1722 .registry
1723 .secret_backend
1724 .as_ref()
1725 .ok_or(crate::errors::SecretError::SopsNotFound)?;
1726
1727 let source_path =
1728 crate::resolve_relative_path(source, config_dir).map_err(|_| {
1729 crate::errors::SecretError::DecryptionFailed {
1730 path: config_dir.join(source),
1731 message: "source path contains traversal".to_string(),
1732 }
1733 })?;
1734
1735 let decrypted = backend.decrypt_file(&source_path)?;
1736
1737 let target_path = expand_tilde(target);
1738 crate::atomic_write(&target_path, decrypted.expose_secret().as_bytes())?;
1739
1740 printer.info(&format!(
1741 "Decrypted {} → {}",
1742 source.display(),
1743 target_path.display()
1744 ));
1745
1746 Ok(format!("secret:decrypt:{}", target_path.display()))
1747 }
1748 SecretAction::Resolve {
1749 provider,
1750 reference,
1751 target,
1752 ..
1753 } => {
1754 let secret_provider = self
1755 .registry
1756 .secret_providers
1757 .iter()
1758 .find(|p| p.name() == provider)
1759 .ok_or_else(|| crate::errors::SecretError::ProviderNotAvailable {
1760 provider: provider.clone(),
1761 hint: format!("no provider '{}' registered", provider),
1762 })?;
1763
1764 let value = secret_provider.resolve(reference)?;
1765
1766 let target_path = expand_tilde(target);
1767 crate::atomic_write(&target_path, value.expose_secret().as_bytes())?;
1768
1769 printer.info(&format!(
1770 "Resolved {}://{} → {}",
1771 provider,
1772 reference,
1773 target_path.display()
1774 ));
1775
1776 Ok(format!(
1777 "secret:resolve:{}:{}",
1778 provider,
1779 target_path.display()
1780 ))
1781 }
1782 SecretAction::ResolveEnv {
1783 provider,
1784 reference,
1785 envs,
1786 ..
1787 } => {
1788 let secret_provider = self
1789 .registry
1790 .secret_providers
1791 .iter()
1792 .find(|p| p.name() == provider)
1793 .ok_or_else(|| crate::errors::SecretError::ProviderNotAvailable {
1794 provider: provider.clone(),
1795 hint: format!("no provider '{}' registered", provider),
1796 })?;
1797
1798 let value = secret_provider.resolve(reference)?;
1799
1800 let plaintext = value.expose_secret().to_string();
1804 for env_name in envs {
1805 secret_env_collector.push((env_name.clone(), plaintext.clone()));
1806 }
1807
1808 printer.info(&format!(
1809 "Resolved {}://{} → env [{}]",
1810 provider,
1811 reference,
1812 envs.join(", ")
1813 ));
1814
1815 Ok(format!(
1816 "secret:resolve-env:{}:{}:[{}]",
1817 provider,
1818 reference,
1819 envs.join(",")
1820 ))
1821 }
1822 SecretAction::Skip { source, reason, .. } => {
1823 printer.warning(&format!("secret {}: {}", source, reason));
1824 Ok(format!("secret:skip:{}", source))
1825 }
1826 }
1827 }
1828
1829 fn apply_script_action(
1830 &self,
1831 action: &ScriptAction,
1832 resolved: &ResolvedProfile,
1833 config_dir: &std::path::Path,
1834 printer: &Printer,
1835 context: ReconcileContext,
1836 ) -> Result<(String, Option<String>)> {
1837 match action {
1838 ScriptAction::Run { entry, phase, .. } => {
1839 let profile_name = resolved
1840 .layers
1841 .last()
1842 .map(|l| l.profile_name.as_str())
1843 .unwrap_or("unknown");
1844
1845 let env_vars =
1846 build_script_env(config_dir, profile_name, context, phase, false, None, None);
1847
1848 let (_desc, _changed, captured) = execute_script(
1849 entry,
1850 config_dir,
1851 &env_vars,
1852 crate::PROFILE_SCRIPT_TIMEOUT,
1853 printer,
1854 )?;
1855
1856 let phase_name = phase.display_name();
1857 Ok((
1858 format!("script:{}:{}", phase_name, entry.run_str()),
1859 captured,
1860 ))
1861 }
1862 }
1863 }
1864
1865 #[allow(clippy::too_many_arguments)]
1866 fn apply_module_action(
1867 &self,
1868 action: &ModuleAction,
1869 config_dir: &std::path::Path,
1870 printer: &Printer,
1871 apply_id: i64,
1872 context: ReconcileContext,
1873 resolved: &ResolvedProfile,
1874 module_actions: &[ResolvedModule],
1875 ) -> Result<String> {
1876 let module_dir = module_actions
1878 .iter()
1879 .find(|m| m.name == action.module_name)
1880 .map(|m| m.dir.clone());
1881
1882 match &action.kind {
1883 ModuleActionKind::InstallPackages { resolved: pkgs } => {
1884 let pkg_names: Vec<String> = pkgs.iter().map(|p| p.resolved_name.clone()).collect();
1887
1888 if let Some(first) = pkgs.first() {
1889 if first.manager == "script" {
1890 for pkg in pkgs {
1892 if let Some(ref script_content) = pkg.script {
1893 let profile_name = resolved
1894 .layers
1895 .last()
1896 .map(|l| l.profile_name.as_str())
1897 .unwrap_or("unknown");
1898 let env_vars = build_script_env(
1899 config_dir,
1900 profile_name,
1901 context,
1902 &ScriptPhase::PostApply,
1903 false,
1904 Some(&action.module_name),
1905 module_dir.as_deref(),
1906 );
1907 let script_entry = ScriptEntry::Simple(script_content.clone());
1908 let working = module_dir.as_deref().unwrap_or(config_dir);
1909 execute_script(
1910 &script_entry,
1911 working,
1912 &env_vars,
1913 MODULE_SCRIPT_TIMEOUT,
1914 printer,
1915 )
1916 .map_err(|_| {
1917 crate::errors::CfgdError::Config(ConfigError::Invalid {
1918 message: format!(
1919 "module {} install script for '{}' failed",
1920 action.module_name, pkg.canonical_name
1921 ),
1922 })
1923 })?;
1924 }
1925 }
1926 } else {
1927 let pm = self
1929 .registry
1930 .package_managers
1931 .iter()
1932 .find(|m| m.name() == first.manager);
1933
1934 if let Some(pm) = pm {
1935 if !pm.is_available() && pm.can_bootstrap() {
1937 pm.bootstrap(printer)?;
1938
1939 let path_dirs = pm.path_dirs();
1941 if !path_dirs.is_empty() {
1942 let env_path =
1943 expand_tilde(std::path::Path::new("~/.cfgd.env"));
1944 let existing = match std::fs::read_to_string(&env_path) {
1945 Ok(s) => s,
1946 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1947 String::new()
1948 }
1949 Err(e) => {
1950 tracing::warn!(
1951 "cannot read {}: {e}",
1952 env_path.display()
1953 );
1954 String::new()
1955 }
1956 };
1957 let new_dirs: Vec<&str> = path_dirs
1958 .iter()
1959 .filter(|d| !existing.contains(d.as_str()))
1960 .map(|d| d.as_str())
1961 .collect();
1962 if !new_dirs.is_empty() {
1963 let mut content = existing;
1964 if !content.ends_with('\n') && !content.is_empty() {
1965 content.push('\n');
1966 }
1967 content.push_str(&format!(
1968 "export PATH=\"{}:$PATH\"\n",
1969 new_dirs.join(":")
1970 ));
1971 crate::atomic_write_str(&env_path, &content)?;
1972 }
1973 }
1974 }
1975
1976 if pm.is_available() {
1978 pm.update(printer)?;
1979 }
1980
1981 pm.install(&pkg_names, printer)?;
1982 }
1983 }
1984 }
1985
1986 Ok(format!(
1987 "module:{}:packages:{}",
1988 action.module_name,
1989 pkg_names.join(",")
1990 ))
1991 }
1992 ModuleActionKind::DeployFiles { files } => {
1993 for file in files {
1994 let target = expand_tilde(&file.target);
1995 if let Some(parent) = target.parent() {
1996 std::fs::create_dir_all(parent)?;
1997 }
1998
1999 let strategy = file.strategy.unwrap_or(self.registry.default_file_strategy);
2002
2003 if let Ok(Some(file_state)) = crate::capture_file_state(&target)
2005 && let Err(e) = self.state.store_file_backup(
2006 apply_id,
2007 &target.display().to_string(),
2008 &file_state,
2009 )
2010 {
2011 tracing::warn!("failed to backup module file {}: {}", target.display(), e);
2012 }
2013
2014 if target.symlink_metadata().is_ok() {
2016 if target.is_dir() && !target.is_symlink() {
2017 std::fs::remove_dir_all(&target)?;
2018 } else {
2019 std::fs::remove_file(&target)?;
2020 }
2021 }
2022
2023 if file.source.is_dir() {
2024 match strategy {
2025 crate::config::FileStrategy::Symlink => {
2026 crate::create_symlink(&file.source, &target)?;
2027 }
2028 _ => {
2029 crate::copy_dir_recursive(&file.source, &target)?;
2030 }
2031 }
2032 } else if file.source.exists() {
2033 match strategy {
2034 crate::config::FileStrategy::Symlink => {
2035 crate::create_symlink(&file.source, &target)?;
2036 }
2037 crate::config::FileStrategy::Hardlink => {
2038 std::fs::hard_link(&file.source, &target)?;
2039 }
2040 crate::config::FileStrategy::Copy
2041 | crate::config::FileStrategy::Template => {
2042 let content = std::fs::read(&file.source)?;
2043 crate::atomic_write(&target, &content)?;
2044 }
2045 }
2046 }
2047
2048 let hash = if target.exists() && !target.is_symlink() {
2050 match std::fs::read(&target) {
2051 Ok(bytes) => crate::sha256_hex(&bytes),
2052 Err(e) => {
2053 tracing::warn!("cannot read {} for hashing: {e}", target.display());
2054 String::new()
2055 }
2056 }
2057 } else {
2058 String::new()
2059 };
2060 self.state.upsert_module_file(
2061 &action.module_name,
2062 &target.display().to_string(),
2063 &hash,
2064 &format!("{:?}", strategy),
2065 apply_id,
2066 )?;
2067 }
2068
2069 printer.success(&format!(
2070 "Module {}: deployed {} file(s)",
2071 action.module_name,
2072 files.len()
2073 ));
2074
2075 Ok(format!(
2076 "module:{}:files:{}",
2077 action.module_name,
2078 files.len()
2079 ))
2080 }
2081 ModuleActionKind::RunScript {
2082 script,
2083 phase: script_phase,
2084 } => {
2085 let profile_name = resolved
2086 .layers
2087 .last()
2088 .map(|l| l.profile_name.as_str())
2089 .unwrap_or("unknown");
2090 let env_vars = build_script_env(
2091 config_dir,
2092 profile_name,
2093 context,
2094 script_phase,
2095 false,
2096 Some(&action.module_name),
2097 module_dir.as_deref(),
2098 );
2099
2100 let working = module_dir.as_deref().unwrap_or(config_dir);
2101 execute_script(script, working, &env_vars, MODULE_SCRIPT_TIMEOUT, printer)?;
2102
2103 Ok(format!("module:{}:script", action.module_name))
2104 }
2105 ModuleActionKind::Skip { reason } => {
2106 printer.warning(&format!(
2107 "Module {}: skipped — {}",
2108 action.module_name, reason
2109 ));
2110 Ok(format!("module:{}:skip", action.module_name))
2111 }
2112 }
2113 }
2114}
2115
2116pub fn verify(
2118 resolved: &ResolvedProfile,
2119 registry: &ProviderRegistry,
2120 state: &StateStore,
2121 _printer: &Printer,
2122 modules: &[ResolvedModule],
2123) -> Result<Vec<VerifyResult>> {
2124 let mut results = Vec::new();
2125
2126 let available_managers = registry.available_package_managers();
2129 let mut installed_cache: HashMap<String, HashSet<String>> = HashMap::new();
2130 for module in modules {
2131 let mut module_ok = true;
2132
2133 for pkg in &module.packages {
2134 if pkg.manager == "script" {
2137 continue;
2138 }
2139
2140 if !installed_cache.contains_key(&pkg.manager) {
2141 let mgr = available_managers.iter().find(|m| m.name() == pkg.manager);
2142 let set = mgr
2143 .map(|m| m.installed_packages())
2144 .transpose()?
2145 .unwrap_or_default();
2146 installed_cache.insert(pkg.manager.clone(), set);
2147 }
2148 let installed = &installed_cache[&pkg.manager];
2149 let ok = installed.contains(&pkg.resolved_name);
2150
2151 if !ok {
2152 module_ok = false;
2153 results.push(VerifyResult {
2154 resource_type: "module".to_string(),
2155 resource_id: format!("{}/{}", module.name, pkg.resolved_name),
2156 matches: false,
2157 expected: "installed".to_string(),
2158 actual: "missing".to_string(),
2159 });
2160 state
2161 .record_drift(
2162 "module",
2163 &format!("{}/{}", module.name, pkg.resolved_name),
2164 Some("installed"),
2165 Some("missing"),
2166 "local",
2167 )
2168 .ok();
2169 }
2170 }
2171
2172 for file in &module.files {
2174 let target = expand_tilde(&file.target);
2175 if !target.exists() {
2176 module_ok = false;
2177 results.push(VerifyResult {
2178 resource_type: "module".to_string(),
2179 resource_id: format!("{}/{}", module.name, target.display()),
2180 matches: false,
2181 expected: "present".to_string(),
2182 actual: "missing".to_string(),
2183 });
2184 state
2185 .record_drift(
2186 "module",
2187 &format!("{}/{}", module.name, target.display()),
2188 Some("present"),
2189 Some("missing"),
2190 "local",
2191 )
2192 .ok();
2193 }
2194 }
2195
2196 if module_ok {
2197 results.push(VerifyResult {
2198 resource_type: "module".to_string(),
2199 resource_id: module.name.clone(),
2200 matches: true,
2201 expected: "healthy".to_string(),
2202 actual: "healthy".to_string(),
2203 });
2204 }
2205 }
2206
2207 let available_managers = registry.available_package_managers();
2209 for pm in &available_managers {
2210 let desired = crate::config::desired_packages_for(pm.name(), &resolved.merged);
2211 if desired.is_empty() {
2212 continue;
2213 }
2214 let installed = pm.installed_packages()?;
2215 for pkg in &desired {
2216 let ok = installed.contains(pkg);
2217 results.push(VerifyResult {
2218 resource_type: "package".to_string(),
2219 resource_id: format!("{}:{}", pm.name(), pkg),
2220 matches: ok,
2221 expected: "installed".to_string(),
2222 actual: if ok {
2223 "installed".to_string()
2224 } else {
2225 "missing".to_string()
2226 },
2227 });
2228
2229 if !ok {
2230 state
2231 .record_drift(
2232 "package",
2233 &format!("{}:{}", pm.name(), pkg),
2234 Some("installed"),
2235 Some("missing"),
2236 "local",
2237 )
2238 .ok();
2239 }
2240 }
2241 }
2242
2243 for sc in registry.available_system_configurators() {
2245 if let Some(desired) = resolved.merged.system.get(sc.name()) {
2246 let drifts = sc.diff(desired)?;
2247 if drifts.is_empty() {
2248 results.push(VerifyResult {
2249 resource_type: "system".to_string(),
2250 resource_id: sc.name().to_string(),
2251 matches: true,
2252 expected: "configured".to_string(),
2253 actual: "configured".to_string(),
2254 });
2255 } else {
2256 for drift in &drifts {
2257 results.push(VerifyResult {
2258 resource_type: "system".to_string(),
2259 resource_id: format!("{}.{}", sc.name(), drift.key),
2260 matches: false,
2261 expected: drift.expected.clone(),
2262 actual: drift.actual.clone(),
2263 });
2264
2265 state
2266 .record_drift(
2267 "system",
2268 &format!("{}.{}", sc.name(), drift.key),
2269 Some(&drift.expected),
2270 Some(&drift.actual),
2271 "local",
2272 )
2273 .ok();
2274 }
2275 }
2276 }
2277 }
2278
2279 for managed in &resolved.merged.files.managed {
2281 let target = expand_tilde(&managed.target);
2282 if target.exists() {
2283 results.push(VerifyResult {
2284 resource_type: "file".to_string(),
2285 resource_id: target.display().to_string(),
2286 matches: true,
2287 expected: "present".to_string(),
2288 actual: "present".to_string(),
2289 });
2290 } else {
2291 results.push(VerifyResult {
2292 resource_type: "file".to_string(),
2293 resource_id: target.display().to_string(),
2294 matches: false,
2295 expected: "present".to_string(),
2296 actual: "missing".to_string(),
2297 });
2298 }
2299 }
2300
2301 verify_env(
2303 &resolved.merged.env,
2304 &resolved.merged.aliases,
2305 modules,
2306 state,
2307 &mut results,
2308 );
2309
2310 Ok(results)
2311}
2312
2313#[derive(Debug, Clone, Serialize)]
2315pub struct VerifyResult {
2316 pub resource_type: String,
2317 pub resource_id: String,
2318 pub matches: bool,
2319 pub expected: String,
2320 pub actual: String,
2321}
2322
2323fn merge_module_env_aliases(
2324 profile_env: &[crate::config::EnvVar],
2325 profile_aliases: &[crate::config::ShellAlias],
2326 modules: &[ResolvedModule],
2327) -> (Vec<crate::config::EnvVar>, Vec<crate::config::ShellAlias>) {
2328 let mut merged = profile_env.to_vec();
2329 let mut merged_aliases = profile_aliases.to_vec();
2330 for module in modules {
2331 crate::merge_env(&mut merged, &module.env);
2332 crate::merge_aliases(&mut merged_aliases, &module.aliases);
2333 }
2334 (merged, merged_aliases)
2335}
2336
2337fn verify_env(
2343 profile_env: &[crate::config::EnvVar],
2344 profile_aliases: &[crate::config::ShellAlias],
2345 modules: &[ResolvedModule],
2346 state: &StateStore,
2347 results: &mut Vec<VerifyResult>,
2348) {
2349 let (merged, merged_aliases) = merge_module_env_aliases(profile_env, profile_aliases, modules);
2350
2351 if merged.is_empty() && merged_aliases.is_empty() {
2352 return;
2353 }
2354
2355 if cfg!(windows) {
2356 let ps_path = expand_tilde(std::path::Path::new("~/.cfgd-env.ps1"));
2358 let expected_ps = generate_powershell_env_content(&merged, &merged_aliases);
2359 verify_env_file(&ps_path, &expected_ps, state, results);
2360
2361 let ps_profile_dirs = [
2363 expand_tilde(std::path::Path::new("~/Documents/PowerShell")),
2364 expand_tilde(std::path::Path::new("~/Documents/WindowsPowerShell")),
2365 ];
2366 for profile_dir in &ps_profile_dirs {
2367 let profile_path = profile_dir.join("Microsoft.PowerShell_profile.ps1");
2368 let has_line = std::fs::read_to_string(&profile_path)
2369 .map(|content| content.contains("cfgd-env.ps1"))
2370 .unwrap_or(false);
2371 results.push(VerifyResult {
2372 resource_type: "env-rc".to_string(),
2373 resource_id: profile_path.display().to_string(),
2374 matches: has_line,
2375 expected: "source line present".to_string(),
2376 actual: if has_line {
2377 "source line present".to_string()
2378 } else {
2379 "source line missing".to_string()
2380 },
2381 });
2382 if !has_line {
2383 state
2384 .record_drift(
2385 "env-rc",
2386 &profile_path.display().to_string(),
2387 Some("source line present"),
2388 Some("source line missing"),
2389 "local",
2390 )
2391 .ok();
2392 }
2393 }
2394
2395 if crate::command_available("sh") {
2397 let bash_path = expand_tilde(std::path::Path::new("~/.cfgd.env"));
2398 let expected_bash = generate_env_file_content(&merged, &merged_aliases);
2399 verify_env_file(&bash_path, &expected_bash, state, results);
2400 }
2401 } else {
2402 let env_path = expand_tilde(std::path::Path::new("~/.cfgd.env"));
2404 let expected_content = generate_env_file_content(&merged, &merged_aliases);
2405 verify_env_file(&env_path, &expected_content, state, results);
2406
2407 let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
2409 let rc_path = if shell.contains("zsh") {
2410 expand_tilde(std::path::Path::new("~/.zshrc"))
2411 } else {
2412 expand_tilde(std::path::Path::new("~/.bashrc"))
2413 };
2414
2415 let has_source_line = std::fs::read_to_string(&rc_path)
2416 .map(|content| content.contains("cfgd.env"))
2417 .unwrap_or(false);
2418 results.push(VerifyResult {
2419 resource_type: "env-rc".to_string(),
2420 resource_id: rc_path.display().to_string(),
2421 matches: has_source_line,
2422 expected: "source line present".to_string(),
2423 actual: if has_source_line {
2424 "source line present".to_string()
2425 } else {
2426 "source line missing".to_string()
2427 },
2428 });
2429 if !has_source_line {
2430 state
2431 .record_drift(
2432 "env-rc",
2433 &rc_path.display().to_string(),
2434 Some("source line present"),
2435 Some("source line missing"),
2436 "local",
2437 )
2438 .ok();
2439 }
2440 }
2441
2442 let fish_conf_d = expand_tilde(std::path::Path::new("~/.config/fish/conf.d"));
2444 let verify_shell = std::env::var("SHELL").unwrap_or_default();
2445 if verify_shell.contains("fish") && fish_conf_d.exists() {
2446 let fish_path = fish_conf_d.join("cfgd-env.fish");
2447 let expected_fish = generate_fish_env_content(&merged, &merged_aliases);
2448 verify_env_file(&fish_path, &expected_fish, state, results);
2449 }
2450}
2451
2452fn verify_env_file(
2454 path: &std::path::Path,
2455 expected: &str,
2456 state: &StateStore,
2457 results: &mut Vec<VerifyResult>,
2458) {
2459 match std::fs::read_to_string(path) {
2460 Ok(actual) if actual == expected => {
2461 results.push(VerifyResult {
2462 resource_type: "env".to_string(),
2463 resource_id: path.display().to_string(),
2464 matches: true,
2465 expected: "current".to_string(),
2466 actual: "current".to_string(),
2467 });
2468 }
2469 Ok(_) => {
2470 results.push(VerifyResult {
2471 resource_type: "env".to_string(),
2472 resource_id: path.display().to_string(),
2473 matches: false,
2474 expected: "current".to_string(),
2475 actual: "stale".to_string(),
2476 });
2477 state
2478 .record_drift(
2479 "env",
2480 &path.display().to_string(),
2481 Some("current"),
2482 Some("stale"),
2483 "local",
2484 )
2485 .ok();
2486 }
2487 Err(_) => {
2488 results.push(VerifyResult {
2489 resource_type: "env".to_string(),
2490 resource_id: path.display().to_string(),
2491 matches: false,
2492 expected: "present".to_string(),
2493 actual: "missing".to_string(),
2494 });
2495 state
2496 .record_drift(
2497 "env",
2498 &path.display().to_string(),
2499 Some("present"),
2500 Some("missing"),
2501 "local",
2502 )
2503 .ok();
2504 }
2505 }
2506}
2507
2508const MODULE_SCRIPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
2514const ENV_FILE_HEADER: &str = "# managed by cfgd \u{2014} do not edit";
2515
2516pub(crate) fn build_script_env(
2518 config_dir: &std::path::Path,
2519 profile_name: &str,
2520 context: ReconcileContext,
2521 phase: &ScriptPhase,
2522 dry_run: bool,
2523 module_name: Option<&str>,
2524 module_dir: Option<&std::path::Path>,
2525) -> Vec<(String, String)> {
2526 let mut env = vec![
2527 (
2528 "CFGD_CONFIG_DIR".to_string(),
2529 config_dir.display().to_string(),
2530 ),
2531 ("CFGD_PROFILE".to_string(), profile_name.to_string()),
2532 (
2533 "CFGD_CONTEXT".to_string(),
2534 match context {
2535 ReconcileContext::Apply => "apply".to_string(),
2536 ReconcileContext::Reconcile => "reconcile".to_string(),
2537 },
2538 ),
2539 ("CFGD_PHASE".to_string(), phase.display_name().to_string()),
2540 ("CFGD_DRY_RUN".to_string(), dry_run.to_string()),
2541 ];
2542 if let Some(name) = module_name {
2543 env.push(("CFGD_MODULE_NAME".to_string(), name.to_string()));
2544 }
2545 if let Some(dir) = module_dir {
2546 env.push(("CFGD_MODULE_DIR".to_string(), dir.display().to_string()));
2547 }
2548 env
2549}
2550
2551pub(crate) fn execute_script(
2555 entry: &ScriptEntry,
2556 working_dir: &std::path::Path,
2557 env_vars: &[(String, String)],
2558 default_timeout: std::time::Duration,
2559 printer: &Printer,
2560) -> Result<(String, bool, Option<String>)> {
2561 let run_str = entry.run_str();
2562 let effective_timeout = match entry {
2563 ScriptEntry::Full {
2564 timeout: Some(t), ..
2565 } => crate::parse_duration_str(t)
2566 .map_err(|e| crate::errors::CfgdError::Config(ConfigError::Invalid { message: e }))?,
2567 _ => default_timeout,
2568 };
2569 let idle_timeout =
2570 match entry {
2571 ScriptEntry::Full {
2572 idle_timeout: Some(t),
2573 ..
2574 } => Some(crate::parse_duration_str(t).map_err(|e| {
2575 crate::errors::CfgdError::Config(ConfigError::Invalid { message: e })
2576 })?),
2577 _ => None,
2578 };
2579
2580 let resolved = if std::path::Path::new(run_str).is_relative() {
2581 working_dir.join(run_str)
2582 } else {
2583 std::path::PathBuf::from(run_str)
2584 };
2585
2586 let mut cmd = if resolved.exists() {
2587 let meta = std::fs::metadata(&resolved)?;
2589 if !crate::is_executable(&resolved, &meta) {
2590 #[cfg(unix)]
2591 let hint = "chmod +x";
2592 #[cfg(windows)]
2593 let hint = "use a .exe, .cmd, .bat, or .ps1 extension";
2594 return Err(crate::errors::CfgdError::Config(ConfigError::Invalid {
2595 message: format!(
2596 "script '{}' exists but is not executable ({})",
2597 resolved.display(),
2598 hint,
2599 ),
2600 }));
2601 }
2602 let mut c = std::process::Command::new(&resolved);
2603 c.current_dir(working_dir);
2604 #[cfg(unix)]
2605 {
2606 use std::os::unix::process::CommandExt;
2607 c.process_group(0);
2608 }
2609 c
2610 } else {
2611 #[cfg(unix)]
2613 let c = {
2614 use std::os::unix::process::CommandExt;
2615 let mut c = std::process::Command::new("sh");
2616 c.arg("-c")
2617 .arg(run_str)
2618 .current_dir(working_dir)
2619 .process_group(0); c
2621 };
2622 #[cfg(windows)]
2623 let c = {
2624 let mut c = std::process::Command::new("cmd.exe");
2625 c.arg("/C").arg(run_str).current_dir(working_dir);
2626 c
2627 };
2628 c
2629 };
2630
2631 for (key, value) in env_vars {
2633 cmd.env(key, value);
2634 }
2635
2636 let label = format!("Running script: {}", run_str);
2637
2638 cmd.stdin(std::process::Stdio::null());
2640 cmd.stdout(std::process::Stdio::piped());
2641 cmd.stderr(std::process::Stdio::piped());
2642
2643 let mut child = cmd.spawn()?;
2644
2645 let pb = printer.spinner(&label);
2647
2648 let (tx, rx) = std::sync::mpsc::channel::<String>();
2651 let last_output = std::sync::Arc::new(std::sync::Mutex::new(std::time::Instant::now()));
2652 let stdout_buf = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
2653 let stderr_buf = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
2654
2655 let stdout_handle = {
2656 let pipe = child.stdout.take();
2657 let buf = std::sync::Arc::clone(&stdout_buf);
2658 let ts = std::sync::Arc::clone(&last_output);
2659 let tx = tx.clone();
2660 std::thread::spawn(move || {
2661 if let Some(pipe) = pipe {
2662 let reader = std::io::BufReader::new(pipe);
2663 for line in std::io::BufRead::lines(reader) {
2664 match line {
2665 Ok(l) => {
2666 *ts.lock().unwrap_or_else(|e| e.into_inner()) =
2667 std::time::Instant::now();
2668 let mut b = buf.lock().unwrap_or_else(|e| e.into_inner());
2669 if !b.is_empty() {
2670 b.push('\n');
2671 }
2672 b.push_str(&l);
2673 let _ = tx.send(l);
2674 }
2675 Err(_) => break,
2676 }
2677 }
2678 }
2679 })
2680 };
2681
2682 let stderr_handle = {
2683 let pipe = child.stderr.take();
2684 let buf = std::sync::Arc::clone(&stderr_buf);
2685 let ts = std::sync::Arc::clone(&last_output);
2686 let tx = tx.clone();
2687 std::thread::spawn(move || {
2688 if let Some(pipe) = pipe {
2689 let reader = std::io::BufReader::new(pipe);
2690 for line in std::io::BufRead::lines(reader) {
2691 match line {
2692 Ok(l) => {
2693 *ts.lock().unwrap_or_else(|e| e.into_inner()) =
2694 std::time::Instant::now();
2695 let mut b = buf.lock().unwrap_or_else(|e| e.into_inner());
2696 if !b.is_empty() {
2697 b.push('\n');
2698 }
2699 b.push_str(&l);
2700 let _ = tx.send(l);
2701 }
2702 Err(_) => break,
2703 }
2704 }
2705 }
2706 })
2707 };
2708 drop(tx);
2709
2710 const VISIBLE_LINES: usize = 5;
2711 let mut ring: std::collections::VecDeque<String> =
2712 std::collections::VecDeque::with_capacity(VISIBLE_LINES);
2713
2714 let start = std::time::Instant::now();
2715 loop {
2716 while let Ok(line) = rx.try_recv() {
2718 if ring.len() >= VISIBLE_LINES {
2719 ring.pop_front();
2720 }
2721 ring.push_back(line);
2722 }
2723 if !ring.is_empty() {
2724 let mut msg = label.clone();
2725 for l in &ring {
2726 let display = if l.len() > 120 {
2727 l.get(..120).unwrap_or(l)
2728 } else {
2729 l.as_str()
2730 };
2731 msg.push_str(&format!("\n {}", display));
2732 }
2733 pb.set_message(msg);
2734 }
2735
2736 match child.try_wait()? {
2737 Some(status) => {
2738 let _ = stdout_handle.join();
2740 let _ = stderr_handle.join();
2741
2742 let stdout_str = std::sync::Arc::try_unwrap(stdout_buf)
2743 .ok()
2744 .and_then(|m| m.into_inner().ok())
2745 .unwrap_or_default();
2746 let stderr_str = std::sync::Arc::try_unwrap(stderr_buf)
2747 .ok()
2748 .and_then(|m| m.into_inner().ok())
2749 .unwrap_or_default();
2750
2751 let captured = combine_script_output(&stdout_str, &stderr_str);
2752
2753 if !status.success() {
2754 pb.finish_and_clear();
2755 let exit_code = status.code().unwrap_or(-1);
2756 return Err(crate::errors::CfgdError::Config(ConfigError::Invalid {
2757 message: format!(
2758 "script '{}' failed (exit {})\n{}",
2759 run_str,
2760 exit_code,
2761 captured.as_deref().unwrap_or("")
2762 ),
2763 }));
2764 }
2765
2766 let elapsed = start.elapsed();
2767 pb.finish_and_clear();
2768 printer.success(&format!("{} ({}s)", run_str, elapsed.as_secs()));
2769 return Ok((label, true, captured));
2770 }
2771 None => {
2772 let elapsed = start.elapsed();
2773 let mut kill_reason = None;
2774 if elapsed > effective_timeout {
2776 kill_reason = Some(("timed out", effective_timeout));
2777 }
2778 if kill_reason.is_none()
2780 && let Some(idle_dur) = idle_timeout
2781 {
2782 let last = *last_output.lock().unwrap_or_else(|e| e.into_inner());
2783 if last.elapsed() > idle_dur {
2784 kill_reason = Some(("idle (no output)", idle_dur));
2785 }
2786 }
2787 if let Some((reason, duration)) = kill_reason {
2788 pb.finish_and_clear();
2789 kill_script_child(&mut child);
2790 let _ = stdout_handle.join();
2792 let _ = stderr_handle.join();
2793 let stdout_str = std::sync::Arc::try_unwrap(stdout_buf)
2794 .ok()
2795 .and_then(|m| m.into_inner().ok())
2796 .unwrap_or_default();
2797 let stderr_str = std::sync::Arc::try_unwrap(stderr_buf)
2798 .ok()
2799 .and_then(|m| m.into_inner().ok())
2800 .unwrap_or_default();
2801 let captured = combine_script_output(&stdout_str, &stderr_str);
2802 return Err(crate::errors::CfgdError::Config(ConfigError::Invalid {
2803 message: format!(
2804 "script '{}' {} after {}s\n{}",
2805 run_str,
2806 reason,
2807 duration.as_secs(),
2808 captured.as_deref().unwrap_or("")
2809 ),
2810 }));
2811 }
2812 std::thread::sleep(std::time::Duration::from_millis(100));
2813 }
2814 }
2815 }
2816}
2817
2818fn kill_script_child(child: &mut std::process::Child) {
2820 #[cfg(unix)]
2821 {
2822 use nix::sys::signal::{Signal, kill};
2823 use nix::unistd::Pid;
2824 let _ = kill(Pid::from_raw(-(child.id() as i32)), Signal::SIGTERM);
2826 }
2827 #[cfg(not(unix))]
2828 {
2829 crate::terminate_process(child.id());
2830 }
2831 std::thread::sleep(std::time::Duration::from_secs(5));
2832 let _ = child.kill();
2833 let _ = child.wait();
2834}
2835
2836fn default_continue_on_error(phase: &ScriptPhase) -> bool {
2839 match phase {
2840 ScriptPhase::PreApply | ScriptPhase::PreReconcile => false,
2841 ScriptPhase::PostApply
2842 | ScriptPhase::PostReconcile
2843 | ScriptPhase::OnChange
2844 | ScriptPhase::OnDrift => true,
2845 }
2846}
2847
2848fn effective_continue_on_error(entry: &ScriptEntry, phase: &ScriptPhase) -> bool {
2850 match entry {
2851 ScriptEntry::Full {
2852 continue_on_error: Some(v),
2853 ..
2854 } => *v,
2855 _ => default_continue_on_error(phase),
2856 }
2857}
2858
2859fn combine_script_output(stdout: &str, stderr: &str) -> Option<String> {
2862 let stdout = stdout.trim();
2863 let stderr = stderr.trim();
2864 if stdout.is_empty() && stderr.is_empty() {
2865 return None;
2866 }
2867 let mut out = String::new();
2868 if !stdout.is_empty() {
2869 out.push_str(stdout);
2870 }
2871 if !stderr.is_empty() {
2872 if !out.is_empty() {
2873 out.push_str("\n--- stderr ---\n");
2874 }
2875 out.push_str(stderr);
2876 }
2877 Some(out)
2878}
2879
2880pub fn format_action_description(action: &Action) -> String {
2882 match action {
2883 Action::File(fa) => match fa {
2884 FileAction::Create { target, .. } => format!("file:create:{}", target.display()),
2885 FileAction::Update { target, .. } => format!("file:update:{}", target.display()),
2886 FileAction::Delete { target, .. } => format!("file:delete:{}", target.display()),
2887 FileAction::SetPermissions { target, mode, .. } => {
2888 format!("file:chmod:{:#o}:{}", mode, target.display())
2889 }
2890 FileAction::Skip { target, .. } => format!("file:skip:{}", target.display()),
2891 },
2892 Action::Package(pa) => match pa {
2893 PackageAction::Bootstrap { manager, .. } => {
2894 format!("package:{}:bootstrap", manager)
2895 }
2896 PackageAction::Install {
2897 manager, packages, ..
2898 } => format!("package:{}:install:{}", manager, packages.join(",")),
2899 PackageAction::Uninstall {
2900 manager, packages, ..
2901 } => format!("package:{}:uninstall:{}", manager, packages.join(",")),
2902 PackageAction::Skip { manager, .. } => format!("package:{}:skip", manager),
2903 },
2904 Action::Secret(sa) => match sa {
2905 SecretAction::Decrypt {
2906 target, backend, ..
2907 } => format!("secret:decrypt:{}:{}", backend, target.display()),
2908 SecretAction::Resolve {
2909 provider,
2910 reference,
2911 target,
2912 ..
2913 } => format!(
2914 "secret:resolve:{}:{}:{}",
2915 provider,
2916 reference,
2917 target.display()
2918 ),
2919 SecretAction::ResolveEnv {
2920 provider,
2921 reference,
2922 envs,
2923 ..
2924 } => format!(
2925 "secret:resolve-env:{}:{}:[{}]",
2926 provider,
2927 reference,
2928 envs.join(",")
2929 ),
2930 SecretAction::Skip { source, .. } => format!("secret:skip:{}", source),
2931 },
2932 Action::System(sa) => match sa {
2933 SystemAction::SetValue {
2934 configurator, key, ..
2935 } => format!("system:{}.{}", configurator, key),
2936 SystemAction::Skip { configurator, .. } => {
2937 format!("system:{}:skip", configurator)
2938 }
2939 },
2940 Action::Script(sa) => match sa {
2941 ScriptAction::Run { entry, phase, .. } => {
2942 format!("script:{}:{}", phase.display_name(), entry.run_str())
2943 }
2944 },
2945 Action::Module(ma) => match &ma.kind {
2946 ModuleActionKind::InstallPackages { resolved } => {
2947 let names: Vec<&str> = resolved.iter().map(|p| p.resolved_name.as_str()).collect();
2948 format!("module:{}:packages:{}", ma.module_name, names.join(","))
2949 }
2950 ModuleActionKind::DeployFiles { files } => {
2951 format!("module:{}:files:{}", ma.module_name, files.len())
2952 }
2953 ModuleActionKind::RunScript { .. } => {
2954 format!("module:{}:script", ma.module_name)
2955 }
2956 ModuleActionKind::Skip { .. } => {
2957 format!("module:{}:skip", ma.module_name)
2958 }
2959 },
2960 Action::Env(ea) => match ea {
2961 EnvAction::WriteEnvFile { path, .. } => {
2962 format!("env:write:{}", path.display())
2963 }
2964 EnvAction::InjectSourceLine { rc_path, .. } => {
2965 format!("env:inject:{}", rc_path.display())
2966 }
2967 },
2968 }
2969}
2970
2971enum RestoreOutcome {
2973 Restored,
2974 Removed,
2975 Skipped,
2976 Failed,
2977}
2978
2979fn restore_file_from_backup(
2981 target: &std::path::Path,
2982 bk: &crate::state::FileBackupRecord,
2983 printer: &crate::output::Printer,
2984) -> RestoreOutcome {
2985 if !bk.oversized && !bk.content.is_empty() {
2988 if let Ok(Some(current)) = crate::capture_file_resolved_state(target)
2990 && current.content == bk.content
2991 {
2992 return RestoreOutcome::Skipped;
2993 }
2994 if target.symlink_metadata().is_ok() {
2996 let _ = std::fs::remove_file(target);
2997 }
2998 if let Some(parent) = target.parent() {
2999 let _ = std::fs::create_dir_all(parent);
3000 }
3001 if let Err(e) = crate::atomic_write(target, &bk.content) {
3002 printer.warning(&format!(
3003 "rollback: failed to restore {}: {}",
3004 target.display(),
3005 e
3006 ));
3007 return RestoreOutcome::Failed;
3008 }
3009 if let Some(mode) = bk.permissions {
3011 let _ = crate::set_file_permissions(target, mode);
3012 }
3013 return RestoreOutcome::Restored;
3014 }
3015
3016 if bk.was_symlink
3018 && let Some(ref link_target) = bk.symlink_target
3019 {
3020 let _ = std::fs::remove_file(target);
3021 if let Err(e) = crate::create_symlink(std::path::Path::new(link_target), target) {
3022 printer.warning(&format!(
3023 "rollback: failed to restore symlink {}: {}",
3024 target.display(),
3025 e
3026 ));
3027 return RestoreOutcome::Failed;
3028 }
3029 return RestoreOutcome::Restored;
3030 }
3031
3032 if bk.content.is_empty() && !bk.was_symlink && !bk.oversized && target.exists() {
3034 if let Err(e) = std::fs::remove_file(target) {
3035 printer.warning(&format!(
3036 "rollback: failed to remove {}: {}",
3037 target.display(),
3038 e
3039 ));
3040 return RestoreOutcome::Failed;
3041 }
3042 return RestoreOutcome::Removed;
3043 }
3044
3045 RestoreOutcome::Skipped
3046}
3047
3048fn action_target_path(action: &Action) -> Option<std::path::PathBuf> {
3051 match action {
3052 Action::File(
3053 FileAction::Create { target, .. }
3054 | FileAction::Update { target, .. }
3055 | FileAction::Delete { target, .. },
3056 ) => Some(target.clone()),
3057 Action::Env(EnvAction::WriteEnvFile { path, .. }) => Some(path.clone()),
3058 _ => None,
3060 }
3061}
3062
3063fn generate_env_file_content(
3065 env: &[crate::config::EnvVar],
3066 aliases: &[crate::config::ShellAlias],
3067) -> String {
3068 let mut lines = vec![ENV_FILE_HEADER.to_string()];
3069 for ev in env {
3070 if crate::validate_env_var_name(&ev.name).is_err() {
3071 tracing::warn!("skipping env var with unsafe name: {}", ev.name);
3072 continue;
3073 }
3074 lines.push(format!(
3075 "export {}=\"{}\"",
3076 ev.name,
3077 crate::escape_double_quoted(&ev.value)
3078 ));
3079 }
3080 for alias in aliases {
3081 if crate::validate_alias_name(&alias.name).is_err() {
3082 tracing::warn!("skipping alias with unsafe name: {}", alias.name);
3083 continue;
3084 }
3085 lines.push(format!(
3086 "alias {}=\"{}\"",
3087 alias.name,
3088 crate::escape_double_quoted(&alias.command)
3089 ));
3090 }
3091 lines.push(String::new()); lines.join("\n")
3093}
3094
3095fn generate_fish_env_content(
3097 env: &[crate::config::EnvVar],
3098 aliases: &[crate::config::ShellAlias],
3099) -> String {
3100 let mut lines = vec![ENV_FILE_HEADER.to_string()];
3101 for ev in env {
3102 if crate::validate_env_var_name(&ev.name).is_err() {
3103 tracing::warn!("skipping env var with unsafe name: {}", ev.name);
3104 continue;
3105 }
3106 if ev.name == "PATH" {
3107 let parts: Vec<String> = ev
3110 .value
3111 .split(':')
3112 .map(|p| format!("'{}'", p.replace('\'', "\\'")))
3113 .collect();
3114 lines.push(format!("set -gx PATH {}", parts.join(" ")));
3115 } else {
3116 lines.push(format!(
3118 "set -gx {} '{}'",
3119 ev.name,
3120 ev.value.replace('\'', "\\'")
3121 ));
3122 }
3123 }
3124 for alias in aliases {
3125 if crate::validate_alias_name(&alias.name).is_err() {
3126 tracing::warn!("skipping alias with unsafe name: {}", alias.name);
3127 continue;
3128 }
3129 lines.push(format!(
3130 "abbr -a {} '{}'",
3131 alias.name,
3132 alias.command.replace('\'', "\\'")
3133 ));
3134 }
3135 lines.push(String::new());
3136 lines.join("\n")
3137}
3138
3139fn generate_powershell_env_content(
3141 env: &[crate::config::EnvVar],
3142 aliases: &[crate::config::ShellAlias],
3143) -> String {
3144 let mut lines = vec![ENV_FILE_HEADER.to_string()];
3145 for ev in env {
3146 if crate::validate_env_var_name(&ev.name).is_err() {
3147 tracing::warn!("skipping env var with unsafe name: {}", ev.name);
3148 continue;
3149 }
3150 if ev.value.contains("$env:") {
3151 lines.push(format!(
3153 "$env:{} = \"{}\"",
3154 ev.name,
3155 ev.value.replace('"', "`\"")
3156 ));
3157 } else {
3158 lines.push(format!(
3160 "$env:{} = '{}'",
3161 ev.name,
3162 ev.value.replace('\'', "''")
3163 ));
3164 }
3165 }
3166 for alias in aliases {
3167 if crate::validate_alias_name(&alias.name).is_err() {
3168 tracing::warn!("skipping alias with unsafe name: {}", alias.name);
3169 continue;
3170 }
3171 if alias.command.split_whitespace().count() == 1 {
3172 lines.push(format!(
3174 "Set-Alias -Name {} -Value {}",
3175 alias.name, alias.command
3176 ));
3177 } else {
3178 lines.push(format!(
3180 "function {} {{ {} @args }}",
3181 alias.name, alias.command
3182 ));
3183 }
3184 }
3185 lines.push(String::new()); lines.join("\n")
3187}
3188
3189fn detect_rc_env_conflicts(
3193 rc_path: &std::path::Path,
3194 env: &[crate::config::EnvVar],
3195 aliases: &[crate::config::ShellAlias],
3196) -> Vec<String> {
3197 let rc_content = match std::fs::read_to_string(rc_path) {
3198 Ok(c) => c,
3199 Err(_) => return Vec::new(),
3200 };
3201
3202 let mut before_lines = Vec::new();
3204 for line in rc_content.lines() {
3205 if line.contains("cfgd.env") {
3206 break;
3207 }
3208 before_lines.push(line);
3209 }
3210
3211 let rc_display = rc_path.display();
3212 let mut warnings = Vec::new();
3213
3214 let env_map: HashMap<&str, &str> = env
3216 .iter()
3217 .map(|e| (e.name.as_str(), e.value.as_str()))
3218 .collect();
3219 let alias_map: HashMap<&str, &str> = aliases
3220 .iter()
3221 .map(|a| (a.name.as_str(), a.command.as_str()))
3222 .collect();
3223
3224 for line in &before_lines {
3225 let trimmed = line.trim();
3226
3227 if let Some(rest) = trimmed.strip_prefix("export ")
3229 && let Some((name, raw_value)) = rest.split_once('=')
3230 {
3231 let name = name.trim();
3232 let value = strip_shell_quotes(raw_value);
3233 if let Some(&cfgd_value) = env_map.get(name)
3234 && value != cfgd_value
3235 {
3236 warnings.push(format!(
3237 "{} sets export {}={} before cfgd source line — cfgd will override to \"{}\"; move it after the source line to keep your value",
3238 rc_display, name, raw_value, cfgd_value,
3239 ));
3240 }
3241 }
3242
3243 if let Some(rest) = trimmed.strip_prefix("alias ")
3245 && let Some((name, raw_value)) = rest.split_once('=')
3246 {
3247 let name = name.trim();
3248 let value = strip_shell_quotes(raw_value);
3249 if let Some(&cfgd_value) = alias_map.get(name)
3250 && value != cfgd_value
3251 {
3252 warnings.push(format!(
3253 "{} sets alias {}={} before cfgd source line — cfgd will override to \"{}\"; move it after the source line to keep your value",
3254 rc_display, name, raw_value, cfgd_value,
3255 ));
3256 }
3257 }
3258 }
3259
3260 warnings
3261}
3262
3263fn strip_shell_quotes(s: &str) -> &str {
3265 let s = s.trim();
3266 if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
3267 &s[1..s.len() - 1]
3268 } else {
3269 s
3270 }
3271}
3272
3273fn content_hash_if_exists(path: &std::path::Path) -> Option<String> {
3274 std::fs::read(path)
3275 .ok()
3276 .map(|bytes| crate::sha256_hex(&bytes))
3277}
3278
3279fn provenance_suffix(origin: &str) -> String {
3281 if origin.is_empty() || origin == "local" {
3282 String::new()
3283 } else {
3284 format!(" <- {origin}")
3285 }
3286}
3287
3288pub fn format_plan_items(phase: &Phase) -> Vec<String> {
3290 phase
3291 .actions
3292 .iter()
3293 .map(|action| match action {
3294 Action::File(fa) => match fa {
3295 FileAction::Create { target, origin, .. } => {
3296 format!("create {}{}", target.display(), provenance_suffix(origin))
3297 }
3298 FileAction::Update { target, origin, .. } => {
3299 format!("update {}{}", target.display(), provenance_suffix(origin))
3300 }
3301 FileAction::Delete { target, origin, .. } => {
3302 format!("delete {}{}", target.display(), provenance_suffix(origin))
3303 }
3304 FileAction::SetPermissions {
3305 target,
3306 mode,
3307 origin,
3308 ..
3309 } => format!(
3310 "chmod {:#o} {}{}",
3311 mode,
3312 target.display(),
3313 provenance_suffix(origin)
3314 ),
3315 FileAction::Skip {
3316 target,
3317 reason,
3318 origin,
3319 ..
3320 } => format!(
3321 "skip {}: {}{}",
3322 target.display(),
3323 reason,
3324 provenance_suffix(origin)
3325 ),
3326 },
3327 Action::Package(pa) => match pa {
3328 PackageAction::Bootstrap {
3329 manager,
3330 method,
3331 origin,
3332 ..
3333 } => format!(
3334 "bootstrap {} via {}{}",
3335 manager,
3336 method,
3337 provenance_suffix(origin)
3338 ),
3339 PackageAction::Install {
3340 manager,
3341 packages,
3342 origin,
3343 ..
3344 } => format!(
3345 "install via {}: {}{}",
3346 manager,
3347 packages.join(", "),
3348 provenance_suffix(origin)
3349 ),
3350 PackageAction::Uninstall {
3351 manager,
3352 packages,
3353 origin,
3354 ..
3355 } => format!(
3356 "uninstall via {}: {}{}",
3357 manager,
3358 packages.join(", "),
3359 provenance_suffix(origin)
3360 ),
3361 PackageAction::Skip {
3362 manager,
3363 reason,
3364 origin,
3365 ..
3366 } => format!("skip {}: {}{}", manager, reason, provenance_suffix(origin)),
3367 },
3368 Action::Secret(sa) => match sa {
3369 SecretAction::Decrypt {
3370 source,
3371 target,
3372 backend,
3373 origin,
3374 ..
3375 } => format!(
3376 "decrypt {} → {} (via {}){}",
3377 source.display(),
3378 target.display(),
3379 backend,
3380 provenance_suffix(origin)
3381 ),
3382 SecretAction::Resolve {
3383 provider,
3384 reference,
3385 target,
3386 origin,
3387 ..
3388 } => format!(
3389 "resolve {}://{} → {}{}",
3390 provider,
3391 reference,
3392 target.display(),
3393 provenance_suffix(origin)
3394 ),
3395 SecretAction::ResolveEnv {
3396 provider,
3397 reference,
3398 envs,
3399 origin,
3400 ..
3401 } => format!(
3402 "resolve {}://{} → env [{}]{}",
3403 provider,
3404 reference,
3405 envs.join(", "),
3406 provenance_suffix(origin)
3407 ),
3408 SecretAction::Skip {
3409 source,
3410 reason,
3411 origin,
3412 ..
3413 } => format!("skip {}: {}{}", source, reason, provenance_suffix(origin)),
3414 },
3415 Action::System(sa) => match sa {
3416 SystemAction::SetValue {
3417 configurator,
3418 key,
3419 desired,
3420 current,
3421 origin,
3422 ..
3423 } => format!(
3424 "set {}.{}: {} → {}{}",
3425 configurator,
3426 key,
3427 current,
3428 desired,
3429 provenance_suffix(origin)
3430 ),
3431 SystemAction::Skip {
3432 configurator,
3433 reason,
3434 ..
3435 } => format!("skip {}: {}", configurator, reason),
3436 },
3437 Action::Script(sa) => match sa {
3438 ScriptAction::Run {
3439 entry,
3440 phase,
3441 origin,
3442 ..
3443 } => {
3444 format!(
3445 "run {} script: {}{}",
3446 phase.display_name(),
3447 entry.run_str(),
3448 provenance_suffix(origin)
3449 )
3450 }
3451 },
3452 Action::Module(ma) => format_module_action_item(ma),
3453 Action::Env(ea) => match ea {
3454 EnvAction::WriteEnvFile { path, .. } => {
3455 format!("write {}", path.display())
3456 }
3457 EnvAction::InjectSourceLine { rc_path, .. } => {
3458 format!("inject source line into {}", rc_path.display())
3459 }
3460 },
3461 })
3462 .collect()
3463}
3464
3465fn format_module_action_item(action: &ModuleAction) -> String {
3467 match &action.kind {
3468 ModuleActionKind::InstallPackages { resolved } => {
3469 let mut by_manager: HashMap<&str, Vec<String>> = HashMap::new();
3471 for pkg in resolved {
3472 let display = if let Some(ref ver) = pkg.version {
3473 if pkg.canonical_name != pkg.resolved_name {
3474 format!(
3475 "{} ({}, alias: {})",
3476 pkg.resolved_name, ver, pkg.canonical_name
3477 )
3478 } else {
3479 format!("{} ({})", pkg.resolved_name, ver)
3480 }
3481 } else if pkg.canonical_name != pkg.resolved_name {
3482 format!("{} (alias: {})", pkg.resolved_name, pkg.canonical_name)
3483 } else {
3484 pkg.resolved_name.clone()
3485 };
3486 by_manager.entry(&pkg.manager).or_default().push(display);
3487 }
3488 let parts: Vec<String> = by_manager
3489 .iter()
3490 .map(|(mgr, pkgs)| format!("{} install {}", mgr, pkgs.join(", ")))
3491 .collect();
3492 format!("[{}] {}", action.module_name, parts.join("; "))
3493 }
3494 ModuleActionKind::DeployFiles { files } => {
3495 let targets: Vec<String> = files
3496 .iter()
3497 .map(|f| f.target.display().to_string())
3498 .collect();
3499 if targets.len() <= 3 {
3500 format!("[{}] deploy: {}", action.module_name, targets.join(", "))
3501 } else {
3502 format!(
3503 "[{}] deploy: {} ({} files)",
3504 action.module_name,
3505 targets[..2].join(", "),
3506 targets.len()
3507 )
3508 }
3509 }
3510 ModuleActionKind::RunScript { script, phase } => {
3511 format!(
3512 "[{}] {}: {}",
3513 action.module_name,
3514 phase.display_name(),
3515 script.run_str()
3516 )
3517 }
3518 ModuleActionKind::Skip { reason } => {
3519 format!("[{}] skip: {}", action.module_name, reason)
3520 }
3521 }
3522}
3523
3524fn parse_resource_from_description(desc: &str) -> (String, String) {
3525 let parts: Vec<&str> = desc.splitn(3, ':').collect();
3526 if parts.len() >= 3 {
3527 (parts[0].to_string(), parts[2..].join(":"))
3528 } else if parts.len() == 2 {
3529 (parts[0].to_string(), parts[1].to_string())
3530 } else {
3531 ("unknown".to_string(), desc.to_string())
3532 }
3533}
3534
3535fn apply_file_action_direct(
3536 action: &FileAction,
3537 _config_dir: &std::path::Path,
3538 _profile: &MergedProfile,
3539) -> Result<()> {
3540 match action {
3541 FileAction::Create {
3542 source,
3543 target,
3544 strategy,
3545 ..
3546 }
3547 | FileAction::Update {
3548 source,
3549 target,
3550 strategy,
3551 ..
3552 } => {
3553 if let Some(parent) = target.parent() {
3554 std::fs::create_dir_all(parent)?;
3555 }
3556 if target.symlink_metadata().is_ok() {
3558 std::fs::remove_file(target)?;
3559 }
3560 match strategy {
3561 crate::config::FileStrategy::Symlink => {
3562 crate::create_symlink(source, target)?;
3563 }
3564 crate::config::FileStrategy::Hardlink => {
3565 std::fs::hard_link(source, target)?;
3566 }
3567 crate::config::FileStrategy::Copy | crate::config::FileStrategy::Template => {
3568 std::fs::copy(source, target)?;
3569 }
3570 }
3571 Ok(())
3572 }
3573 FileAction::Delete { target, .. } => {
3574 if target.exists() {
3575 std::fs::remove_file(target)?;
3576 }
3577 Ok(())
3578 }
3579 FileAction::SetPermissions { target, mode, .. } => {
3580 crate::set_file_permissions(target, *mode)?;
3581 Ok(())
3582 }
3583 FileAction::Skip { .. } => Ok(()),
3584 }
3585}
3586
3587impl FileAction {
3589 fn clone_action(&self) -> FileAction {
3590 match self {
3591 FileAction::Create {
3592 source,
3593 target,
3594 origin,
3595 strategy,
3596 source_hash,
3597 } => FileAction::Create {
3598 source: source.clone(),
3599 target: target.clone(),
3600 origin: origin.clone(),
3601 strategy: *strategy,
3602 source_hash: source_hash.clone(),
3603 },
3604 FileAction::Update {
3605 source,
3606 target,
3607 diff,
3608 origin,
3609 strategy,
3610 source_hash,
3611 } => FileAction::Update {
3612 source: source.clone(),
3613 target: target.clone(),
3614 diff: diff.clone(),
3615 origin: origin.clone(),
3616 strategy: *strategy,
3617 source_hash: source_hash.clone(),
3618 },
3619 FileAction::Delete { target, origin } => FileAction::Delete {
3620 target: target.clone(),
3621 origin: origin.clone(),
3622 },
3623 FileAction::SetPermissions {
3624 target,
3625 mode,
3626 origin,
3627 } => FileAction::SetPermissions {
3628 target: target.clone(),
3629 mode: *mode,
3630 origin: origin.clone(),
3631 },
3632 FileAction::Skip {
3633 target,
3634 reason,
3635 origin,
3636 } => FileAction::Skip {
3637 target: target.clone(),
3638 reason: reason.clone(),
3639 origin: origin.clone(),
3640 },
3641 }
3642 }
3643}
3644
3645#[cfg(test)]
3646mod tests {
3647 use super::*;
3648 use std::collections::HashSet;
3649 use std::path::Path;
3650
3651 use crate::config::*;
3652 use crate::providers::PackageManager;
3653
3654 use crate::providers::StubPackageManager as MockPackageManager;
3655 use crate::test_helpers::{
3656 make_empty_resolved, make_resolved_module, test_printer, test_state,
3657 };
3658
3659 #[test]
3660 fn empty_plan_has_eight_phases() {
3661 let state = test_state();
3662 let registry = ProviderRegistry::new();
3663 let reconciler = Reconciler::new(®istry, &state);
3664 let resolved = make_empty_resolved();
3665
3666 let plan = reconciler
3667 .plan(
3668 &resolved,
3669 Vec::new(),
3670 Vec::new(),
3671 Vec::new(),
3672 ReconcileContext::Apply,
3673 )
3674 .unwrap();
3675
3676 assert_eq!(plan.phases.len(), 8);
3677 assert!(plan.is_empty());
3678 }
3679
3680 #[test]
3681 fn plan_includes_package_actions() {
3682 let state = test_state();
3683 let registry = ProviderRegistry::new();
3684 let reconciler = Reconciler::new(®istry, &state);
3685 let resolved = make_empty_resolved();
3686
3687 let pkg_actions = vec![PackageAction::Install {
3688 manager: "brew".to_string(),
3689 packages: vec!["ripgrep".to_string()],
3690 origin: "local".to_string(),
3691 }];
3692
3693 let plan = reconciler
3694 .plan(
3695 &resolved,
3696 Vec::new(),
3697 pkg_actions,
3698 Vec::new(),
3699 ReconcileContext::Apply,
3700 )
3701 .unwrap();
3702
3703 assert!(!plan.is_empty());
3704 assert_eq!(plan.total_actions(), 1);
3705 }
3706
3707 #[test]
3708 fn plan_includes_file_actions() {
3709 let state = test_state();
3710 let registry = ProviderRegistry::new();
3711 let reconciler = Reconciler::new(®istry, &state);
3712 let resolved = make_empty_resolved();
3713
3714 let file_actions = vec![FileAction::Create {
3715 source: PathBuf::from("/src/test"),
3716 target: PathBuf::from("/dst/test"),
3717 origin: "local".to_string(),
3718 strategy: crate::config::FileStrategy::default(),
3719 source_hash: None,
3720 }];
3721
3722 let plan = reconciler
3723 .plan(
3724 &resolved,
3725 file_actions,
3726 Vec::new(),
3727 Vec::new(),
3728 ReconcileContext::Apply,
3729 )
3730 .unwrap();
3731
3732 assert!(!plan.is_empty());
3733 assert_eq!(plan.total_actions(), 1);
3734 }
3735
3736 #[test]
3737 fn plan_includes_script_actions() {
3738 let state = test_state();
3739 let registry = ProviderRegistry::new();
3740 let reconciler = Reconciler::new(®istry, &state);
3741
3742 let mut resolved = make_empty_resolved();
3743 resolved.merged.scripts.pre_reconcile =
3744 vec![ScriptEntry::Simple("scripts/pre.sh".to_string())];
3745 resolved.merged.scripts.post_reconcile =
3746 vec![ScriptEntry::Simple("scripts/post.sh".to_string())];
3747
3748 let plan = reconciler
3749 .plan(
3750 &resolved,
3751 Vec::new(),
3752 Vec::new(),
3753 Vec::new(),
3754 ReconcileContext::Reconcile,
3755 )
3756 .unwrap();
3757
3758 let pre_phase = plan
3760 .phases
3761 .iter()
3762 .find(|p| p.name == PhaseName::PreScripts)
3763 .unwrap();
3764 assert_eq!(pre_phase.actions.len(), 1);
3765
3766 let post_phase = plan
3768 .phases
3769 .iter()
3770 .find(|p| p.name == PhaseName::PostScripts)
3771 .unwrap();
3772 assert_eq!(post_phase.actions.len(), 1);
3773 }
3774
3775 #[test]
3776 fn apply_empty_plan_records_success() {
3777 let state = test_state();
3778 let registry = ProviderRegistry::new();
3779 let reconciler = Reconciler::new(®istry, &state);
3780 let resolved = make_empty_resolved();
3781
3782 let plan = reconciler
3783 .plan(
3784 &resolved,
3785 Vec::new(),
3786 Vec::new(),
3787 Vec::new(),
3788 ReconcileContext::Apply,
3789 )
3790 .unwrap();
3791
3792 let printer = test_printer();
3793 let result = reconciler
3794 .apply(
3795 &plan,
3796 &resolved,
3797 Path::new("."),
3798 &printer,
3799 None,
3800 &[],
3801 ReconcileContext::Apply,
3802 false,
3803 )
3804 .unwrap();
3805
3806 assert_eq!(result.status, ApplyStatus::Success);
3808 assert_eq!(result.action_results.len(), 0);
3809 }
3810
3811 #[test]
3812 fn phase_name_roundtrip() {
3813 for name in &[
3814 PhaseName::PreScripts,
3815 PhaseName::Env,
3816 PhaseName::Modules,
3817 PhaseName::Packages,
3818 PhaseName::System,
3819 PhaseName::Files,
3820 PhaseName::Secrets,
3821 PhaseName::PostScripts,
3822 ] {
3823 let s = name.as_str();
3824 let parsed = PhaseName::from_str(s).unwrap();
3825 assert_eq!(&parsed, name);
3826 }
3827 }
3828
3829 #[test]
3830 fn format_plan_items_for_display() {
3831 let phase = Phase {
3832 name: PhaseName::Packages,
3833 actions: vec![
3834 Action::Package(PackageAction::Install {
3835 manager: "brew".to_string(),
3836 packages: vec!["ripgrep".to_string(), "fd".to_string()],
3837 origin: "local".to_string(),
3838 }),
3839 Action::Package(PackageAction::Skip {
3840 manager: "apt".to_string(),
3841 reason: "not available".to_string(),
3842 origin: "local".to_string(),
3843 }),
3844 ],
3845 };
3846
3847 let items = format_plan_items(&phase);
3848 assert_eq!(items.len(), 2); assert!(items[0].contains("ripgrep"));
3850 assert!(items[1].contains("skip apt: not available"));
3851 }
3852
3853 #[test]
3854 fn verify_returns_results() {
3855 let state = test_state();
3856 let mut registry = ProviderRegistry::new();
3857
3858 registry.package_managers.push(Box::new(
3859 MockPackageManager::new("cargo").with_installed(&["ripgrep"]),
3860 ));
3861
3862 let mut resolved = make_empty_resolved();
3863 resolved.merged.packages.cargo = Some(crate::config::CargoSpec {
3864 file: None,
3865 packages: vec!["ripgrep".to_string(), "bat".to_string()],
3866 });
3867
3868 let printer = test_printer();
3869 let results = verify(&resolved, ®istry, &state, &printer, &[]).unwrap();
3870
3871 let rg = results
3873 .iter()
3874 .find(|r| r.resource_id == "cargo:ripgrep")
3875 .unwrap();
3876 assert!(rg.matches);
3877
3878 let bat = results
3879 .iter()
3880 .find(|r| r.resource_id == "cargo:bat")
3881 .unwrap();
3882 assert!(!bat.matches);
3883 }
3884
3885 #[test]
3886 fn plan_hash_string() {
3887 let plan = Plan {
3888 phases: vec![Phase {
3889 name: PhaseName::Packages,
3890 actions: vec![Action::Package(PackageAction::Install {
3891 manager: "brew".to_string(),
3892 packages: vec!["ripgrep".to_string()],
3893 origin: "local".to_string(),
3894 })],
3895 }],
3896 warnings: vec![],
3897 };
3898 let hash = plan.to_hash_string();
3899 assert!(!hash.is_empty());
3900 assert_eq!(
3901 hash,
3902 plan.to_hash_string(),
3903 "plan hash must be deterministic"
3904 );
3905 }
3906
3907 #[test]
3908 fn apply_result_counts() {
3909 let result = ApplyResult {
3910 action_results: vec![
3911 ActionResult {
3912 phase: "files".to_string(),
3913 description: "test".to_string(),
3914 success: true,
3915 error: None,
3916 changed: true,
3917 },
3918 ActionResult {
3919 phase: "files".to_string(),
3920 description: "test2".to_string(),
3921 success: false,
3922 error: Some("failed".to_string()),
3923 changed: false,
3924 },
3925 ],
3926 status: ApplyStatus::Partial,
3927 apply_id: 0,
3928 };
3929
3930 assert_eq!(result.succeeded(), 1);
3931 assert_eq!(result.failed(), 1);
3932 }
3933
3934 use crate::modules::{ResolvedFile, ResolvedModule, ResolvedPackage};
3937
3938 #[test]
3939 fn plan_includes_module_phase() {
3940 let state = test_state();
3941 let registry = ProviderRegistry::new();
3942 let reconciler = Reconciler::new(®istry, &state);
3943 let resolved = make_empty_resolved();
3944
3945 let modules = vec![make_resolved_module("nvim")];
3946 let plan = reconciler
3947 .plan(
3948 &resolved,
3949 Vec::new(),
3950 Vec::new(),
3951 modules,
3952 ReconcileContext::Apply,
3953 )
3954 .unwrap();
3955
3956 assert_eq!(plan.phases.len(), 8);
3957 let module_phase = plan
3958 .phases
3959 .iter()
3960 .find(|p| p.name == PhaseName::Modules)
3961 .unwrap();
3962
3963 assert!(!module_phase.actions.is_empty());
3965
3966 for action in &module_phase.actions {
3968 match action {
3969 Action::Module(ma) => {
3970 assert_eq!(ma.module_name, "nvim");
3971 }
3972 _ => panic!("expected Module action in Modules phase"),
3973 }
3974 }
3975 }
3976
3977 #[test]
3978 fn plan_module_with_files() {
3979 let state = test_state();
3980 let registry = ProviderRegistry::new();
3981 let reconciler = Reconciler::new(®istry, &state);
3982 let resolved = make_empty_resolved();
3983
3984 let modules = vec![ResolvedModule {
3985 name: "nvim".to_string(),
3986 packages: vec![],
3987 files: vec![ResolvedFile {
3988 source: PathBuf::from("/tmp/nvim-config"),
3989 target: PathBuf::from("/home/user/.config/nvim"),
3990 is_git_source: false,
3991 strategy: None,
3992 encryption: None,
3993 }],
3994 env: vec![],
3995 aliases: vec![],
3996 post_apply_scripts: vec![],
3997 pre_apply_scripts: Vec::new(),
3998 pre_reconcile_scripts: Vec::new(),
3999 post_reconcile_scripts: Vec::new(),
4000 on_change_scripts: Vec::new(),
4001 system: HashMap::new(),
4002 depends: vec![],
4003 dir: PathBuf::from("."),
4004 }];
4005
4006 let plan = reconciler
4007 .plan(
4008 &resolved,
4009 Vec::new(),
4010 Vec::new(),
4011 modules,
4012 ReconcileContext::Apply,
4013 )
4014 .unwrap();
4015
4016 let module_phase = plan
4017 .phases
4018 .iter()
4019 .find(|p| p.name == PhaseName::Modules)
4020 .unwrap();
4021 assert_eq!(module_phase.actions.len(), 1);
4022
4023 match &module_phase.actions[0] {
4024 Action::Module(ma) => match &ma.kind {
4025 ModuleActionKind::DeployFiles { files } => {
4026 assert_eq!(files.len(), 1);
4027 assert_eq!(files[0].target, PathBuf::from("/home/user/.config/nvim"));
4028 }
4029 _ => panic!("expected DeployFiles action"),
4030 },
4031 _ => panic!("expected Module action"),
4032 }
4033 }
4034
4035 #[test]
4036 fn plan_module_with_scripts() {
4037 let state = test_state();
4038 let registry = ProviderRegistry::new();
4039 let reconciler = Reconciler::new(®istry, &state);
4040 let resolved = make_empty_resolved();
4041
4042 let modules = vec![ResolvedModule {
4043 name: "nvim".to_string(),
4044 packages: vec![],
4045 files: vec![],
4046 env: vec![],
4047 aliases: vec![],
4048 post_apply_scripts: vec![
4049 ScriptEntry::Simple("nvim --headless +qa".to_string()),
4050 ScriptEntry::Simple("echo done".to_string()),
4051 ],
4052 pre_apply_scripts: Vec::new(),
4053 pre_reconcile_scripts: Vec::new(),
4054 post_reconcile_scripts: Vec::new(),
4055 on_change_scripts: Vec::new(),
4056 system: HashMap::new(),
4057 depends: vec![],
4058 dir: PathBuf::from("."),
4059 }];
4060
4061 let plan = reconciler
4062 .plan(
4063 &resolved,
4064 Vec::new(),
4065 Vec::new(),
4066 modules,
4067 ReconcileContext::Apply,
4068 )
4069 .unwrap();
4070
4071 let module_phase = plan
4072 .phases
4073 .iter()
4074 .find(|p| p.name == PhaseName::Modules)
4075 .unwrap();
4076 assert_eq!(module_phase.actions.len(), 2);
4077
4078 for action in &module_phase.actions {
4079 match action {
4080 Action::Module(ma) => match &ma.kind {
4081 ModuleActionKind::RunScript { script, .. } => {
4082 assert!(!script.run_str().is_empty());
4083 }
4084 _ => panic!("expected RunScript action"),
4085 },
4086 _ => panic!("expected Module action"),
4087 }
4088 }
4089 }
4090
4091 #[test]
4092 fn plan_multiple_modules_in_dependency_order() {
4093 let state = test_state();
4094 let registry = ProviderRegistry::new();
4095 let reconciler = Reconciler::new(®istry, &state);
4096 let resolved = make_empty_resolved();
4097
4098 let modules = vec![
4099 ResolvedModule {
4100 name: "node".to_string(),
4101 packages: vec![ResolvedPackage {
4102 canonical_name: "nodejs".to_string(),
4103 resolved_name: "nodejs".to_string(),
4104 manager: "apt".to_string(),
4105 version: Some("18.19.0".to_string()),
4106 script: None,
4107 }],
4108 files: vec![],
4109 env: vec![],
4110 aliases: vec![],
4111 post_apply_scripts: vec![],
4112 pre_apply_scripts: Vec::new(),
4113 pre_reconcile_scripts: Vec::new(),
4114 post_reconcile_scripts: Vec::new(),
4115 on_change_scripts: Vec::new(),
4116 system: HashMap::new(),
4117 depends: vec![],
4118 dir: PathBuf::from("."),
4119 },
4120 ResolvedModule {
4121 name: "nvim".to_string(),
4122 packages: vec![ResolvedPackage {
4123 canonical_name: "neovim".to_string(),
4124 resolved_name: "neovim".to_string(),
4125 manager: "brew".to_string(),
4126 version: Some("0.10.2".to_string()),
4127 script: None,
4128 }],
4129 files: vec![],
4130 env: vec![],
4131 aliases: vec![],
4132 post_apply_scripts: vec![],
4133 pre_apply_scripts: Vec::new(),
4134 pre_reconcile_scripts: Vec::new(),
4135 post_reconcile_scripts: Vec::new(),
4136 on_change_scripts: Vec::new(),
4137 system: HashMap::new(),
4138 depends: vec!["node".to_string()],
4139 dir: PathBuf::from("."),
4140 },
4141 ];
4142
4143 let plan = reconciler
4144 .plan(
4145 &resolved,
4146 Vec::new(),
4147 Vec::new(),
4148 modules,
4149 ReconcileContext::Apply,
4150 )
4151 .unwrap();
4152
4153 let module_phase = plan
4154 .phases
4155 .iter()
4156 .find(|p| p.name == PhaseName::Modules)
4157 .unwrap();
4158 assert_eq!(module_phase.actions.len(), 2);
4160
4161 match &module_phase.actions[0] {
4163 Action::Module(ma) => assert_eq!(ma.module_name, "node"),
4164 _ => panic!("expected Module action"),
4165 }
4166 match &module_phase.actions[1] {
4168 Action::Module(ma) => assert_eq!(ma.module_name, "nvim"),
4169 _ => panic!("expected Module action"),
4170 }
4171 }
4172
4173 #[test]
4174 fn format_module_plan_items_packages() {
4175 let phase = Phase {
4176 name: PhaseName::Modules,
4177 actions: vec![Action::Module(ModuleAction {
4178 module_name: "nvim".to_string(),
4179 kind: ModuleActionKind::InstallPackages {
4180 resolved: vec![
4181 ResolvedPackage {
4182 canonical_name: "neovim".to_string(),
4183 resolved_name: "neovim".to_string(),
4184 manager: "brew".to_string(),
4185 version: Some("0.10.2".to_string()),
4186 script: None,
4187 },
4188 ResolvedPackage {
4189 canonical_name: "fd".to_string(),
4190 resolved_name: "fd-find".to_string(),
4191 manager: "apt".to_string(),
4192 version: Some("8.7.0".to_string()),
4193 script: None,
4194 },
4195 ],
4196 },
4197 })],
4198 };
4199
4200 let items = format_plan_items(&phase);
4201 assert_eq!(items.len(), 1);
4202 assert!(items[0].contains("[nvim]"));
4203 assert!(items[0].contains("fd-find"));
4205 }
4206
4207 #[test]
4208 fn format_module_plan_items_files() {
4209 let phase = Phase {
4210 name: PhaseName::Modules,
4211 actions: vec![Action::Module(ModuleAction {
4212 module_name: "nvim".to_string(),
4213 kind: ModuleActionKind::DeployFiles {
4214 files: vec![ResolvedFile {
4215 source: PathBuf::from("/cache/nvim/config"),
4216 target: PathBuf::from("/home/user/.config/nvim"),
4217 is_git_source: false,
4218 strategy: None,
4219 encryption: None,
4220 }],
4221 },
4222 })],
4223 };
4224
4225 let items = format_plan_items(&phase);
4226 assert_eq!(items.len(), 1);
4227 assert!(items[0].contains("[nvim]"));
4228 assert!(items[0].contains("deploy"));
4229 assert!(items[0].contains(".config/nvim"));
4230 }
4231
4232 #[test]
4233 fn format_module_plan_items_skip() {
4234 let phase = Phase {
4235 name: PhaseName::Modules,
4236 actions: vec![Action::Module(ModuleAction {
4237 module_name: "bad".to_string(),
4238 kind: ModuleActionKind::Skip {
4239 reason: "dependency not met".to_string(),
4240 },
4241 })],
4242 };
4243
4244 let items = format_plan_items(&phase);
4245 assert_eq!(items.len(), 1);
4246 assert!(items[0].contains("[bad]"));
4247 assert!(items[0].contains("skip"));
4248 assert!(items[0].contains("dependency not met"));
4249 }
4250
4251 #[test]
4252 fn format_module_action_description() {
4253 let action = Action::Module(ModuleAction {
4254 module_name: "nvim".to_string(),
4255 kind: ModuleActionKind::InstallPackages {
4256 resolved: vec![ResolvedPackage {
4257 canonical_name: "neovim".to_string(),
4258 resolved_name: "neovim".to_string(),
4259 manager: "brew".to_string(),
4260 version: Some("0.10.2".to_string()),
4261 script: None,
4262 }],
4263 },
4264 });
4265
4266 let desc = format_action_description(&action);
4267 assert!(desc.starts_with("module:nvim:packages:"));
4268 assert!(desc.contains("neovim"));
4269 }
4270
4271 #[test]
4272 fn module_state_stored_after_apply() {
4273 let state = test_state();
4274 let mut registry = ProviderRegistry::new();
4275
4276 registry.package_managers.push(Box::new(
4277 MockPackageManager::new("brew").with_installed(&["neovim"]),
4278 ));
4279
4280 let reconciler = Reconciler::new(®istry, &state);
4281 let resolved = make_empty_resolved();
4282
4283 let modules = vec![make_resolved_module("nvim")];
4284 let plan = reconciler
4285 .plan(
4286 &resolved,
4287 Vec::new(),
4288 Vec::new(),
4289 modules.clone(),
4290 ReconcileContext::Apply,
4291 )
4292 .unwrap();
4293
4294 let printer = test_printer();
4295 let _result = reconciler
4296 .apply(
4297 &plan,
4298 &resolved,
4299 Path::new("."),
4300 &printer,
4301 None,
4302 &modules,
4303 ReconcileContext::Apply,
4304 false,
4305 )
4306 .unwrap();
4307
4308 let module_state = state.module_state_by_name("nvim").unwrap();
4310 assert!(module_state.is_some());
4311 let ms = module_state.unwrap();
4312 assert_eq!(ms.module_name, "nvim");
4313 assert_eq!(ms.status, "installed");
4314 assert!(!ms.packages_hash.is_empty());
4315 assert!(!ms.files_hash.is_empty());
4316 }
4317
4318 #[test]
4319 fn module_state_upsert_and_remove() {
4320 let state = test_state();
4321
4322 state
4323 .upsert_module_state("nvim", None, "hash1", "hash2", None, "installed")
4324 .unwrap();
4325
4326 let ms = state.module_state_by_name("nvim").unwrap().unwrap();
4327 assert_eq!(ms.packages_hash, "hash1");
4328 assert_eq!(ms.status, "installed");
4329
4330 state
4332 .upsert_module_state(
4333 "nvim",
4334 None,
4335 "hash3",
4336 "hash4",
4337 Some("[{\"url\":\"test\"}]"),
4338 "outdated",
4339 )
4340 .unwrap();
4341
4342 let ms = state.module_state_by_name("nvim").unwrap().unwrap();
4343 assert_eq!(ms.packages_hash, "hash3");
4344 assert_eq!(ms.status, "outdated");
4345 assert!(ms.git_sources.is_some());
4346
4347 let all = state.module_states().unwrap();
4349 assert_eq!(all.len(), 1);
4350
4351 state.remove_module_state("nvim").unwrap();
4353 assert!(state.module_state_by_name("nvim").unwrap().is_none());
4354 }
4355
4356 #[test]
4357 fn verify_module_drift_packages() {
4358 let state = test_state();
4359 let mut registry = ProviderRegistry::new();
4360
4361 registry.package_managers.push(Box::new(
4363 MockPackageManager::new("brew").with_installed(&["neovim"]),
4364 ));
4365
4366 let resolved = make_empty_resolved();
4367 let printer = test_printer();
4368
4369 let modules = vec![make_resolved_module("nvim")];
4370 let results = verify(&resolved, ®istry, &state, &printer, &modules).unwrap();
4371
4372 let drift = results
4374 .iter()
4375 .find(|r| r.resource_type == "module" && r.resource_id == "nvim/ripgrep");
4376 assert!(drift.is_some());
4377 assert!(!drift.unwrap().matches);
4378
4379 let ok = results
4381 .iter()
4382 .find(|r| r.resource_type == "module" && r.resource_id == "nvim/neovim");
4383 assert!(ok.is_none()); }
4385
4386 #[test]
4387 fn phase_name_modules_roundtrip() {
4388 let s = PhaseName::Modules.as_str();
4389 assert_eq!(s, "modules");
4390 let parsed = PhaseName::from_str(s).unwrap();
4391 assert_eq!(parsed, PhaseName::Modules);
4392 assert_eq!(PhaseName::Modules.display_name(), "Modules");
4393 }
4394
4395 #[test]
4396 fn plan_hash_includes_module_actions() {
4397 let plan = Plan {
4398 phases: vec![Phase {
4399 name: PhaseName::Modules,
4400 actions: vec![Action::Module(ModuleAction {
4401 module_name: "nvim".to_string(),
4402 kind: ModuleActionKind::InstallPackages {
4403 resolved: vec![ResolvedPackage {
4404 canonical_name: "neovim".to_string(),
4405 resolved_name: "neovim".to_string(),
4406 manager: "brew".to_string(),
4407 version: Some("0.10.2".to_string()),
4408 script: None,
4409 }],
4410 },
4411 })],
4412 }],
4413 warnings: vec![],
4414 };
4415
4416 let hash = plan.to_hash_string();
4417 assert!(hash.contains("nvim"));
4418 assert!(hash.contains("neovim"));
4419 assert!(hash.contains("brew"));
4420 }
4421
4422 #[test]
4423 fn verify_module_healthy_when_all_installed() {
4424 let state = test_state();
4425 let mut registry = ProviderRegistry::new();
4426
4427 registry.package_managers.push(Box::new(
4428 MockPackageManager::new("brew").with_installed(&["neovim", "ripgrep"]),
4429 ));
4430
4431 let resolved = make_empty_resolved();
4432 let printer = test_printer();
4433
4434 let modules = vec![make_resolved_module("nvim")];
4435 let results = verify(&resolved, ®istry, &state, &printer, &modules).unwrap();
4436
4437 let healthy = results
4439 .iter()
4440 .find(|r| r.resource_type == "module" && r.resource_id == "nvim");
4441 assert!(healthy.is_some());
4442 assert!(healthy.unwrap().matches);
4443 assert_eq!(healthy.unwrap().expected, "healthy");
4444
4445 let drifts: Vec<_> = results
4447 .iter()
4448 .filter(|r| r.resource_type == "module" && !r.matches)
4449 .collect();
4450 assert!(drifts.is_empty());
4451 }
4452
4453 #[test]
4454 fn verify_module_script_packages_not_false_drift() {
4455 let state = test_state();
4458 let registry = ProviderRegistry::new(); let resolved = make_empty_resolved();
4461 let printer = test_printer();
4462
4463 let modules = vec![ResolvedModule {
4464 name: "rustup".to_string(),
4465 packages: vec![ResolvedPackage {
4466 canonical_name: "rustup".to_string(),
4467 resolved_name: "rustup".to_string(),
4468 manager: "script".to_string(),
4469 version: None,
4470 script: Some("curl -sSf https://sh.rustup.rs | sh".into()),
4471 }],
4472 files: vec![],
4473 env: vec![],
4474 aliases: vec![],
4475 post_apply_scripts: vec![],
4476 pre_apply_scripts: Vec::new(),
4477 pre_reconcile_scripts: Vec::new(),
4478 post_reconcile_scripts: Vec::new(),
4479 on_change_scripts: Vec::new(),
4480 system: HashMap::new(),
4481 depends: vec![],
4482 dir: PathBuf::from("."),
4483 }];
4484
4485 let results = verify(&resolved, ®istry, &state, &printer, &modules).unwrap();
4486
4487 let healthy = results
4489 .iter()
4490 .find(|r| r.resource_type == "module" && r.resource_id == "rustup");
4491 assert!(healthy.is_some());
4492 assert!(healthy.unwrap().matches);
4493 assert_eq!(healthy.unwrap().expected, "healthy");
4494
4495 let drifts: Vec<_> = results
4497 .iter()
4498 .filter(|r| r.resource_type == "module" && !r.matches)
4499 .collect();
4500 assert!(drifts.is_empty());
4501 }
4502
4503 #[test]
4504 fn plan_module_with_script_packages() {
4505 let state = test_state();
4506 let registry = ProviderRegistry::new();
4507 let reconciler = Reconciler::new(®istry, &state);
4508 let resolved = make_empty_resolved();
4509
4510 let modules = vec![ResolvedModule {
4511 name: "rustup".to_string(),
4512 packages: vec![ResolvedPackage {
4513 canonical_name: "rustup".to_string(),
4514 resolved_name: "rustup".to_string(),
4515 manager: "script".to_string(),
4516 version: None,
4517 script: Some("curl -sSf https://sh.rustup.rs | sh".into()),
4518 }],
4519 files: vec![],
4520 env: vec![],
4521 aliases: vec![],
4522 post_apply_scripts: vec![],
4523 pre_apply_scripts: Vec::new(),
4524 pre_reconcile_scripts: Vec::new(),
4525 post_reconcile_scripts: Vec::new(),
4526 on_change_scripts: Vec::new(),
4527 system: HashMap::new(),
4528 depends: vec![],
4529 dir: PathBuf::from("."),
4530 }];
4531
4532 let plan = reconciler
4533 .plan(
4534 &resolved,
4535 Vec::new(),
4536 Vec::new(),
4537 modules,
4538 ReconcileContext::Apply,
4539 )
4540 .unwrap();
4541
4542 let module_phase = plan
4543 .phases
4544 .iter()
4545 .find(|p| p.name == PhaseName::Modules)
4546 .unwrap();
4547 assert_eq!(module_phase.actions.len(), 1);
4548
4549 match &module_phase.actions[0] {
4550 Action::Module(ma) => {
4551 assert_eq!(ma.module_name, "rustup");
4552 match &ma.kind {
4553 ModuleActionKind::InstallPackages { resolved } => {
4554 assert_eq!(resolved.len(), 1);
4555 assert_eq!(resolved[0].manager, "script");
4556 assert!(resolved[0].script.is_some());
4557 }
4558 _ => panic!("expected InstallPackages action"),
4559 }
4560 }
4561 _ => panic!("expected Module action"),
4562 }
4563 }
4564
4565 #[test]
4566 fn format_module_plan_script_packages() {
4567 let phase = Phase {
4568 name: PhaseName::Modules,
4569 actions: vec![Action::Module(ModuleAction {
4570 module_name: "rustup".to_string(),
4571 kind: ModuleActionKind::InstallPackages {
4572 resolved: vec![ResolvedPackage {
4573 canonical_name: "rustup".to_string(),
4574 resolved_name: "rustup".to_string(),
4575 manager: "script".to_string(),
4576 version: None,
4577 script: Some("install-rustup.sh".into()),
4578 }],
4579 },
4580 })],
4581 };
4582
4583 let items = format_plan_items(&phase);
4584 assert_eq!(items.len(), 1);
4585 assert!(items[0].contains("[rustup]"));
4586 assert!(items[0].contains("script"));
4587 assert!(items[0].contains("rustup"));
4588 }
4589
4590 #[test]
4591 fn empty_modules_produces_empty_phase() {
4592 let state = test_state();
4593 let registry = ProviderRegistry::new();
4594 let reconciler = Reconciler::new(®istry, &state);
4595 let resolved = make_empty_resolved();
4596
4597 let plan = reconciler
4598 .plan(
4599 &resolved,
4600 Vec::new(),
4601 Vec::new(),
4602 Vec::new(),
4603 ReconcileContext::Apply,
4604 )
4605 .unwrap();
4606
4607 let module_phase = plan
4608 .phases
4609 .iter()
4610 .find(|p| p.name == PhaseName::Modules)
4611 .unwrap();
4612 assert!(module_phase.actions.is_empty());
4613 }
4614
4615 #[test]
4616 fn conflict_detection_different_content() {
4617 let dir = tempfile::tempdir().unwrap();
4618 let file_a = dir.path().join("a.txt");
4619 let file_b = dir.path().join("b.txt");
4620 std::fs::write(&file_a, "content A").unwrap();
4621 std::fs::write(&file_b, "content B").unwrap();
4622
4623 let target = PathBuf::from("/home/user/.config/app");
4624 let file_actions = vec![FileAction::Create {
4625 source: file_a,
4626 target: target.clone(),
4627 origin: "local".to_string(),
4628 strategy: crate::config::FileStrategy::Copy,
4629 source_hash: None,
4630 }];
4631
4632 let modules = vec![ResolvedModule {
4633 name: "mymod".to_string(),
4634 packages: vec![],
4635 files: vec![crate::modules::ResolvedFile {
4636 source: file_b,
4637 target,
4638 is_git_source: false,
4639 strategy: None,
4640 encryption: None,
4641 }],
4642 env: vec![],
4643 aliases: vec![],
4644 post_apply_scripts: vec![],
4645 pre_apply_scripts: Vec::new(),
4646 pre_reconcile_scripts: Vec::new(),
4647 post_reconcile_scripts: Vec::new(),
4648 on_change_scripts: Vec::new(),
4649 system: HashMap::new(),
4650 depends: vec![],
4651 dir: PathBuf::from("."),
4652 }];
4653
4654 let result = Reconciler::detect_file_conflicts(&file_actions, &modules);
4655 assert!(result.is_err());
4656 let err = result.unwrap_err().to_string();
4657 assert!(err.contains("conflict"), "expected conflict error: {err}");
4658 }
4659
4660 #[test]
4661 fn conflict_detection_identical_content_ok() {
4662 let dir = tempfile::tempdir().unwrap();
4663 let file_a = dir.path().join("a.txt");
4664 let file_b = dir.path().join("b.txt");
4665 std::fs::write(&file_a, "same content").unwrap();
4666 std::fs::write(&file_b, "same content").unwrap();
4667
4668 let target = PathBuf::from("/home/user/.config/app");
4669 let file_actions = vec![FileAction::Create {
4670 source: file_a,
4671 target: target.clone(),
4672 origin: "local".to_string(),
4673 strategy: crate::config::FileStrategy::Copy,
4674 source_hash: None,
4675 }];
4676
4677 let modules = vec![ResolvedModule {
4678 name: "mymod".to_string(),
4679 packages: vec![],
4680 files: vec![crate::modules::ResolvedFile {
4681 source: file_b,
4682 target: target.clone(),
4683 is_git_source: false,
4684 strategy: None,
4685 encryption: None,
4686 }],
4687 env: vec![],
4688 aliases: vec![],
4689 post_apply_scripts: vec![],
4690 pre_apply_scripts: Vec::new(),
4691 pre_reconcile_scripts: Vec::new(),
4692 post_reconcile_scripts: Vec::new(),
4693 on_change_scripts: Vec::new(),
4694 system: HashMap::new(),
4695 depends: vec![],
4696 dir: PathBuf::from("."),
4697 }];
4698
4699 let result = Reconciler::detect_file_conflicts(&file_actions, &modules);
4700 assert!(
4701 result.is_ok(),
4702 "identical content targeting the same path should NOT conflict: {:?}",
4703 result.err()
4704 );
4705 let file_c = dir.path().join("c.txt");
4707 std::fs::write(&file_c, "different content").unwrap();
4708 let conflicting_modules = vec![ResolvedModule {
4709 name: "mymod".to_string(),
4710 packages: vec![],
4711 files: vec![crate::modules::ResolvedFile {
4712 source: file_c,
4713 target: target.clone(),
4714 is_git_source: false,
4715 strategy: None,
4716 encryption: None,
4717 }],
4718 env: vec![],
4719 aliases: vec![],
4720 post_apply_scripts: vec![],
4721 pre_apply_scripts: Vec::new(),
4722 pre_reconcile_scripts: Vec::new(),
4723 post_reconcile_scripts: Vec::new(),
4724 on_change_scripts: Vec::new(),
4725 system: HashMap::new(),
4726 depends: vec![],
4727 dir: PathBuf::from("."),
4728 }];
4729 assert!(
4730 Reconciler::detect_file_conflicts(&file_actions, &conflicting_modules).is_err(),
4731 "different content at same target should conflict (proves the Ok was meaningful)"
4732 );
4733 }
4734
4735 #[test]
4736 fn conflict_detection_no_overlap_ok() {
4737 let dir = tempfile::tempdir().unwrap();
4738 let file_a = dir.path().join("a.txt");
4739 let file_b = dir.path().join("b.txt");
4740 std::fs::write(&file_a, "content A").unwrap();
4741 std::fs::write(&file_b, "content B").unwrap();
4742
4743 let target_a = PathBuf::from("/target/a");
4744 let target_b = PathBuf::from("/target/b");
4745 let file_actions = vec![FileAction::Create {
4746 source: file_a.clone(),
4747 target: target_a,
4748 origin: "local".to_string(),
4749 strategy: crate::config::FileStrategy::Copy,
4750 source_hash: None,
4751 }];
4752
4753 let modules = vec![ResolvedModule {
4754 name: "mymod".to_string(),
4755 packages: vec![],
4756 files: vec![crate::modules::ResolvedFile {
4757 source: file_b.clone(),
4758 target: target_b,
4759 is_git_source: false,
4760 strategy: None,
4761 encryption: None,
4762 }],
4763 env: vec![],
4764 aliases: vec![],
4765 post_apply_scripts: vec![],
4766 pre_apply_scripts: Vec::new(),
4767 pre_reconcile_scripts: Vec::new(),
4768 post_reconcile_scripts: Vec::new(),
4769 on_change_scripts: Vec::new(),
4770 system: HashMap::new(),
4771 depends: vec![],
4772 dir: PathBuf::from("."),
4773 }];
4774
4775 let result = Reconciler::detect_file_conflicts(&file_actions, &modules);
4776 assert!(
4777 result.is_ok(),
4778 "different targets should not conflict: {:?}",
4779 result.err()
4780 );
4781 let overlapping_modules = vec![ResolvedModule {
4783 name: "mymod".to_string(),
4784 packages: vec![],
4785 files: vec![crate::modules::ResolvedFile {
4786 source: file_b,
4787 target: PathBuf::from("/target/a"), is_git_source: false,
4789 strategy: None,
4790 encryption: None,
4791 }],
4792 env: vec![],
4793 aliases: vec![],
4794 post_apply_scripts: vec![],
4795 pre_apply_scripts: Vec::new(),
4796 pre_reconcile_scripts: Vec::new(),
4797 post_reconcile_scripts: Vec::new(),
4798 on_change_scripts: Vec::new(),
4799 system: HashMap::new(),
4800 depends: vec![],
4801 dir: PathBuf::from("."),
4802 }];
4803 assert!(
4804 Reconciler::detect_file_conflicts(&file_actions, &overlapping_modules).is_err(),
4805 "different content at same target should conflict (proves the Ok was meaningful)"
4806 );
4807 }
4808
4809 #[test]
4810 fn generate_env_file_quoted_and_unquoted() {
4811 let env = vec![
4812 crate::config::EnvVar {
4813 name: "EDITOR".into(),
4814 value: "nvim".into(),
4815 },
4816 crate::config::EnvVar {
4817 name: "PATH".into(),
4818 value: "/usr/local/bin:$PATH".into(),
4819 },
4820 ];
4821 let content = super::generate_env_file_content(&env, &[]);
4822 assert!(content.starts_with("# managed by cfgd"));
4823 assert!(content.contains("export EDITOR=\"nvim\""));
4824 assert!(content.contains("export PATH=\"/usr/local/bin:$PATH\""));
4826 }
4827
4828 #[test]
4829 fn generate_fish_env_splits_path() {
4830 let env = vec![
4831 crate::config::EnvVar {
4832 name: "EDITOR".into(),
4833 value: "nvim".into(),
4834 },
4835 crate::config::EnvVar {
4836 name: "PATH".into(),
4837 value: "/usr/local/bin:/home/user/.cargo/bin:$PATH".into(),
4838 },
4839 ];
4840 let content = super::generate_fish_env_content(&env, &[]);
4841 assert!(content.starts_with("# managed by cfgd"));
4842 assert!(content.contains("set -gx EDITOR 'nvim'"));
4843 assert!(content.contains("set -gx PATH '/usr/local/bin' '/home/user/.cargo/bin' '$PATH'"));
4844 }
4845
4846 #[test]
4847 fn plan_env_empty_when_no_env() {
4848 let tmp = tempfile::tempdir().unwrap();
4849 let (actions, _warnings) = Reconciler::plan_env_with_home(&[], &[], &[], &[], tmp.path());
4850 assert!(actions.is_empty());
4851 }
4852
4853 #[test]
4854 fn plan_env_module_wins_on_conflict() {
4855 let profile_env = vec![crate::config::EnvVar {
4856 name: "EDITOR".into(),
4857 value: "vim".into(),
4858 }];
4859 let modules = vec![ResolvedModule {
4860 name: "nvim".into(),
4861 packages: vec![],
4862 files: vec![],
4863 env: vec![crate::config::EnvVar {
4864 name: "EDITOR".into(),
4865 value: "nvim".into(),
4866 }],
4867 aliases: vec![],
4868 post_apply_scripts: vec![],
4869 pre_apply_scripts: Vec::new(),
4870 pre_reconcile_scripts: Vec::new(),
4871 post_reconcile_scripts: Vec::new(),
4872 on_change_scripts: Vec::new(),
4873 system: HashMap::new(),
4874 depends: vec![],
4875 dir: PathBuf::from("."),
4876 }];
4877 let tmp = tempfile::tempdir().unwrap();
4879 let (actions, _warnings) =
4880 Reconciler::plan_env_with_home(&profile_env, &[], &modules, &[], tmp.path());
4881 let has_write = actions
4883 .iter()
4884 .any(|a| matches!(a, Action::Env(EnvAction::WriteEnvFile { .. })));
4885 assert!(has_write, "Expected WriteEnvFile action for non-empty env");
4886 }
4887
4888 #[test]
4889 fn plan_env_generates_file_matching_expected() {
4890 let env = vec![crate::config::EnvVar {
4891 name: "EDITOR".into(),
4892 value: "nvim".into(),
4893 }];
4894
4895 let dir = tempfile::tempdir().unwrap();
4897 let env_path = dir.path().join(".cfgd.env");
4898 let expected = super::generate_env_file_content(&env, &[]);
4899 std::fs::write(&env_path, &expected).unwrap();
4900
4901 assert!(expected.contains("export EDITOR=\"nvim\""));
4904 assert!(expected.contains("# managed by cfgd"));
4905 }
4906
4907 #[test]
4908 fn phase_name_env_roundtrip() {
4909 assert_eq!(PhaseName::Env.as_str(), "env");
4910 assert_eq!(PhaseName::Env.display_name(), "Environment");
4911 assert_eq!("env".parse::<PhaseName>().unwrap(), PhaseName::Env);
4912 }
4913
4914 #[test]
4915 fn generate_env_file_with_aliases() {
4916 let env = vec![crate::config::EnvVar {
4917 name: "EDITOR".into(),
4918 value: "nvim".into(),
4919 }];
4920 let aliases = vec![
4921 crate::config::ShellAlias {
4922 name: "vim".into(),
4923 command: "nvim".into(),
4924 },
4925 crate::config::ShellAlias {
4926 name: "ll".into(),
4927 command: "ls -la".into(),
4928 },
4929 ];
4930 let content = super::generate_env_file_content(&env, &aliases);
4931 assert!(content.contains("export EDITOR=\"nvim\""));
4932 assert!(content.contains("alias vim=\"nvim\""));
4933 assert!(content.contains("alias ll=\"ls -la\""));
4934 }
4935
4936 #[test]
4937 fn generate_fish_env_with_aliases() {
4938 let env = vec![crate::config::EnvVar {
4939 name: "EDITOR".into(),
4940 value: "nvim".into(),
4941 }];
4942 let aliases = vec![crate::config::ShellAlias {
4943 name: "vim".into(),
4944 command: "nvim".into(),
4945 }];
4946 let content = super::generate_fish_env_content(&env, &aliases);
4947 assert!(content.contains("set -gx EDITOR 'nvim'"));
4948 assert!(content.contains("abbr -a vim 'nvim'"));
4949 }
4950
4951 #[test]
4952 fn plan_env_aliases_only() {
4953 let aliases = vec![crate::config::ShellAlias {
4954 name: "vim".into(),
4955 command: "nvim".into(),
4956 }];
4957 let tmp = tempfile::tempdir().unwrap();
4958 let (actions, _warnings) =
4959 Reconciler::plan_env_with_home(&[], &aliases, &[], &[], tmp.path());
4960 let has_write = actions
4961 .iter()
4962 .any(|a| matches!(a, Action::Env(EnvAction::WriteEnvFile { .. })));
4963 assert!(has_write, "Expected WriteEnvFile action for aliases-only");
4964 }
4965
4966 #[test]
4967 #[cfg(unix)]
4968 fn plan_env_module_alias_wins_on_conflict() {
4969 let profile_aliases = vec![crate::config::ShellAlias {
4970 name: "vim".into(),
4971 command: "vi".into(),
4972 }];
4973 let modules = vec![ResolvedModule {
4974 name: "nvim".into(),
4975 packages: vec![],
4976 files: vec![],
4977 env: vec![],
4978 aliases: vec![crate::config::ShellAlias {
4979 name: "vim".into(),
4980 command: "nvim".into(),
4981 }],
4982 post_apply_scripts: vec![],
4983 pre_apply_scripts: Vec::new(),
4984 pre_reconcile_scripts: Vec::new(),
4985 post_reconcile_scripts: Vec::new(),
4986 on_change_scripts: Vec::new(),
4987 system: HashMap::new(),
4988 depends: vec![],
4989 dir: PathBuf::from("."),
4990 }];
4991 let tmp = tempfile::tempdir().unwrap();
4992 let (actions, _warnings) =
4993 Reconciler::plan_env_with_home(&[], &profile_aliases, &modules, &[], tmp.path());
4994 for action in &actions {
4996 if let Action::Env(EnvAction::WriteEnvFile { content, .. }) = action {
4997 assert!(
4998 content.contains("alias vim=\"nvim\""),
4999 "Module alias should override profile alias"
5000 );
5001 assert!(
5002 !content.contains("alias vim=\"vi\""),
5003 "Profile alias should be overridden"
5004 );
5005 return;
5006 }
5007 }
5008 panic!("Expected WriteEnvFile action");
5009 }
5010
5011 #[test]
5012 fn generate_env_file_alias_escapes_quotes() {
5013 let aliases = vec![crate::config::ShellAlias {
5014 name: "greet".into(),
5015 command: "echo \"hello world\"".into(),
5016 }];
5017 let content = super::generate_env_file_content(&[], &aliases);
5018 assert!(content.contains("alias greet=\"echo \\\"hello world\\\"\""));
5019 }
5020
5021 struct MockSecretProvider {
5024 provider_name: String,
5025 value: String,
5026 }
5027
5028 impl crate::providers::SecretProvider for MockSecretProvider {
5029 fn name(&self) -> &str {
5030 &self.provider_name
5031 }
5032 fn is_available(&self) -> bool {
5033 true
5034 }
5035 fn resolve(&self, _reference: &str) -> Result<secrecy::SecretString> {
5036 Ok(secrecy::SecretString::from(self.value.clone()))
5037 }
5038 }
5039
5040 #[test]
5041 fn plan_secrets_envs_only_produces_resolve_env() {
5042 let state = test_state();
5043 let mut registry = ProviderRegistry::new();
5044 registry.secret_providers.push(Box::new(MockSecretProvider {
5045 provider_name: "vault".into(),
5046 value: "secret-token".into(),
5047 }));
5048 let reconciler = Reconciler::new(®istry, &state);
5049
5050 let mut profile = MergedProfile::default();
5051 profile.secrets.push(crate::config::SecretSpec {
5052 source: "vault://secret/data/github#token".to_string(),
5053 target: None,
5054 template: None,
5055 backend: None,
5056 envs: Some(vec!["GITHUB_TOKEN".to_string()]),
5057 });
5058
5059 let actions = reconciler.plan_secrets(&profile);
5060 assert_eq!(actions.len(), 1);
5062 match &actions[0] {
5063 Action::Secret(SecretAction::ResolveEnv { provider, envs, .. }) => {
5064 assert_eq!(provider, "vault");
5065 assert_eq!(envs, &["GITHUB_TOKEN"]);
5066 }
5067 other => panic!("Expected ResolveEnv, got {:?}", other),
5068 }
5069 }
5070
5071 #[test]
5072 fn plan_secrets_target_and_envs_produces_both_actions() {
5073 let state = test_state();
5074 let mut registry = ProviderRegistry::new();
5075 registry.secret_providers.push(Box::new(MockSecretProvider {
5076 provider_name: "1password".into(),
5077 value: "ghp_abc123".into(),
5078 }));
5079 let reconciler = Reconciler::new(®istry, &state);
5080
5081 let mut profile = MergedProfile::default();
5082 profile.secrets.push(crate::config::SecretSpec {
5083 source: "1password://Vault/GitHub/Token".to_string(),
5084 target: Some(PathBuf::from("/tmp/github-token")),
5085 template: None,
5086 backend: None,
5087 envs: Some(vec!["GITHUB_TOKEN".to_string()]),
5088 });
5089
5090 let actions = reconciler.plan_secrets(&profile);
5091 assert_eq!(actions.len(), 2);
5093 assert!(
5094 matches!(&actions[0], Action::Secret(SecretAction::Resolve { .. })),
5095 "First action should be Resolve, got {:?}",
5096 &actions[0]
5097 );
5098 assert!(
5099 matches!(&actions[1], Action::Secret(SecretAction::ResolveEnv { .. })),
5100 "Second action should be ResolveEnv, got {:?}",
5101 &actions[1]
5102 );
5103 }
5104
5105 #[test]
5106 fn plan_env_with_secret_envs_includes_them() {
5107 let secret_envs = vec![
5108 ("GITHUB_TOKEN".to_string(), "ghp_abc123".to_string()),
5109 ("NPM_TOKEN".to_string(), "npm_xyz789".to_string()),
5110 ];
5111 let tmp = tempfile::tempdir().unwrap();
5112 let (actions, _warnings) =
5113 Reconciler::plan_env_with_home(&[], &[], &[], &secret_envs, tmp.path());
5114 let has_write = actions
5116 .iter()
5117 .any(|a| matches!(a, Action::Env(EnvAction::WriteEnvFile { .. })));
5118 assert!(has_write, "Expected WriteEnvFile action for secret envs");
5119 }
5120
5121 #[test]
5122 #[cfg(unix)]
5123 fn plan_env_secret_envs_appear_in_generated_content() {
5124 let regular_env = vec![crate::config::EnvVar {
5125 name: "EDITOR".into(),
5126 value: "nvim".into(),
5127 }];
5128 let secret_envs = vec![("GITHUB_TOKEN".to_string(), "ghp_abc123".to_string())];
5129 let tmp = tempfile::tempdir().unwrap();
5130 let (actions, _warnings) =
5131 Reconciler::plan_env_with_home(®ular_env, &[], &[], &secret_envs, tmp.path());
5132
5133 for action in &actions {
5135 if let Action::Env(EnvAction::WriteEnvFile { content, .. }) = action {
5136 assert!(
5137 content.contains("export EDITOR=\"nvim\""),
5138 "Regular env should be present"
5139 );
5140 assert!(
5141 content.contains("export GITHUB_TOKEN=\"ghp_abc123\""),
5142 "Secret env should be present in content: {}",
5143 content
5144 );
5145 let editor_pos = content.find("EDITOR").unwrap_or(0);
5147 let token_pos = content.find("GITHUB_TOKEN").unwrap_or(0);
5148 assert!(
5149 token_pos > editor_pos,
5150 "Secret env should appear after regular env"
5151 );
5152 return;
5153 }
5154 }
5155 panic!("Expected WriteEnvFile action");
5156 }
5157
5158 #[test]
5161 fn rc_conflict_env_different_value_warns() {
5162 let dir = tempfile::tempdir().unwrap();
5163 let rc = dir.path().join(".bashrc");
5164 std::fs::write(
5165 &rc,
5166 "export EDITOR=\"vim\"\n[ -f ~/.cfgd.env ] && source ~/.cfgd.env\n",
5167 )
5168 .unwrap();
5169 let env = vec![crate::config::EnvVar {
5170 name: "EDITOR".into(),
5171 value: "nvim".into(),
5172 }];
5173 let warnings = super::detect_rc_env_conflicts(&rc, &env, &[]);
5174 assert_eq!(warnings.len(), 1);
5175 assert!(warnings[0].contains("EDITOR"));
5176 assert!(warnings[0].contains("move it after the source line"));
5177 }
5178
5179 #[test]
5180 fn rc_conflict_env_same_value_no_warning() {
5181 let dir = tempfile::tempdir().unwrap();
5182 let rc = dir.path().join(".bashrc");
5183 std::fs::write(
5184 &rc,
5185 "export EDITOR=\"nvim\"\n[ -f ~/.cfgd.env ] && source ~/.cfgd.env\n",
5186 )
5187 .unwrap();
5188 let env = vec![crate::config::EnvVar {
5189 name: "EDITOR".into(),
5190 value: "nvim".into(),
5191 }];
5192 let warnings = super::detect_rc_env_conflicts(&rc, &env, &[]);
5193 assert!(warnings.is_empty());
5194 }
5195
5196 #[test]
5197 fn rc_conflict_alias_different_value_warns() {
5198 let dir = tempfile::tempdir().unwrap();
5199 let rc = dir.path().join(".bashrc");
5200 std::fs::write(
5201 &rc,
5202 "alias vim=\"vi\"\n[ -f ~/.cfgd.env ] && source ~/.cfgd.env\n",
5203 )
5204 .unwrap();
5205 let aliases = vec![crate::config::ShellAlias {
5206 name: "vim".into(),
5207 command: "nvim".into(),
5208 }];
5209 let warnings = super::detect_rc_env_conflicts(&rc, &[], &aliases);
5210 assert_eq!(warnings.len(), 1);
5211 assert!(warnings[0].contains("alias vim"));
5212 assert!(warnings[0].contains("move it after the source line"));
5213 }
5214
5215 #[test]
5216 fn rc_conflict_after_source_line_no_warning() {
5217 let dir = tempfile::tempdir().unwrap();
5218 let rc = dir.path().join(".bashrc");
5219 std::fs::write(
5220 &rc,
5221 "[ -f ~/.cfgd.env ] && source ~/.cfgd.env\nexport EDITOR=\"vim\"\n",
5222 )
5223 .unwrap();
5224 let env = vec![crate::config::EnvVar {
5225 name: "EDITOR".into(),
5226 value: "nvim".into(),
5227 }];
5228 let warnings = super::detect_rc_env_conflicts(&rc, &env, &[]);
5229 assert!(warnings.is_empty());
5230 }
5231
5232 #[test]
5233 fn rc_conflict_no_source_line_all_before() {
5234 let dir = tempfile::tempdir().unwrap();
5235 let rc = dir.path().join(".bashrc");
5236 std::fs::write(&rc, "export EDITOR=\"vim\"\nalias vim=\"vi\"\n").unwrap();
5237 let env = vec![crate::config::EnvVar {
5238 name: "EDITOR".into(),
5239 value: "nvim".into(),
5240 }];
5241 let aliases = vec![crate::config::ShellAlias {
5242 name: "vim".into(),
5243 command: "nvim".into(),
5244 }];
5245 let warnings = super::detect_rc_env_conflicts(&rc, &env, &aliases);
5246 assert_eq!(warnings.len(), 2);
5247 }
5248
5249 #[test]
5250 fn rc_conflict_nonexistent_file_no_warnings() {
5251 let warnings = super::detect_rc_env_conflicts(
5252 std::path::Path::new("/nonexistent/.bashrc"),
5253 &[crate::config::EnvVar {
5254 name: "FOO".into(),
5255 value: "bar".into(),
5256 }],
5257 &[],
5258 );
5259 assert!(warnings.is_empty());
5260 }
5261
5262 #[test]
5263 fn strip_shell_quotes_works() {
5264 assert_eq!(super::strip_shell_quotes("\"hello\""), "hello");
5265 assert_eq!(super::strip_shell_quotes("'hello'"), "hello");
5266 assert_eq!(super::strip_shell_quotes("hello"), "hello");
5267 assert_eq!(super::strip_shell_quotes("\"\""), "");
5268 }
5269
5270 #[test]
5273 fn generate_powershell_env_basic() {
5274 let env = vec![
5275 crate::config::EnvVar {
5276 name: "EDITOR".into(),
5277 value: "code".into(),
5278 },
5279 crate::config::EnvVar {
5280 name: "PATH".into(),
5281 value: r"C:\Users\user\.cargo\bin;$env:PATH".into(),
5282 },
5283 ];
5284 let content = super::generate_powershell_env_content(&env, &[]);
5285 assert!(content.starts_with("# managed by cfgd"));
5286 assert!(content.contains("$env:EDITOR = 'code'"));
5287 assert!(content.contains(r#"$env:PATH = "C:\Users\user\.cargo\bin;$env:PATH""#));
5289 }
5290
5291 #[test]
5292 fn generate_powershell_env_with_aliases() {
5293 let aliases = vec![
5294 crate::config::ShellAlias {
5295 name: "g".into(),
5296 command: "git".into(),
5297 },
5298 crate::config::ShellAlias {
5299 name: "ll".into(),
5300 command: "Get-ChildItem -Force".into(),
5301 },
5302 ];
5303 let content = super::generate_powershell_env_content(&[], &aliases);
5304 assert!(content.contains("Set-Alias -Name g -Value git"));
5305 assert!(content.contains("function ll {"));
5306 assert!(content.contains("Get-ChildItem -Force @args"));
5307 }
5308
5309 #[test]
5310 fn generate_powershell_env_escapes_quotes() {
5311 let env = vec![crate::config::EnvVar {
5312 name: "GREETING".into(),
5313 value: r#"say "hello""#.into(),
5314 }];
5315 let content = super::generate_powershell_env_content(&env, &[]);
5316 assert!(content.contains("$env:GREETING = 'say \"hello\"'"));
5318 }
5319
5320 #[test]
5321 fn generate_powershell_env_empty() {
5322 let content = super::generate_powershell_env_content(&[], &[]);
5323 assert!(content.starts_with("# managed by cfgd"));
5324 assert_eq!(content.lines().count(), 1);
5326 }
5327
5328 struct TrackingPackageManager {
5332 name: String,
5333 installed: std::sync::Mutex<HashSet<String>>,
5334 install_calls: std::sync::Mutex<Vec<Vec<String>>>,
5335 uninstall_calls: std::sync::Mutex<Vec<Vec<String>>>,
5336 }
5337
5338 impl TrackingPackageManager {
5339 fn new(name: &str) -> Self {
5340 Self {
5341 name: name.to_string(),
5342 installed: std::sync::Mutex::new(HashSet::new()),
5343 install_calls: std::sync::Mutex::new(Vec::new()),
5344 uninstall_calls: std::sync::Mutex::new(Vec::new()),
5345 }
5346 }
5347
5348 fn with_installed(name: &str, pkgs: &[&str]) -> Self {
5349 let mut set = HashSet::new();
5350 for p in pkgs {
5351 set.insert(p.to_string());
5352 }
5353 Self {
5354 name: name.to_string(),
5355 installed: std::sync::Mutex::new(set),
5356 install_calls: std::sync::Mutex::new(Vec::new()),
5357 uninstall_calls: std::sync::Mutex::new(Vec::new()),
5358 }
5359 }
5360 }
5361
5362 impl PackageManager for TrackingPackageManager {
5363 fn name(&self) -> &str {
5364 &self.name
5365 }
5366 fn is_available(&self) -> bool {
5367 true
5368 }
5369 fn can_bootstrap(&self) -> bool {
5370 false
5371 }
5372 fn bootstrap(&self, _printer: &Printer) -> Result<()> {
5373 Ok(())
5374 }
5375 fn installed_packages(&self) -> Result<HashSet<String>> {
5376 Ok(self.installed.lock().unwrap().clone())
5377 }
5378 fn install(&self, packages: &[String], _printer: &Printer) -> Result<()> {
5379 self.install_calls.lock().unwrap().push(packages.to_vec());
5380 let mut installed = self.installed.lock().unwrap();
5381 for p in packages {
5382 installed.insert(p.clone());
5383 }
5384 Ok(())
5385 }
5386 fn uninstall(&self, packages: &[String], _printer: &Printer) -> Result<()> {
5387 self.uninstall_calls.lock().unwrap().push(packages.to_vec());
5388 let mut installed = self.installed.lock().unwrap();
5389 for p in packages {
5390 installed.remove(p);
5391 }
5392 Ok(())
5393 }
5394 fn update(&self, _printer: &Printer) -> Result<()> {
5395 Ok(())
5396 }
5397 fn available_version(&self, _package: &str) -> Result<Option<String>> {
5398 Ok(None)
5399 }
5400 }
5401
5402 #[test]
5403 fn apply_package_install_calls_mock_and_records_state() {
5404 let state = test_state();
5405 let mut registry = ProviderRegistry::new();
5406 registry
5407 .package_managers
5408 .push(Box::new(TrackingPackageManager::new("brew")));
5409
5410 let reconciler = Reconciler::new(®istry, &state);
5411 let resolved = make_empty_resolved();
5412
5413 let pkg_actions = vec![PackageAction::Install {
5414 manager: "brew".to_string(),
5415 packages: vec!["ripgrep".to_string(), "fd".to_string()],
5416 origin: "local".to_string(),
5417 }];
5418
5419 let plan = reconciler
5420 .plan(
5421 &resolved,
5422 Vec::new(),
5423 pkg_actions,
5424 Vec::new(),
5425 ReconcileContext::Apply,
5426 )
5427 .unwrap();
5428
5429 let printer = test_printer();
5430 let result = reconciler
5431 .apply(
5432 &plan,
5433 &resolved,
5434 Path::new("."),
5435 &printer,
5436 None,
5437 &[],
5438 ReconcileContext::Apply,
5439 false,
5440 )
5441 .unwrap();
5442
5443 assert_eq!(result.status, ApplyStatus::Success);
5444 assert_eq!(result.action_results.len(), 1);
5445 assert!(result.action_results[0].success);
5446 assert!(result.action_results[0].error.is_none());
5447 assert!(result.action_results[0].description.contains("ripgrep"));
5448
5449 let pm = registry.package_managers[0].as_ref();
5451 let installed = pm.installed_packages().unwrap();
5452 assert!(installed.contains("ripgrep"));
5453 assert!(installed.contains("fd"));
5454 }
5455
5456 #[test]
5457 fn apply_package_uninstall_calls_mock() {
5458 let state = test_state();
5459 let mut registry = ProviderRegistry::new();
5460 registry
5461 .package_managers
5462 .push(Box::new(TrackingPackageManager::with_installed(
5463 "brew",
5464 &["ripgrep", "fd"],
5465 )));
5466
5467 let reconciler = Reconciler::new(®istry, &state);
5468 let resolved = make_empty_resolved();
5469
5470 let pkg_actions = vec![PackageAction::Uninstall {
5471 manager: "brew".to_string(),
5472 packages: vec!["ripgrep".to_string()],
5473 origin: "local".to_string(),
5474 }];
5475
5476 let plan = reconciler
5477 .plan(
5478 &resolved,
5479 Vec::new(),
5480 pkg_actions,
5481 Vec::new(),
5482 ReconcileContext::Apply,
5483 )
5484 .unwrap();
5485
5486 let printer = test_printer();
5487 let result = reconciler
5488 .apply(
5489 &plan,
5490 &resolved,
5491 Path::new("."),
5492 &printer,
5493 None,
5494 &[],
5495 ReconcileContext::Apply,
5496 false,
5497 )
5498 .unwrap();
5499
5500 assert_eq!(result.status, ApplyStatus::Success);
5501 assert_eq!(result.action_results.len(), 1);
5502 assert!(result.action_results[0].success);
5503
5504 let pm = registry.package_managers[0].as_ref();
5505 let installed = pm.installed_packages().unwrap();
5506 assert!(!installed.contains("ripgrep"));
5507 assert!(installed.contains("fd"));
5508 }
5509
5510 #[test]
5511 fn apply_empty_plan_records_success_in_state_store() {
5512 let state = test_state();
5513 let registry = ProviderRegistry::new();
5514 let reconciler = Reconciler::new(®istry, &state);
5515 let resolved = make_empty_resolved();
5516
5517 let plan = reconciler
5518 .plan(
5519 &resolved,
5520 Vec::new(),
5521 Vec::new(),
5522 Vec::new(),
5523 ReconcileContext::Apply,
5524 )
5525 .unwrap();
5526
5527 let printer = test_printer();
5528 let result = reconciler
5529 .apply(
5530 &plan,
5531 &resolved,
5532 Path::new("."),
5533 &printer,
5534 None,
5535 &[],
5536 ReconcileContext::Apply,
5537 false,
5538 )
5539 .unwrap();
5540
5541 assert_eq!(result.status, ApplyStatus::Success);
5542 assert_eq!(result.action_results.len(), 0);
5543
5544 let last = state.last_apply().unwrap();
5546 assert!(last.is_some());
5547 let record = last.unwrap();
5548 assert_eq!(record.status, ApplyStatus::Success);
5549 assert_eq!(record.profile, "test");
5550 assert_eq!(record.id, result.apply_id);
5551 }
5552
5553 #[test]
5554 fn apply_records_correct_apply_id() {
5555 let state = test_state();
5556 let registry = ProviderRegistry::new();
5557 let reconciler = Reconciler::new(®istry, &state);
5558 let resolved = make_empty_resolved();
5559
5560 let plan = reconciler
5561 .plan(
5562 &resolved,
5563 Vec::new(),
5564 Vec::new(),
5565 Vec::new(),
5566 ReconcileContext::Apply,
5567 )
5568 .unwrap();
5569 let printer = test_printer();
5570
5571 let result1 = reconciler
5573 .apply(
5574 &plan,
5575 &resolved,
5576 Path::new("."),
5577 &printer,
5578 None,
5579 &[],
5580 ReconcileContext::Apply,
5581 false,
5582 )
5583 .unwrap();
5584
5585 let result2 = reconciler
5587 .apply(
5588 &plan,
5589 &resolved,
5590 Path::new("."),
5591 &printer,
5592 None,
5593 &[],
5594 ReconcileContext::Apply,
5595 false,
5596 )
5597 .unwrap();
5598
5599 assert!(result2.apply_id > result1.apply_id);
5601
5602 let last = state.last_apply().unwrap().unwrap();
5604 assert_eq!(last.id, result2.apply_id);
5605 }
5606
5607 #[test]
5608 fn apply_env_write_env_file_to_tempdir() {
5609 let dir = tempfile::tempdir().unwrap();
5610 let env_path = dir.path().join(".cfgd.env");
5611
5612 let env = vec![
5613 crate::config::EnvVar {
5614 name: "EDITOR".into(),
5615 value: "nvim".into(),
5616 },
5617 crate::config::EnvVar {
5618 name: "CARGO_HOME".into(),
5619 value: "/home/user/.cargo".into(),
5620 },
5621 ];
5622 let content = super::generate_env_file_content(&env, &[]);
5623
5624 let action = EnvAction::WriteEnvFile {
5625 path: env_path.clone(),
5626 content: content.clone(),
5627 };
5628
5629 let printer = test_printer();
5630 let desc = Reconciler::apply_env_action(&action, &printer).unwrap();
5631
5632 let written = std::fs::read_to_string(&env_path).unwrap();
5634 assert_eq!(written, content);
5635 assert!(written.contains("export EDITOR=\"nvim\""));
5636 assert!(written.contains("export CARGO_HOME=\"/home/user/.cargo\""));
5637 assert!(desc.starts_with("env:write:"));
5638 }
5639
5640 #[test]
5641 fn apply_env_write_skips_when_content_matches() {
5642 let dir = tempfile::tempdir().unwrap();
5643 let env_path = dir.path().join(".cfgd.env");
5644
5645 let env = vec![crate::config::EnvVar {
5646 name: "EDITOR".into(),
5647 value: "nvim".into(),
5648 }];
5649 let content = super::generate_env_file_content(&env, &[]);
5650
5651 std::fs::write(&env_path, &content).unwrap();
5653
5654 let action = EnvAction::WriteEnvFile {
5655 path: env_path.clone(),
5656 content,
5657 };
5658
5659 let printer = test_printer();
5660 let desc = Reconciler::apply_env_action(&action, &printer).unwrap();
5661
5662 assert!(desc.contains("skipped"), "Expected skip: {}", desc);
5664 }
5665
5666 #[test]
5667 fn apply_env_inject_source_line_creates_file() {
5668 let dir = tempfile::tempdir().unwrap();
5669 let rc_path = dir.path().join(".bashrc");
5670
5671 let action = EnvAction::InjectSourceLine {
5672 rc_path: rc_path.clone(),
5673 line: "[ -f ~/.cfgd.env ] && source ~/.cfgd.env".to_string(),
5674 };
5675
5676 let printer = test_printer();
5677 let desc = Reconciler::apply_env_action(&action, &printer).unwrap();
5678
5679 let written = std::fs::read_to_string(&rc_path).unwrap();
5680 assert!(written.contains("source ~/.cfgd.env"));
5681 assert!(desc.starts_with("env:inject:"));
5682 }
5683
5684 #[test]
5685 fn apply_env_inject_skips_when_already_present() {
5686 let dir = tempfile::tempdir().unwrap();
5687 let rc_path = dir.path().join(".bashrc");
5688
5689 std::fs::write(
5691 &rc_path,
5692 "# existing config\n[ -f ~/.cfgd.env ] && source ~/.cfgd.env\n",
5693 )
5694 .unwrap();
5695
5696 let action = EnvAction::InjectSourceLine {
5697 rc_path: rc_path.clone(),
5698 line: "[ -f ~/.cfgd.env ] && source ~/.cfgd.env".to_string(),
5699 };
5700
5701 let printer = test_printer();
5702 let desc = Reconciler::apply_env_action(&action, &printer).unwrap();
5703
5704 assert!(desc.contains("skipped"), "Expected skip: {}", desc);
5705 }
5706
5707 #[test]
5708 fn apply_env_inject_appends_to_existing_content() {
5709 let dir = tempfile::tempdir().unwrap();
5710 let rc_path = dir.path().join(".bashrc");
5711
5712 std::fs::write(&rc_path, "# my config\nexport FOO=bar").unwrap();
5713
5714 let action = EnvAction::InjectSourceLine {
5715 rc_path: rc_path.clone(),
5716 line: "[ -f ~/.cfgd.env ] && source ~/.cfgd.env".to_string(),
5717 };
5718
5719 let printer = test_printer();
5720 Reconciler::apply_env_action(&action, &printer).unwrap();
5721
5722 let written = std::fs::read_to_string(&rc_path).unwrap();
5723 assert!(written.starts_with("# my config\n"));
5724 assert!(written.contains("export FOO=bar"));
5725 assert!(written.contains("source ~/.cfgd.env"));
5726 }
5727
5728 #[test]
5729 fn apply_full_flow_plan_apply_verify_consistent() {
5730 let state = test_state();
5731 let mut registry = ProviderRegistry::new();
5732 registry
5733 .package_managers
5734 .push(Box::new(TrackingPackageManager::with_installed(
5735 "brew",
5736 &["git"],
5737 )));
5738
5739 let reconciler = Reconciler::new(®istry, &state);
5740 let resolved = make_empty_resolved();
5741
5742 let pkg_actions = vec![PackageAction::Install {
5744 manager: "brew".to_string(),
5745 packages: vec!["ripgrep".to_string(), "fd".to_string()],
5746 origin: "local".to_string(),
5747 }];
5748
5749 let plan = reconciler
5750 .plan(
5751 &resolved,
5752 Vec::new(),
5753 pkg_actions,
5754 Vec::new(),
5755 ReconcileContext::Apply,
5756 )
5757 .unwrap();
5758 assert!(!plan.is_empty());
5759
5760 let printer = test_printer();
5762 let result = reconciler
5763 .apply(
5764 &plan,
5765 &resolved,
5766 Path::new("."),
5767 &printer,
5768 None,
5769 &[],
5770 ReconcileContext::Apply,
5771 false,
5772 )
5773 .unwrap();
5774
5775 assert_eq!(result.status, ApplyStatus::Success);
5776 assert_eq!(result.succeeded(), 1);
5777 assert_eq!(result.failed(), 0);
5778
5779 let last = state.last_apply().unwrap().unwrap();
5781 assert_eq!(last.id, result.apply_id);
5782 assert_eq!(last.status, ApplyStatus::Success);
5783 assert!(last.summary.is_some());
5784
5785 let resources = state.managed_resources().unwrap();
5787 assert!(
5788 !resources.is_empty(),
5789 "Expected managed resources after apply"
5790 );
5791 }
5792
5793 #[test]
5794 fn apply_records_summary_json() {
5795 let state = test_state();
5796 let mut registry = ProviderRegistry::new();
5797 registry
5798 .package_managers
5799 .push(Box::new(TrackingPackageManager::new("brew")));
5800
5801 let reconciler = Reconciler::new(®istry, &state);
5802 let resolved = make_empty_resolved();
5803
5804 let pkg_actions = vec![PackageAction::Install {
5805 manager: "brew".to_string(),
5806 packages: vec!["jq".to_string()],
5807 origin: "local".to_string(),
5808 }];
5809
5810 let plan = reconciler
5811 .plan(
5812 &resolved,
5813 Vec::new(),
5814 pkg_actions,
5815 Vec::new(),
5816 ReconcileContext::Apply,
5817 )
5818 .unwrap();
5819 let printer = test_printer();
5820 let result = reconciler
5821 .apply(
5822 &plan,
5823 &resolved,
5824 Path::new("."),
5825 &printer,
5826 None,
5827 &[],
5828 ReconcileContext::Apply,
5829 false,
5830 )
5831 .unwrap();
5832
5833 let last = state.last_apply().unwrap().unwrap();
5835 let summary = last.summary.unwrap();
5836 let parsed: serde_json::Value = serde_json::from_str(&summary).unwrap();
5837 assert_eq!(parsed["total"], 1);
5838 assert_eq!(parsed["succeeded"], 1);
5839 assert_eq!(parsed["failed"], 0);
5840 assert_eq!(result.apply_id, last.id);
5841 }
5842
5843 #[test]
5844 fn apply_with_phase_filter_only_runs_matching_phase() {
5845 let state = test_state();
5846 let mut registry = ProviderRegistry::new();
5847 registry
5848 .package_managers
5849 .push(Box::new(TrackingPackageManager::new("brew")));
5850
5851 let reconciler = Reconciler::new(®istry, &state);
5852 let resolved = make_empty_resolved();
5853
5854 let pkg_actions = vec![PackageAction::Install {
5856 manager: "brew".to_string(),
5857 packages: vec!["ripgrep".to_string()],
5858 origin: "local".to_string(),
5859 }];
5860
5861 let plan = reconciler
5862 .plan(
5863 &resolved,
5864 Vec::new(),
5865 pkg_actions,
5866 Vec::new(),
5867 ReconcileContext::Apply,
5868 )
5869 .unwrap();
5870
5871 let printer = test_printer();
5872
5873 let result = reconciler
5875 .apply(
5876 &plan,
5877 &resolved,
5878 Path::new("."),
5879 &printer,
5880 Some(&PhaseName::Env),
5881 &[],
5882 ReconcileContext::Apply,
5883 false,
5884 )
5885 .unwrap();
5886
5887 assert_eq!(result.status, ApplyStatus::Success);
5888 assert_eq!(result.action_results.len(), 0);
5890 }
5891
5892 #[test]
5893 fn apply_with_phase_filter_runs_only_packages() {
5894 let state = test_state();
5895 let mut registry = ProviderRegistry::new();
5896 registry
5897 .package_managers
5898 .push(Box::new(TrackingPackageManager::new("brew")));
5899
5900 let reconciler = Reconciler::new(®istry, &state);
5901 let resolved = make_empty_resolved();
5902
5903 let pkg_actions = vec![PackageAction::Install {
5904 manager: "brew".to_string(),
5905 packages: vec!["ripgrep".to_string()],
5906 origin: "local".to_string(),
5907 }];
5908
5909 let plan = reconciler
5910 .plan(
5911 &resolved,
5912 Vec::new(),
5913 pkg_actions,
5914 Vec::new(),
5915 ReconcileContext::Apply,
5916 )
5917 .unwrap();
5918
5919 let printer = test_printer();
5920
5921 let result = reconciler
5923 .apply(
5924 &plan,
5925 &resolved,
5926 Path::new("."),
5927 &printer,
5928 Some(&PhaseName::Packages),
5929 &[],
5930 ReconcileContext::Apply,
5931 false,
5932 )
5933 .unwrap();
5934
5935 assert_eq!(result.status, ApplyStatus::Success);
5936 assert_eq!(result.action_results.len(), 1);
5937 assert!(result.action_results[0].success);
5938 }
5939
5940 #[test]
5941 fn apply_file_create_action_writes_file() {
5942 let dir = tempfile::tempdir().unwrap();
5943 let source = dir.path().join("source.txt");
5944 let target = dir.path().join("subdir/target.txt");
5945 std::fs::write(&source, "hello world").unwrap();
5946
5947 let state = test_state();
5948 let mut registry = ProviderRegistry::new();
5949 registry.default_file_strategy = crate::config::FileStrategy::Copy;
5950
5951 let reconciler = Reconciler::new(®istry, &state);
5952 let resolved = make_empty_resolved();
5953
5954 let file_actions = vec![FileAction::Create {
5955 source: source.clone(),
5956 target: target.clone(),
5957 origin: "local".to_string(),
5958 strategy: crate::config::FileStrategy::Copy,
5959 source_hash: None,
5960 }];
5961
5962 let plan = reconciler
5963 .plan(
5964 &resolved,
5965 file_actions,
5966 Vec::new(),
5967 Vec::new(),
5968 ReconcileContext::Apply,
5969 )
5970 .unwrap();
5971
5972 let printer = test_printer();
5973 let result = reconciler
5974 .apply(
5975 &plan,
5976 &resolved,
5977 dir.path(),
5978 &printer,
5979 Some(&PhaseName::Files),
5980 &[],
5981 ReconcileContext::Apply,
5982 false,
5983 )
5984 .unwrap();
5985
5986 assert_eq!(result.status, ApplyStatus::Success);
5987 assert_eq!(result.action_results.len(), 1);
5988 assert!(result.action_results[0].success);
5989
5990 assert!(target.exists());
5992 let content = std::fs::read_to_string(&target).unwrap();
5993 assert_eq!(content, "hello world");
5994 }
5995
5996 #[test]
5997 fn apply_multiple_package_actions_all_succeed() {
5998 let state = test_state();
5999 let mut registry = ProviderRegistry::new();
6000 registry
6001 .package_managers
6002 .push(Box::new(TrackingPackageManager::new("brew")));
6003 registry
6004 .package_managers
6005 .push(Box::new(TrackingPackageManager::new("cargo")));
6006
6007 let reconciler = Reconciler::new(®istry, &state);
6008 let resolved = make_empty_resolved();
6009
6010 let pkg_actions = vec![
6011 PackageAction::Install {
6012 manager: "brew".to_string(),
6013 packages: vec!["jq".to_string()],
6014 origin: "local".to_string(),
6015 },
6016 PackageAction::Install {
6017 manager: "cargo".to_string(),
6018 packages: vec!["bat".to_string()],
6019 origin: "local".to_string(),
6020 },
6021 ];
6022
6023 let plan = reconciler
6024 .plan(
6025 &resolved,
6026 Vec::new(),
6027 pkg_actions,
6028 Vec::new(),
6029 ReconcileContext::Apply,
6030 )
6031 .unwrap();
6032
6033 let printer = test_printer();
6034 let result = reconciler
6035 .apply(
6036 &plan,
6037 &resolved,
6038 Path::new("."),
6039 &printer,
6040 None,
6041 &[],
6042 ReconcileContext::Apply,
6043 false,
6044 )
6045 .unwrap();
6046
6047 assert_eq!(result.status, ApplyStatus::Success);
6048 assert_eq!(result.action_results.len(), 2);
6049 assert_eq!(result.succeeded(), 2);
6050 assert_eq!(result.failed(), 0);
6051
6052 let brew = registry.package_managers[0].as_ref();
6054 assert!(brew.installed_packages().unwrap().contains("jq"));
6055 let cargo = registry.package_managers[1].as_ref();
6056 assert!(cargo.installed_packages().unwrap().contains("bat"));
6057 }
6058
6059 #[test]
6060 fn apply_package_skip_action_succeeds() {
6061 let state = test_state();
6062 let registry = ProviderRegistry::new();
6063 let reconciler = Reconciler::new(®istry, &state);
6064 let resolved = make_empty_resolved();
6065
6066 let pkg_actions = vec![PackageAction::Skip {
6067 manager: "apt".to_string(),
6068 reason: "not available on macOS".to_string(),
6069 origin: "local".to_string(),
6070 }];
6071
6072 let plan = reconciler
6073 .plan(
6074 &resolved,
6075 Vec::new(),
6076 pkg_actions,
6077 Vec::new(),
6078 ReconcileContext::Apply,
6079 )
6080 .unwrap();
6081
6082 let printer = test_printer();
6083 let result = reconciler
6084 .apply(
6085 &plan,
6086 &resolved,
6087 Path::new("."),
6088 &printer,
6089 None,
6090 &[],
6091 ReconcileContext::Apply,
6092 false,
6093 )
6094 .unwrap();
6095
6096 assert_eq!(result.status, ApplyStatus::Success);
6097 assert_eq!(result.action_results.len(), 1);
6098 assert!(result.action_results[0].success);
6099 assert!(result.action_results[0].description.contains("skip"));
6100 }
6101
6102 #[test]
6103 fn apply_env_write_with_aliases_produces_correct_file() {
6104 let dir = tempfile::tempdir().unwrap();
6105 let env_path = dir.path().join(".cfgd.env");
6106
6107 let env = vec![crate::config::EnvVar {
6108 name: "EDITOR".into(),
6109 value: "nvim".into(),
6110 }];
6111 let aliases = vec![crate::config::ShellAlias {
6112 name: "ll".into(),
6113 command: "ls -la".into(),
6114 }];
6115 let content = super::generate_env_file_content(&env, &aliases);
6116
6117 let action = EnvAction::WriteEnvFile {
6118 path: env_path.clone(),
6119 content: content.clone(),
6120 };
6121
6122 let printer = test_printer();
6123 Reconciler::apply_env_action(&action, &printer).unwrap();
6124
6125 let written = std::fs::read_to_string(&env_path).unwrap();
6126 assert!(written.contains("export EDITOR=\"nvim\""));
6127 assert!(written.contains("alias ll=\"ls -la\""));
6128 assert!(written.starts_with("# managed by cfgd"));
6129 }
6130
6131 #[test]
6132 fn combine_script_output_both() {
6133 let result = super::combine_script_output("hello\nworld", "warn: something");
6134 assert_eq!(
6135 result,
6136 Some("hello\nworld\n--- stderr ---\nwarn: something".to_string())
6137 );
6138 }
6139
6140 #[test]
6141 fn combine_script_output_stdout_only() {
6142 let result = super::combine_script_output("output line", "");
6143 assert_eq!(result, Some("output line".to_string()));
6144 }
6145
6146 #[test]
6147 fn combine_script_output_stderr_only() {
6148 let result = super::combine_script_output("", "error msg");
6149 assert_eq!(result, Some("error msg".to_string()));
6150 }
6151
6152 #[test]
6153 fn combine_script_output_empty() {
6154 assert!(super::combine_script_output("", "").is_none());
6155 assert!(super::combine_script_output(" ", " \n ").is_none());
6156 }
6157
6158 #[test]
6159 fn continue_on_error_defaults_per_phase() {
6160 assert!(!super::default_continue_on_error(&ScriptPhase::PreApply));
6162 assert!(!super::default_continue_on_error(
6163 &ScriptPhase::PreReconcile
6164 ));
6165 assert!(super::default_continue_on_error(&ScriptPhase::PostApply));
6167 assert!(super::default_continue_on_error(
6168 &ScriptPhase::PostReconcile
6169 ));
6170 assert!(super::default_continue_on_error(&ScriptPhase::OnChange));
6171 assert!(super::default_continue_on_error(&ScriptPhase::OnDrift));
6172 }
6173
6174 #[test]
6175 fn effective_continue_on_error_uses_explicit_value() {
6176 let entry = ScriptEntry::Full {
6177 run: "echo test".to_string(),
6178 timeout: None,
6179 idle_timeout: None,
6180 continue_on_error: Some(true),
6181 };
6182 assert!(super::effective_continue_on_error(
6184 &entry,
6185 &ScriptPhase::PreApply
6186 ));
6187
6188 let entry_false = ScriptEntry::Full {
6189 run: "echo test".to_string(),
6190 timeout: None,
6191 idle_timeout: None,
6192 continue_on_error: Some(false),
6193 };
6194 assert!(!super::effective_continue_on_error(
6196 &entry_false,
6197 &ScriptPhase::PostApply
6198 ));
6199 }
6200
6201 #[test]
6202 fn effective_continue_on_error_falls_back_to_default() {
6203 let simple = ScriptEntry::Simple("echo test".to_string());
6204 assert!(!super::effective_continue_on_error(
6205 &simple,
6206 &ScriptPhase::PreApply
6207 ));
6208 assert!(super::effective_continue_on_error(
6209 &simple,
6210 &ScriptPhase::PostApply
6211 ));
6212
6213 let full_no_override = ScriptEntry::Full {
6214 run: "echo test".to_string(),
6215 timeout: None,
6216 idle_timeout: None,
6217 continue_on_error: None,
6218 };
6219 assert!(!super::effective_continue_on_error(
6220 &full_no_override,
6221 &ScriptPhase::PreApply
6222 ));
6223 assert!(super::effective_continue_on_error(
6224 &full_no_override,
6225 &ScriptPhase::PostApply
6226 ));
6227 }
6228
6229 #[test]
6230 fn plan_scripts_with_apply_context_uses_pre_post_apply() {
6231 let state = test_state();
6232 let registry = ProviderRegistry::new();
6233 let reconciler = Reconciler::new(®istry, &state);
6234
6235 let mut resolved = make_empty_resolved();
6236 resolved.merged.scripts.pre_apply = vec![ScriptEntry::Simple("scripts/pre.sh".to_string())];
6237 resolved.merged.scripts.post_apply =
6238 vec![ScriptEntry::Simple("scripts/post.sh".to_string())];
6239
6240 let plan = reconciler
6241 .plan(
6242 &resolved,
6243 Vec::new(),
6244 Vec::new(),
6245 Vec::new(),
6246 ReconcileContext::Apply,
6247 )
6248 .unwrap();
6249
6250 let pre_phase = plan
6251 .phases
6252 .iter()
6253 .find(|p| p.name == PhaseName::PreScripts)
6254 .unwrap();
6255 assert_eq!(pre_phase.actions.len(), 1);
6256 match &pre_phase.actions[0] {
6257 Action::Script(ScriptAction::Run { entry, phase, .. }) => {
6258 assert_eq!(entry.run_str(), "scripts/pre.sh");
6259 assert_eq!(*phase, ScriptPhase::PreApply);
6260 }
6261 _ => panic!("expected Script action"),
6262 }
6263
6264 let post_phase = plan
6265 .phases
6266 .iter()
6267 .find(|p| p.name == PhaseName::PostScripts)
6268 .unwrap();
6269 assert_eq!(post_phase.actions.len(), 1);
6270 match &post_phase.actions[0] {
6271 Action::Script(ScriptAction::Run { entry, phase, .. }) => {
6272 assert_eq!(entry.run_str(), "scripts/post.sh");
6273 assert_eq!(*phase, ScriptPhase::PostApply);
6274 }
6275 _ => panic!("expected Script action"),
6276 }
6277 }
6278
6279 #[test]
6280 fn plan_scripts_carries_full_entry() {
6281 let state = test_state();
6282 let registry = ProviderRegistry::new();
6283 let reconciler = Reconciler::new(®istry, &state);
6284
6285 let mut resolved = make_empty_resolved();
6286 resolved.merged.scripts.pre_apply = vec![ScriptEntry::Full {
6287 run: "scripts/check.sh".to_string(),
6288 timeout: Some("10s".to_string()),
6289 idle_timeout: None,
6290 continue_on_error: Some(true),
6291 }];
6292
6293 let plan = reconciler
6294 .plan(
6295 &resolved,
6296 Vec::new(),
6297 Vec::new(),
6298 Vec::new(),
6299 ReconcileContext::Apply,
6300 )
6301 .unwrap();
6302
6303 let pre_phase = plan
6304 .phases
6305 .iter()
6306 .find(|p| p.name == PhaseName::PreScripts)
6307 .unwrap();
6308 assert_eq!(pre_phase.actions.len(), 1);
6309 match &pre_phase.actions[0] {
6310 Action::Script(ScriptAction::Run { entry, .. }) => match entry {
6311 ScriptEntry::Full {
6312 run,
6313 timeout,
6314 continue_on_error,
6315 ..
6316 } => {
6317 assert_eq!(run, "scripts/check.sh");
6318 assert_eq!(timeout.as_deref(), Some("10s"));
6319 assert_eq!(*continue_on_error, Some(true));
6320 }
6321 _ => panic!("expected Full entry"),
6322 },
6323 _ => panic!("expected Script action"),
6324 }
6325 }
6326
6327 #[test]
6328 fn build_script_env_includes_expected_vars() {
6329 let env = super::build_script_env(
6330 std::path::Path::new("/home/user/.config/cfgd"),
6331 "default",
6332 ReconcileContext::Apply,
6333 &ScriptPhase::PreApply,
6334 false,
6335 None,
6336 None,
6337 );
6338 let map: HashMap<String, String> = env.into_iter().collect();
6339 assert_eq!(
6340 map.get("CFGD_CONFIG_DIR").unwrap(),
6341 "/home/user/.config/cfgd"
6342 );
6343 assert_eq!(map.get("CFGD_PROFILE").unwrap(), "default");
6344 assert_eq!(map.get("CFGD_CONTEXT").unwrap(), "apply");
6345 assert_eq!(map.get("CFGD_PHASE").unwrap(), "preApply");
6346 assert_eq!(map.get("CFGD_DRY_RUN").unwrap(), "false");
6347 assert!(!map.contains_key("CFGD_MODULE_NAME"));
6348 assert!(!map.contains_key("CFGD_MODULE_DIR"));
6349 }
6350
6351 #[test]
6352 fn build_script_env_includes_module_vars() {
6353 let env = super::build_script_env(
6354 std::path::Path::new("/config"),
6355 "work",
6356 ReconcileContext::Reconcile,
6357 &ScriptPhase::PostApply,
6358 true,
6359 Some("nvim"),
6360 Some(std::path::Path::new("/modules/nvim")),
6361 );
6362 let map: HashMap<String, String> = env.into_iter().collect();
6363 assert_eq!(map.get("CFGD_MODULE_NAME").unwrap(), "nvim");
6364 assert_eq!(map.get("CFGD_MODULE_DIR").unwrap(), "/modules/nvim");
6365 assert_eq!(map.get("CFGD_DRY_RUN").unwrap(), "true");
6366 assert_eq!(map.get("CFGD_CONTEXT").unwrap(), "reconcile");
6367 }
6368
6369 #[test]
6370 fn execute_script_inline_command() {
6371 let printer = test_printer();
6372 let entry = ScriptEntry::Simple("echo hello".to_string());
6373 let dir = tempfile::tempdir().unwrap();
6374 let (desc, changed, output) = super::execute_script(
6375 &entry,
6376 dir.path(),
6377 &[],
6378 std::time::Duration::from_secs(10),
6379 &printer,
6380 )
6381 .unwrap();
6382 assert!(desc.contains("echo hello"));
6383 assert!(changed);
6384 assert_eq!(output, Some("hello".to_string()));
6385 }
6386
6387 #[test]
6388 fn execute_script_failure_returns_error() {
6389 let printer = test_printer();
6390 let entry = ScriptEntry::Simple("exit 1".to_string());
6391 let dir = tempfile::tempdir().unwrap();
6392 let result = super::execute_script(
6393 &entry,
6394 dir.path(),
6395 &[],
6396 std::time::Duration::from_secs(10),
6397 &printer,
6398 );
6399 assert!(result.is_err());
6400 let err = result.unwrap_err().to_string();
6401 assert!(
6402 err.contains("exit 1"),
6403 "error should mention exit code: {err}"
6404 );
6405 }
6406
6407 #[test]
6408 fn execute_script_with_timeout_override() {
6409 let printer = test_printer();
6410 let entry = ScriptEntry::Full {
6411 run: "echo fast".to_string(),
6412 timeout: Some("5s".to_string()),
6413 idle_timeout: None,
6414 continue_on_error: None,
6415 };
6416 let dir = tempfile::tempdir().unwrap();
6417 let (_, _, output) = super::execute_script(
6418 &entry,
6419 dir.path(),
6420 &[],
6421 std::time::Duration::from_secs(300),
6422 &printer,
6423 )
6424 .unwrap();
6425 assert_eq!(output, Some("fast".to_string()));
6426 }
6427
6428 #[test]
6429 #[cfg(unix)]
6430 fn execute_script_injects_env_vars() {
6431 let printer = test_printer();
6432 let entry = ScriptEntry::Simple("echo $MY_VAR".to_string());
6433 let dir = tempfile::tempdir().unwrap();
6434 let env = vec![("MY_VAR".to_string(), "test_value".to_string())];
6435 let (_, _, output) = super::execute_script(
6436 &entry,
6437 dir.path(),
6438 &env,
6439 std::time::Duration::from_secs(10),
6440 &printer,
6441 )
6442 .unwrap();
6443 assert_eq!(output, Some("test_value".to_string()));
6444 }
6445
6446 #[test]
6447 #[cfg(unix)]
6448 fn execute_script_runs_executable_file() {
6449 let dir = tempfile::tempdir().unwrap();
6450 let script_path = dir.path().join("test.sh");
6451 std::fs::write(&script_path, "#!/bin/sh\necho from_file\n").unwrap();
6452 {
6453 use std::os::unix::fs::PermissionsExt;
6454 std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap();
6455 }
6456 let printer = test_printer();
6457 let entry = ScriptEntry::Simple("test.sh".to_string());
6458 let (_, _, output) = super::execute_script(
6459 &entry,
6460 dir.path(),
6461 &[],
6462 std::time::Duration::from_secs(10),
6463 &printer,
6464 )
6465 .unwrap();
6466 assert_eq!(output, Some("from_file".to_string()));
6467 }
6468
6469 #[test]
6470 fn execute_script_rejects_non_executable_file() {
6471 let dir = tempfile::tempdir().unwrap();
6472 let script_path = dir.path().join("noexec.sh");
6473 std::fs::write(&script_path, "#!/bin/sh\necho hi\n").unwrap();
6474 #[cfg(unix)]
6475 {
6476 use std::os::unix::fs::PermissionsExt;
6477 std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o644)).unwrap();
6478 }
6479 let printer = test_printer();
6480 let entry = ScriptEntry::Simple("noexec.sh".to_string());
6481 let result = super::execute_script(
6482 &entry,
6483 dir.path(),
6484 &[],
6485 std::time::Duration::from_secs(10),
6486 &printer,
6487 );
6488 assert!(result.is_err());
6489 let err = result.unwrap_err().to_string();
6490 assert!(
6491 err.contains("not executable"),
6492 "should say not executable: {err}"
6493 );
6494 }
6495
6496 #[test]
6497 #[cfg(unix)]
6498 fn execute_script_idle_timeout_kills_idle_process() {
6499 let printer = test_printer();
6500 let entry = ScriptEntry::Full {
6502 run: "echo started; sleep 60".to_string(),
6503 timeout: Some("30s".to_string()),
6504 idle_timeout: Some("1s".to_string()),
6505 continue_on_error: None,
6506 };
6507 let dir = tempfile::tempdir().unwrap();
6508 let result = super::execute_script(
6509 &entry,
6510 dir.path(),
6511 &[],
6512 std::time::Duration::from_secs(30),
6513 &printer,
6514 );
6515 assert!(result.is_err());
6516 let err = result.unwrap_err().to_string();
6517 assert!(
6518 err.contains("idle (no output)"),
6519 "should mention idle timeout: {err}"
6520 );
6521 }
6522
6523 #[test]
6526 fn rollback_restores_file_content() {
6527 let dir = tempfile::tempdir().unwrap();
6528 let target = dir.path().join("config.txt");
6529 let file_path = target.display().to_string();
6530
6531 let state = test_state();
6535
6536 let apply_id_1 = state
6538 .record_apply("test", "hash1", ApplyStatus::Success, None)
6539 .unwrap();
6540 let resource_id = format!("file:create:{}", target.display());
6541 let jid1 = state
6542 .journal_begin(apply_id_1, 0, "files", "file", &resource_id, None)
6543 .unwrap();
6544 state.journal_complete(jid1, None, None).unwrap();
6545 std::fs::write(&target, "v1 content").unwrap();
6546
6547 let file_state = crate::capture_file_state(&target).unwrap().unwrap();
6549 let apply_id_2 = state
6550 .record_apply("test", "hash2", ApplyStatus::Success, None)
6551 .unwrap();
6552 let update_resource_id = format!("file:update:{}", target.display());
6553 state
6554 .store_file_backup(apply_id_2, &file_path, &file_state)
6555 .unwrap();
6556 let jid2 = state
6557 .journal_begin(apply_id_2, 0, "files", "file", &update_resource_id, None)
6558 .unwrap();
6559 state.journal_complete(jid2, None, None).unwrap();
6560 std::fs::write(&target, "v2 content").unwrap();
6561
6562 let registry = ProviderRegistry::new();
6564 let reconciler = Reconciler::new(®istry, &state);
6565 let printer = test_printer();
6566 let rollback_result = reconciler.rollback_apply(apply_id_1, &printer).unwrap();
6567
6568 assert_eq!(rollback_result.files_restored, 1);
6569 assert_eq!(rollback_result.files_removed, 0);
6570 assert!(rollback_result.non_file_actions.is_empty());
6571
6572 let restored = std::fs::read_to_string(&target).unwrap();
6573 assert_eq!(restored, "v1 content");
6574 }
6575
6576 #[test]
6577 fn rollback_no_changes_when_at_latest_apply() {
6578 let state = test_state();
6581 let apply_id = state
6582 .record_apply("test", "hash1", ApplyStatus::Success, None)
6583 .unwrap();
6584
6585 let registry = ProviderRegistry::new();
6586 let reconciler = Reconciler::new(®istry, &state);
6587 let printer = test_printer();
6588 let rollback_result = reconciler.rollback_apply(apply_id, &printer).unwrap();
6589
6590 assert_eq!(rollback_result.files_restored, 0);
6591 assert_eq!(rollback_result.files_removed, 0);
6592 assert!(rollback_result.non_file_actions.is_empty());
6593 }
6594
6595 #[test]
6596 fn rollback_lists_non_file_actions() {
6597 let state = test_state();
6598 let apply_id_1 = state
6599 .record_apply("test", "hash1", ApplyStatus::Success, None)
6600 .unwrap();
6601
6602 let apply_id_2 = state
6604 .record_apply("test", "hash2", ApplyStatus::Success, None)
6605 .unwrap();
6606 let journal_id = state
6607 .journal_begin(
6608 apply_id_2,
6609 0,
6610 "packages",
6611 "package",
6612 "package:brew:install:ripgrep",
6613 None,
6614 )
6615 .unwrap();
6616 state.journal_complete(journal_id, None, None).unwrap();
6617
6618 let registry = ProviderRegistry::new();
6619 let reconciler = Reconciler::new(®istry, &state);
6620 let printer = test_printer();
6621 let rollback_result = reconciler.rollback_apply(apply_id_1, &printer).unwrap();
6622
6623 assert_eq!(rollback_result.files_restored, 0);
6624 assert_eq!(rollback_result.files_removed, 0);
6625 assert_eq!(rollback_result.non_file_actions.len(), 1);
6626 assert!(rollback_result.non_file_actions[0].contains("ripgrep"));
6627 }
6628
6629 #[test]
6630 fn rollback_records_new_apply_entry() {
6631 let state = test_state();
6632 let apply_id = state
6633 .record_apply("test", "hash1", ApplyStatus::Success, None)
6634 .unwrap();
6635
6636 let registry = ProviderRegistry::new();
6637 let reconciler = Reconciler::new(®istry, &state);
6638 let printer = test_printer();
6639 reconciler.rollback_apply(apply_id, &printer).unwrap();
6640
6641 let last = state.last_apply().unwrap().unwrap();
6643 assert_eq!(last.profile, "rollback");
6644 assert!(last.id > apply_id);
6645 }
6646
6647 struct FailingPackageManager {
6651 name: String,
6652 }
6653
6654 impl FailingPackageManager {
6655 fn new(name: &str) -> Self {
6656 Self {
6657 name: name.to_string(),
6658 }
6659 }
6660 }
6661
6662 impl PackageManager for FailingPackageManager {
6663 fn name(&self) -> &str {
6664 &self.name
6665 }
6666 fn is_available(&self) -> bool {
6667 true
6668 }
6669 fn can_bootstrap(&self) -> bool {
6670 false
6671 }
6672 fn bootstrap(&self, _printer: &Printer) -> Result<()> {
6673 Ok(())
6674 }
6675 fn installed_packages(&self) -> Result<HashSet<String>> {
6676 Ok(HashSet::new())
6677 }
6678 fn install(&self, _packages: &[String], _printer: &Printer) -> Result<()> {
6679 Err(crate::errors::PackageError::InstallFailed {
6680 manager: self.name.clone(),
6681 message: "simulated install failure".to_string(),
6682 }
6683 .into())
6684 }
6685 fn uninstall(&self, _packages: &[String], _printer: &Printer) -> Result<()> {
6686 Ok(())
6687 }
6688 fn update(&self, _printer: &Printer) -> Result<()> {
6689 Ok(())
6690 }
6691 fn available_version(&self, _package: &str) -> Result<Option<String>> {
6692 Ok(None)
6693 }
6694 }
6695
6696 #[test]
6697 fn apply_partial_when_some_actions_fail() {
6698 let state = test_state();
6699 let mut registry = ProviderRegistry::new();
6700
6701 registry
6703 .package_managers
6704 .push(Box::new(TrackingPackageManager::new("brew")));
6705 registry
6706 .package_managers
6707 .push(Box::new(FailingPackageManager::new("apt")));
6708
6709 let reconciler = Reconciler::new(®istry, &state);
6710 let resolved = make_empty_resolved();
6711
6712 let pkg_actions = vec![
6713 PackageAction::Install {
6714 manager: "brew".to_string(),
6715 packages: vec!["jq".to_string()],
6716 origin: "local".to_string(),
6717 },
6718 PackageAction::Install {
6719 manager: "apt".to_string(),
6720 packages: vec!["curl".to_string()],
6721 origin: "local".to_string(),
6722 },
6723 ];
6724
6725 let plan = reconciler
6726 .plan(
6727 &resolved,
6728 Vec::new(),
6729 pkg_actions,
6730 Vec::new(),
6731 ReconcileContext::Apply,
6732 )
6733 .unwrap();
6734
6735 let printer = test_printer();
6736 let result = reconciler
6737 .apply(
6738 &plan,
6739 &resolved,
6740 Path::new("."),
6741 &printer,
6742 None,
6743 &[],
6744 ReconcileContext::Apply,
6745 false,
6746 )
6747 .unwrap();
6748
6749 assert_eq!(result.status, ApplyStatus::Partial);
6750 assert_eq!(result.succeeded(), 1);
6751 assert_eq!(result.failed(), 1);
6752
6753 let last = state.last_apply().unwrap().unwrap();
6755 assert_eq!(last.status, ApplyStatus::Partial);
6756 }
6757
6758 #[test]
6759 fn apply_failed_when_all_actions_fail() {
6760 let state = test_state();
6761 let mut registry = ProviderRegistry::new();
6762
6763 registry
6764 .package_managers
6765 .push(Box::new(FailingPackageManager::new("apt")));
6766
6767 let reconciler = Reconciler::new(®istry, &state);
6768 let resolved = make_empty_resolved();
6769
6770 let pkg_actions = vec![PackageAction::Install {
6771 manager: "apt".to_string(),
6772 packages: vec!["curl".to_string()],
6773 origin: "local".to_string(),
6774 }];
6775
6776 let plan = reconciler
6777 .plan(
6778 &resolved,
6779 Vec::new(),
6780 pkg_actions,
6781 Vec::new(),
6782 ReconcileContext::Apply,
6783 )
6784 .unwrap();
6785
6786 let printer = test_printer();
6787 let result = reconciler
6788 .apply(
6789 &plan,
6790 &resolved,
6791 Path::new("."),
6792 &printer,
6793 None,
6794 &[],
6795 ReconcileContext::Apply,
6796 false,
6797 )
6798 .unwrap();
6799
6800 assert_eq!(result.status, ApplyStatus::Failed);
6801 assert_eq!(result.succeeded(), 0);
6802 assert_eq!(result.failed(), 1);
6803 assert!(result.action_results[0].error.is_some());
6804
6805 let last = state.last_apply().unwrap().unwrap();
6806 assert_eq!(last.status, ApplyStatus::Failed);
6807 }
6808
6809 #[test]
6812 #[cfg(unix)]
6813 fn apply_continue_on_error_post_script_continues() {
6814 let state = test_state();
6816 let mut registry = ProviderRegistry::new();
6817 registry
6818 .package_managers
6819 .push(Box::new(TrackingPackageManager::new("brew")));
6820
6821 let reconciler = Reconciler::new(®istry, &state);
6822 let mut resolved = make_empty_resolved();
6823
6824 resolved.merged.scripts.post_apply = vec![ScriptEntry::Full {
6826 run: "exit 42".to_string(),
6827 timeout: Some("5s".to_string()),
6828 idle_timeout: None,
6829 continue_on_error: Some(true),
6830 }];
6831
6832 let pkg_actions = vec![PackageAction::Install {
6833 manager: "brew".to_string(),
6834 packages: vec!["jq".to_string()],
6835 origin: "local".to_string(),
6836 }];
6837
6838 let plan = reconciler
6839 .plan(
6840 &resolved,
6841 Vec::new(),
6842 pkg_actions,
6843 Vec::new(),
6844 ReconcileContext::Apply,
6845 )
6846 .unwrap();
6847
6848 let printer = test_printer();
6849 let result = reconciler
6850 .apply(
6851 &plan,
6852 &resolved,
6853 Path::new("."),
6854 &printer,
6855 None,
6856 &[],
6857 ReconcileContext::Apply,
6858 false,
6859 )
6860 .unwrap();
6861
6862 assert_eq!(result.status, ApplyStatus::Partial);
6864 assert_eq!(result.succeeded(), 1); assert_eq!(result.failed(), 1); let failed = result.action_results.iter().find(|r| !r.success).unwrap();
6869 assert!(
6870 failed.description.contains("exit 42"),
6871 "failed action should be the script: {}",
6872 failed.description
6873 );
6874 }
6875
6876 #[test]
6877 #[cfg(unix)]
6878 fn apply_continue_on_error_false_pre_script_aborts() {
6879 let state = test_state();
6881 let registry = ProviderRegistry::new();
6882 let reconciler = Reconciler::new(®istry, &state);
6883
6884 let mut resolved = make_empty_resolved();
6885 resolved.merged.scripts.pre_apply = vec![ScriptEntry::Full {
6886 run: "exit 1".to_string(),
6887 timeout: Some("5s".to_string()),
6888 idle_timeout: None,
6889 continue_on_error: Some(false),
6890 }];
6891
6892 let plan = reconciler
6893 .plan(
6894 &resolved,
6895 Vec::new(),
6896 Vec::new(),
6897 Vec::new(),
6898 ReconcileContext::Apply,
6899 )
6900 .unwrap();
6901
6902 let printer = test_printer();
6903 let result = reconciler.apply(
6904 &plan,
6905 &resolved,
6906 Path::new("."),
6907 &printer,
6908 None,
6909 &[],
6910 ReconcileContext::Apply,
6911 false,
6912 );
6913
6914 assert!(result.is_err());
6916 let err = result.unwrap_err().to_string();
6917 assert!(
6918 err.contains("pre-script failed"),
6919 "should mention pre-script failure: {err}"
6920 );
6921 }
6922
6923 #[test]
6924 #[cfg(unix)]
6925 fn apply_continue_on_error_default_post_script_continues() {
6926 let state = test_state();
6928 let registry = ProviderRegistry::new();
6929 let reconciler = Reconciler::new(®istry, &state);
6930
6931 let mut resolved = make_empty_resolved();
6932 resolved.merged.scripts.post_apply = vec![ScriptEntry::Simple("exit 1".to_string())];
6934
6935 let plan = reconciler
6936 .plan(
6937 &resolved,
6938 Vec::new(),
6939 Vec::new(),
6940 Vec::new(),
6941 ReconcileContext::Apply,
6942 )
6943 .unwrap();
6944
6945 let printer = test_printer();
6946 let result = reconciler
6947 .apply(
6948 &plan,
6949 &resolved,
6950 Path::new("."),
6951 &printer,
6952 None,
6953 &[],
6954 ReconcileContext::Apply,
6955 false,
6956 )
6957 .unwrap();
6958
6959 assert_eq!(result.status, ApplyStatus::Failed);
6961 assert_eq!(result.failed(), 1);
6962 }
6963
6964 #[test]
6967 #[cfg(unix)]
6968 fn apply_on_change_script_runs_when_changes_occur() {
6969 let dir = tempfile::tempdir().unwrap();
6970 let source = dir.path().join("source.txt");
6971 let target = dir.path().join("target.txt");
6972 let marker = dir.path().join("on_change_marker");
6973
6974 std::fs::write(&source, "hello").unwrap();
6975
6976 let state = test_state();
6977 let mut registry = ProviderRegistry::new();
6978 registry.default_file_strategy = crate::config::FileStrategy::Copy;
6979
6980 let reconciler = Reconciler::new(®istry, &state);
6981 let mut resolved = make_empty_resolved();
6982
6983 resolved.merged.scripts.on_change =
6985 vec![ScriptEntry::Simple(format!("touch {}", marker.display()))];
6986
6987 let file_actions = vec![FileAction::Create {
6988 source: source.clone(),
6989 target: target.clone(),
6990 origin: "local".to_string(),
6991 strategy: crate::config::FileStrategy::Copy,
6992 source_hash: None,
6993 }];
6994
6995 let plan = reconciler
6996 .plan(
6997 &resolved,
6998 file_actions,
6999 Vec::new(),
7000 Vec::new(),
7001 ReconcileContext::Apply,
7002 )
7003 .unwrap();
7004
7005 let printer = test_printer();
7006 let result = reconciler
7007 .apply(
7008 &plan,
7009 &resolved,
7010 dir.path(),
7011 &printer,
7012 None,
7013 &[],
7014 ReconcileContext::Apply,
7015 false,
7016 )
7017 .unwrap();
7018
7019 assert_eq!(result.status, ApplyStatus::Success);
7020
7021 assert!(
7023 marker.exists(),
7024 "onChange marker file should exist, proving the onChange script ran"
7025 );
7026
7027 assert!(target.exists());
7029 assert_eq!(std::fs::read_to_string(&target).unwrap(), "hello");
7030 }
7031
7032 #[test]
7033 #[cfg(unix)]
7034 fn apply_on_change_script_does_not_run_when_no_changes() {
7035 let dir = tempfile::tempdir().unwrap();
7036 let marker = dir.path().join("on_change_marker_noop");
7037
7038 let state = test_state();
7039 let registry = ProviderRegistry::new();
7040 let reconciler = Reconciler::new(®istry, &state);
7041
7042 let mut resolved = make_empty_resolved();
7043 resolved.merged.scripts.on_change =
7044 vec![ScriptEntry::Simple(format!("touch {}", marker.display()))];
7045
7046 let plan = reconciler
7048 .plan(
7049 &resolved,
7050 Vec::new(),
7051 Vec::new(),
7052 Vec::new(),
7053 ReconcileContext::Apply,
7054 )
7055 .unwrap();
7056
7057 let printer = test_printer();
7058 let result = reconciler
7059 .apply(
7060 &plan,
7061 &resolved,
7062 dir.path(),
7063 &printer,
7064 None,
7065 &[],
7066 ReconcileContext::Apply,
7067 false,
7068 )
7069 .unwrap();
7070
7071 assert_eq!(result.status, ApplyStatus::Success);
7072 assert!(
7074 !marker.exists(),
7075 "onChange marker should NOT exist when no changes occurred"
7076 );
7077 }
7078
7079 #[test]
7082 fn parse_resource_from_description_cases() {
7083 let cases: &[(&str, &str, &str)] = &[
7084 (
7085 "file:create:/home/user/.config",
7086 "file",
7087 "/home/user/.config",
7088 ),
7089 ("system:skip", "system", "skip"),
7090 ("unknown-action", "unknown", "unknown-action"),
7091 (
7092 "secret:resolve:vault:path/to/secret",
7093 "secret",
7094 "vault:path/to/secret",
7095 ),
7096 ];
7097 for (input, expected_type, expected_id) in cases {
7098 let (rtype, rid) = super::parse_resource_from_description(input);
7099 assert_eq!(rtype, *expected_type, "wrong type for {input:?}");
7100 assert_eq!(rid, *expected_id, "wrong id for {input:?}");
7101 }
7102 }
7103
7104 #[test]
7105 fn provenance_suffix_local_is_empty() {
7106 assert_eq!(super::provenance_suffix("local"), "");
7107 assert_eq!(super::provenance_suffix(""), "");
7108 }
7109
7110 #[test]
7111 fn provenance_suffix_non_local() {
7112 assert_eq!(super::provenance_suffix("acme"), " <- acme");
7113 assert_eq!(super::provenance_suffix("corp/source"), " <- corp/source");
7114 }
7115
7116 #[test]
7117 fn action_target_path_file_create() {
7118 let target = PathBuf::from("/home/user/.zshrc");
7119 let action = Action::File(FileAction::Create {
7120 source: PathBuf::from("/src"),
7121 target: target.clone(),
7122 origin: "local".into(),
7123 strategy: crate::config::FileStrategy::Copy,
7124 source_hash: None,
7125 });
7126 assert_eq!(super::action_target_path(&action), Some(target));
7127 }
7128
7129 #[test]
7130 fn action_target_path_file_update() {
7131 let target = PathBuf::from("/home/user/.bashrc");
7132 let action = Action::File(FileAction::Update {
7133 source: PathBuf::from("/src"),
7134 target: target.clone(),
7135 diff: String::new(),
7136 origin: "local".into(),
7137 strategy: crate::config::FileStrategy::Copy,
7138 source_hash: None,
7139 });
7140 assert_eq!(super::action_target_path(&action), Some(target));
7141 }
7142
7143 #[test]
7144 fn action_target_path_file_delete() {
7145 let target = PathBuf::from("/home/user/.old");
7146 let action = Action::File(FileAction::Delete {
7147 target: target.clone(),
7148 origin: "local".into(),
7149 });
7150 assert_eq!(super::action_target_path(&action), Some(target));
7151 }
7152
7153 #[test]
7154 fn action_target_path_env_write() {
7155 let path = PathBuf::from("/home/user/.cfgd.env");
7156 let action = Action::Env(EnvAction::WriteEnvFile {
7157 path: path.clone(),
7158 content: "test".into(),
7159 });
7160 assert_eq!(super::action_target_path(&action), Some(path));
7161 }
7162
7163 #[test]
7164 fn action_target_path_package_returns_none() {
7165 let action = Action::Package(PackageAction::Install {
7166 manager: "brew".into(),
7167 packages: vec!["jq".into()],
7168 origin: "local".into(),
7169 });
7170 assert!(super::action_target_path(&action).is_none());
7171 }
7172
7173 #[test]
7174 fn action_target_path_module_returns_none() {
7175 let action = Action::Module(ModuleAction {
7176 module_name: "test".into(),
7177 kind: ModuleActionKind::Skip {
7178 reason: "n/a".into(),
7179 },
7180 });
7181 assert!(super::action_target_path(&action).is_none());
7182 }
7183
7184 #[test]
7185 fn action_target_path_env_inject_returns_none() {
7186 let action = Action::Env(EnvAction::InjectSourceLine {
7187 rc_path: PathBuf::from("/home/user/.bashrc"),
7188 line: "source ~/.cfgd.env".into(),
7189 });
7190 assert!(super::action_target_path(&action).is_none());
7191 }
7192
7193 #[test]
7194 fn phase_name_from_str_unknown_returns_error() {
7195 let result = PhaseName::from_str("unknown-phase");
7196 assert!(result.is_err());
7197 assert_eq!(result.unwrap_err(), "unknown phase: unknown-phase");
7198 }
7199
7200 #[test]
7201 fn script_phase_display_name_all_variants() {
7202 assert_eq!(ScriptPhase::PreApply.display_name(), "preApply");
7203 assert_eq!(ScriptPhase::PostApply.display_name(), "postApply");
7204 assert_eq!(ScriptPhase::PreReconcile.display_name(), "preReconcile");
7205 assert_eq!(ScriptPhase::PostReconcile.display_name(), "postReconcile");
7206 assert_eq!(ScriptPhase::OnDrift.display_name(), "onDrift");
7207 assert_eq!(ScriptPhase::OnChange.display_name(), "onChange");
7208 }
7209
7210 #[test]
7211 fn format_action_description_secret_decrypt() {
7212 let action = Action::Secret(SecretAction::Decrypt {
7213 source: PathBuf::from("secrets/token.enc"),
7214 target: PathBuf::from("/home/user/.token"),
7215 backend: "sops".into(),
7216 origin: "local".into(),
7217 });
7218 let desc = format_action_description(&action);
7219 assert!(desc.starts_with("secret:decrypt:"));
7220 assert!(desc.contains("sops"));
7221 assert!(desc.contains(".token"));
7222 }
7223
7224 #[test]
7225 fn format_action_description_secret_resolve_env() {
7226 let action = Action::Secret(SecretAction::ResolveEnv {
7227 provider: "vault".into(),
7228 reference: "secret/data/gh#token".into(),
7229 envs: vec!["GH_TOKEN".into(), "GITHUB_TOKEN".into()],
7230 origin: "local".into(),
7231 });
7232 let desc = format_action_description(&action);
7233 assert!(desc.contains("secret:resolve-env:vault"));
7234 assert!(desc.contains("GH_TOKEN,GITHUB_TOKEN"));
7235 }
7236
7237 #[test]
7238 fn format_action_description_secret_skip() {
7239 let action = Action::Secret(SecretAction::Skip {
7240 source: "vault://test".into(),
7241 reason: "no backend".into(),
7242 origin: "local".into(),
7243 });
7244 let desc = format_action_description(&action);
7245 assert_eq!(desc, "secret:skip:vault://test");
7246 }
7247
7248 #[test]
7249 fn format_action_description_system_set_value() {
7250 let action = Action::System(SystemAction::SetValue {
7251 configurator: "sysctl".into(),
7252 key: "net.ipv4.ip_forward".into(),
7253 desired: "1".into(),
7254 current: "0".into(),
7255 origin: "local".into(),
7256 });
7257 let desc = format_action_description(&action);
7258 assert_eq!(desc, "system:sysctl.net.ipv4.ip_forward");
7259 }
7260
7261 #[test]
7262 fn format_action_description_system_skip() {
7263 let action = Action::System(SystemAction::Skip {
7264 configurator: "custom".into(),
7265 reason: "no configurator".into(),
7266 origin: "local".into(),
7267 });
7268 let desc = format_action_description(&action);
7269 assert_eq!(desc, "system:custom:skip");
7270 }
7271
7272 #[test]
7273 fn format_action_description_env_write_and_inject() {
7274 let write = Action::Env(EnvAction::WriteEnvFile {
7275 path: PathBuf::from("/home/user/.cfgd.env"),
7276 content: "content".into(),
7277 });
7278 assert!(format_action_description(&write).starts_with("env:write:"));
7279
7280 let inject = Action::Env(EnvAction::InjectSourceLine {
7281 rc_path: PathBuf::from("/home/user/.bashrc"),
7282 line: "source ~/.cfgd.env".into(),
7283 });
7284 assert!(format_action_description(&inject).starts_with("env:inject:"));
7285 }
7286
7287 #[test]
7288 fn format_action_description_module_deploy_files() {
7289 let action = Action::Module(ModuleAction {
7290 module_name: "nvim".into(),
7291 kind: ModuleActionKind::DeployFiles {
7292 files: vec![
7293 crate::modules::ResolvedFile {
7294 source: PathBuf::from("/src/a"),
7295 target: PathBuf::from("/dst/a"),
7296 is_git_source: false,
7297 strategy: None,
7298 encryption: None,
7299 },
7300 crate::modules::ResolvedFile {
7301 source: PathBuf::from("/src/b"),
7302 target: PathBuf::from("/dst/b"),
7303 is_git_source: false,
7304 strategy: None,
7305 encryption: None,
7306 },
7307 ],
7308 },
7309 });
7310 let desc = format_action_description(&action);
7311 assert_eq!(desc, "module:nvim:files:2");
7312 }
7313
7314 #[test]
7315 fn format_action_description_module_skip() {
7316 let action = Action::Module(ModuleAction {
7317 module_name: "broken".into(),
7318 kind: ModuleActionKind::Skip {
7319 reason: "dependency unmet".into(),
7320 },
7321 });
7322 assert_eq!(format_action_description(&action), "module:broken:skip");
7323 }
7324
7325 #[test]
7326 fn format_action_description_module_run_script() {
7327 let action = Action::Module(ModuleAction {
7328 module_name: "nvim".into(),
7329 kind: ModuleActionKind::RunScript {
7330 script: ScriptEntry::Simple("setup.sh".into()),
7331 phase: ScriptPhase::PostApply,
7332 },
7333 });
7334 assert_eq!(format_action_description(&action), "module:nvim:script");
7335 }
7336
7337 #[test]
7338 fn plan_to_hash_string_empty_plan_is_empty() {
7339 let plan = Plan {
7340 phases: vec![],
7341 warnings: vec![],
7342 };
7343 assert_eq!(plan.to_hash_string(), "");
7344 }
7345
7346 #[test]
7347 fn plan_to_hash_string_multiple_phases() {
7348 let plan = Plan {
7349 phases: vec![
7350 Phase {
7351 name: PhaseName::Packages,
7352 actions: vec![Action::Package(PackageAction::Install {
7353 manager: "brew".into(),
7354 packages: vec!["jq".into()],
7355 origin: "local".into(),
7356 })],
7357 },
7358 Phase {
7359 name: PhaseName::Files,
7360 actions: vec![Action::File(FileAction::Create {
7361 source: PathBuf::from("/src"),
7362 target: PathBuf::from("/dst"),
7363 origin: "local".into(),
7364 strategy: crate::config::FileStrategy::Copy,
7365 source_hash: None,
7366 })],
7367 },
7368 ],
7369 warnings: vec![],
7370 };
7371 let hash = plan.to_hash_string();
7372 assert!(hash.contains('|'));
7373 assert!(hash.contains("jq"));
7374 }
7375
7376 #[test]
7377 fn plan_total_actions_sums_across_phases() {
7378 let plan = Plan {
7379 phases: vec![
7380 Phase {
7381 name: PhaseName::Packages,
7382 actions: vec![
7383 Action::Package(PackageAction::Install {
7384 manager: "brew".into(),
7385 packages: vec!["a".into()],
7386 origin: "local".into(),
7387 }),
7388 Action::Package(PackageAction::Install {
7389 manager: "brew".into(),
7390 packages: vec!["b".into()],
7391 origin: "local".into(),
7392 }),
7393 ],
7394 },
7395 Phase {
7396 name: PhaseName::Files,
7397 actions: vec![Action::File(FileAction::Skip {
7398 target: PathBuf::from("/x"),
7399 reason: "n/a".into(),
7400 origin: "local".into(),
7401 })],
7402 },
7403 ],
7404 warnings: vec![],
7405 };
7406 assert_eq!(plan.total_actions(), 3);
7407 assert!(!plan.is_empty());
7408 }
7409
7410 #[test]
7411 fn plan_secrets_sops_file_target() {
7412 use crate::providers::SecretBackend;
7413
7414 struct MockSopsBackend;
7415 impl SecretBackend for MockSopsBackend {
7416 fn name(&self) -> &str {
7417 "sops"
7418 }
7419 fn is_available(&self) -> bool {
7420 true
7421 }
7422 fn encrypt_file(&self, _path: &std::path::Path) -> Result<()> {
7423 Ok(())
7424 }
7425 fn decrypt_file(&self, _path: &std::path::Path) -> Result<secrecy::SecretString> {
7426 Ok(secrecy::SecretString::from("decrypted"))
7427 }
7428 fn edit_file(&self, _path: &std::path::Path) -> Result<()> {
7429 Ok(())
7430 }
7431 }
7432
7433 let state = test_state();
7434 let mut registry = ProviderRegistry::new();
7435 registry.secret_backend = Some(Box::new(MockSopsBackend));
7436 let reconciler = Reconciler::new(®istry, &state);
7437
7438 let mut profile = MergedProfile::default();
7439 profile.secrets.push(crate::config::SecretSpec {
7440 source: "secrets/token.enc".to_string(),
7441 target: Some(PathBuf::from("/home/user/.token")),
7442 template: None,
7443 backend: None,
7444 envs: None,
7445 });
7446
7447 let actions = reconciler.plan_secrets(&profile);
7448 assert_eq!(actions.len(), 1);
7449 match &actions[0] {
7450 Action::Secret(SecretAction::Decrypt {
7451 backend, target, ..
7452 }) => {
7453 assert_eq!(backend, "sops");
7454 assert_eq!(*target, PathBuf::from("/home/user/.token"));
7455 }
7456 other => panic!("Expected Decrypt, got {:?}", other),
7457 }
7458 }
7459
7460 #[test]
7461 fn plan_secrets_no_backend_skips() {
7462 let state = test_state();
7463 let registry = ProviderRegistry::new(); let reconciler = Reconciler::new(®istry, &state);
7465
7466 let mut profile = MergedProfile::default();
7467 profile.secrets.push(crate::config::SecretSpec {
7468 source: "secrets/token.enc".to_string(),
7469 target: Some(PathBuf::from("/home/user/.token")),
7470 template: None,
7471 backend: None,
7472 envs: None,
7473 });
7474
7475 let actions = reconciler.plan_secrets(&profile);
7476 assert_eq!(actions.len(), 1);
7477 match &actions[0] {
7478 Action::Secret(SecretAction::Skip { reason, .. }) => {
7479 assert!(
7480 reason.contains("no secret backend"),
7481 "expected no-backend skip, got: {reason}"
7482 );
7483 }
7484 other => panic!("Expected Skip, got {:?}", other),
7485 }
7486 }
7487
7488 #[test]
7489 fn plan_secrets_envs_only_without_provider_skips() {
7490 let state = test_state();
7491 let registry = ProviderRegistry::new(); let reconciler = Reconciler::new(®istry, &state);
7493
7494 let mut profile = MergedProfile::default();
7495 profile.secrets.push(crate::config::SecretSpec {
7496 source: "plain-source".to_string(),
7497 target: None,
7498 template: None,
7499 backend: None,
7500 envs: Some(vec!["MY_SECRET".to_string()]),
7501 });
7502
7503 let actions = reconciler.plan_secrets(&profile);
7504 assert_eq!(actions.len(), 1);
7505 match &actions[0] {
7506 Action::Secret(SecretAction::Skip { reason, .. }) => {
7507 assert!(
7508 reason.contains("secret provider reference"),
7509 "expected env-needs-provider skip, got: {reason}"
7510 );
7511 }
7512 other => panic!("Expected Skip, got {:?}", other),
7513 }
7514 }
7515
7516 #[test]
7517 fn plan_secrets_provider_not_available_skips() {
7518 let state = test_state();
7519 let registry = ProviderRegistry::new(); let reconciler = Reconciler::new(®istry, &state);
7521
7522 let mut profile = MergedProfile::default();
7523 profile.secrets.push(crate::config::SecretSpec {
7524 source: "vault://secret/data/test#key".to_string(),
7525 target: Some(PathBuf::from("/tmp/test")),
7526 template: None,
7527 backend: None,
7528 envs: None,
7529 });
7530
7531 let actions = reconciler.plan_secrets(&profile);
7532 assert_eq!(actions.len(), 1);
7533 match &actions[0] {
7534 Action::Secret(SecretAction::Skip { reason, .. }) => {
7535 assert!(
7536 reason.contains("not available"),
7537 "expected provider-unavailable skip, got: {reason}"
7538 );
7539 }
7540 other => panic!("Expected Skip, got {:?}", other),
7541 }
7542 }
7543
7544 #[test]
7545 fn plan_secrets_sops_with_envs_generates_skip_for_envs() {
7546 use crate::providers::SecretBackend;
7547
7548 struct MockSopsBackend;
7549 impl SecretBackend for MockSopsBackend {
7550 fn name(&self) -> &str {
7551 "sops"
7552 }
7553 fn is_available(&self) -> bool {
7554 true
7555 }
7556 fn encrypt_file(&self, _path: &std::path::Path) -> Result<()> {
7557 Ok(())
7558 }
7559 fn decrypt_file(&self, _path: &std::path::Path) -> Result<secrecy::SecretString> {
7560 Ok(secrecy::SecretString::from("decrypted"))
7561 }
7562 fn edit_file(&self, _path: &std::path::Path) -> Result<()> {
7563 Ok(())
7564 }
7565 }
7566
7567 let state = test_state();
7568 let mut registry = ProviderRegistry::new();
7569 registry.secret_backend = Some(Box::new(MockSopsBackend));
7570 let reconciler = Reconciler::new(®istry, &state);
7571
7572 let mut profile = MergedProfile::default();
7573 profile.secrets.push(crate::config::SecretSpec {
7574 source: "secrets/token.enc".to_string(),
7575 target: Some(PathBuf::from("/home/user/.token")),
7576 template: None,
7577 backend: None,
7578 envs: Some(vec!["TOKEN".to_string()]),
7579 });
7580
7581 let actions = reconciler.plan_secrets(&profile);
7582 assert_eq!(actions.len(), 2);
7584 assert!(matches!(
7585 &actions[0],
7586 Action::Secret(SecretAction::Decrypt { .. })
7587 ));
7588 match &actions[1] {
7589 Action::Secret(SecretAction::Skip { reason, .. }) => {
7590 assert!(
7591 reason.contains("SOPS file targets cannot inject env vars"),
7592 "got: {reason}"
7593 );
7594 }
7595 other => panic!("Expected Skip for SOPS env injection, got {:?}", other),
7596 }
7597 }
7598
7599 #[test]
7600 fn plan_secrets_provider_no_target_no_envs_skips() {
7601 let state = test_state();
7602 let mut registry = ProviderRegistry::new();
7603 registry.secret_providers.push(Box::new(MockSecretProvider {
7604 provider_name: "vault".into(),
7605 value: "secret".into(),
7606 }));
7607 let reconciler = Reconciler::new(®istry, &state);
7608
7609 let mut profile = MergedProfile::default();
7610 profile.secrets.push(crate::config::SecretSpec {
7611 source: "vault://secret/data/test#key".to_string(),
7612 target: None,
7613 template: None,
7614 backend: None,
7615 envs: None,
7616 });
7617
7618 let actions = reconciler.plan_secrets(&profile);
7619 assert_eq!(actions.len(), 1);
7620 match &actions[0] {
7621 Action::Secret(SecretAction::Skip { reason, .. }) => {
7622 assert!(reason.contains("no target or envs"), "got: {reason}");
7623 }
7624 other => panic!("Expected Skip for no-target/no-envs, got {:?}", other),
7625 }
7626 }
7627
7628 #[test]
7629 fn plan_modules_reconcile_context_uses_pre_post_reconcile() {
7630 let state = test_state();
7631 let registry = ProviderRegistry::new();
7632 let reconciler = Reconciler::new(®istry, &state);
7633
7634 let modules = vec![ResolvedModule {
7635 name: "test".to_string(),
7636 packages: vec![],
7637 files: vec![],
7638 env: vec![],
7639 aliases: vec![],
7640 pre_apply_scripts: vec![ScriptEntry::Simple("pre-apply.sh".into())],
7641 post_apply_scripts: vec![ScriptEntry::Simple("post-apply.sh".into())],
7642 pre_reconcile_scripts: vec![ScriptEntry::Simple("pre-reconcile.sh".into())],
7643 post_reconcile_scripts: vec![ScriptEntry::Simple("post-reconcile.sh".into())],
7644 on_change_scripts: vec![],
7645 system: HashMap::new(),
7646 depends: vec![],
7647 dir: PathBuf::from("."),
7648 }];
7649
7650 let actions = reconciler.plan_modules(&modules, ReconcileContext::Reconcile);
7652 assert_eq!(actions.len(), 2); match &actions[0] {
7656 Action::Module(ma) => match &ma.kind {
7657 ModuleActionKind::RunScript { script, phase } => {
7658 assert_eq!(script.run_str(), "pre-reconcile.sh");
7659 assert_eq!(*phase, ScriptPhase::PreReconcile);
7660 }
7661 _ => panic!("expected RunScript"),
7662 },
7663 _ => panic!("expected Module action"),
7664 }
7665
7666 match &actions[1] {
7668 Action::Module(ma) => match &ma.kind {
7669 ModuleActionKind::RunScript { script, phase } => {
7670 assert_eq!(script.run_str(), "post-reconcile.sh");
7671 assert_eq!(*phase, ScriptPhase::PostReconcile);
7672 }
7673 _ => panic!("expected RunScript"),
7674 },
7675 _ => panic!("expected Module action"),
7676 }
7677 }
7678
7679 #[test]
7680 fn format_plan_items_all_action_types() {
7681 let phase = Phase {
7682 name: PhaseName::System,
7683 actions: vec![
7684 Action::System(SystemAction::SetValue {
7685 configurator: "sysctl".into(),
7686 key: "net.ipv4.ip_forward".into(),
7687 desired: "1".into(),
7688 current: "0".into(),
7689 origin: "local".into(),
7690 }),
7691 Action::System(SystemAction::Skip {
7692 configurator: "custom".into(),
7693 reason: "no configurator".into(),
7694 origin: "local".into(),
7695 }),
7696 ],
7697 };
7698 let items = format_plan_items(&phase);
7699 assert_eq!(items.len(), 2);
7700 assert!(items[0].contains("set sysctl.net.ipv4.ip_forward"));
7701 assert!(items[0].contains("0 \u{2192} 1"));
7702 assert!(items[1].contains("skip custom: no configurator"));
7703 }
7704
7705 #[test]
7706 fn format_plan_items_secret_actions() {
7707 let phase = Phase {
7708 name: PhaseName::Secrets,
7709 actions: vec![
7710 Action::Secret(SecretAction::Decrypt {
7711 source: PathBuf::from("secret.enc"),
7712 target: PathBuf::from("/out/secret"),
7713 backend: "sops".into(),
7714 origin: "corp".into(),
7715 }),
7716 Action::Secret(SecretAction::Resolve {
7717 provider: "vault".into(),
7718 reference: "secret/gh#token".into(),
7719 target: PathBuf::from("/tmp/token"),
7720 origin: "local".into(),
7721 }),
7722 Action::Secret(SecretAction::ResolveEnv {
7723 provider: "1password".into(),
7724 reference: "Vault/Secret".into(),
7725 envs: vec!["TOKEN".into()],
7726 origin: "local".into(),
7727 }),
7728 Action::Secret(SecretAction::Skip {
7729 source: "missing".into(),
7730 reason: "not available".into(),
7731 origin: "local".into(),
7732 }),
7733 ],
7734 };
7735 let items = format_plan_items(&phase);
7736 assert_eq!(items.len(), 4);
7737 assert!(items[0].contains("decrypt"));
7738 assert!(items[0].contains("<- corp"));
7739 assert!(items[1].contains("resolve vault://"));
7740 assert!(items[2].contains("resolve 1password://"));
7741 assert!(items[2].contains("env [TOKEN]"));
7742 assert!(items[3].contains("skip missing"));
7743 }
7744
7745 #[test]
7746 fn format_plan_items_env_actions() {
7747 let phase = Phase {
7748 name: PhaseName::Env,
7749 actions: vec![
7750 Action::Env(EnvAction::WriteEnvFile {
7751 path: PathBuf::from("/home/user/.cfgd.env"),
7752 content: "content".into(),
7753 }),
7754 Action::Env(EnvAction::InjectSourceLine {
7755 rc_path: PathBuf::from("/home/user/.bashrc"),
7756 line: "source ~/.cfgd.env".into(),
7757 }),
7758 ],
7759 };
7760 let items = format_plan_items(&phase);
7761 assert_eq!(items.len(), 2);
7762 assert!(items[0].contains("write"));
7763 assert!(items[0].contains(".cfgd.env"));
7764 assert!(items[1].contains("inject source line"));
7765 assert!(items[1].contains(".bashrc"));
7766 }
7767
7768 #[test]
7769 fn format_plan_items_script_action_with_provenance() {
7770 let phase = Phase {
7771 name: PhaseName::PreScripts,
7772 actions: vec![Action::Script(ScriptAction::Run {
7773 entry: ScriptEntry::Simple("setup.sh".into()),
7774 phase: ScriptPhase::PreApply,
7775 origin: "corp-source".into(),
7776 })],
7777 };
7778 let items = format_plan_items(&phase);
7779 assert_eq!(items.len(), 1);
7780 assert!(items[0].contains("run preApply script: setup.sh"));
7781 assert!(items[0].contains("<- corp-source"));
7782 }
7783
7784 #[test]
7785 fn format_module_action_item_deploy_truncates_many_files() {
7786 let files: Vec<crate::modules::ResolvedFile> = (0..5)
7787 .map(|i| crate::modules::ResolvedFile {
7788 source: PathBuf::from(format!("/src/{i}")),
7789 target: PathBuf::from(format!("/dst/{i}")),
7790 is_git_source: false,
7791 strategy: None,
7792 encryption: None,
7793 })
7794 .collect();
7795 let action = ModuleAction {
7796 module_name: "big".into(),
7797 kind: ModuleActionKind::DeployFiles { files },
7798 };
7799 let item = super::format_module_action_item(&action);
7800 assert!(item.contains("[big]"));
7801 assert!(item.contains("5 files"));
7802 }
7803
7804 #[test]
7805 fn detect_file_conflicts_skip_and_delete_actions_ignored() {
7806 let dir = tempfile::tempdir().unwrap();
7807 let file_a = dir.path().join("a.txt");
7808 let file_b = dir.path().join("b.txt");
7809 std::fs::write(&file_a, "content A").unwrap();
7810 std::fs::write(&file_b, "content B").unwrap();
7811
7812 let shared_target = PathBuf::from("/target/a");
7813
7814 let file_actions = vec![
7815 FileAction::Skip {
7816 target: shared_target.clone(),
7817 reason: "unchanged".into(),
7818 origin: "local".into(),
7819 },
7820 FileAction::Delete {
7821 target: PathBuf::from("/target/b"),
7822 origin: "local".into(),
7823 },
7824 ];
7825
7826 let modules = vec![ResolvedModule {
7829 name: "mymod".to_string(),
7830 packages: vec![],
7831 files: vec![crate::modules::ResolvedFile {
7832 source: file_a.clone(),
7833 target: shared_target.clone(),
7834 is_git_source: false,
7835 strategy: None,
7836 encryption: None,
7837 }],
7838 env: vec![],
7839 aliases: vec![],
7840 post_apply_scripts: vec![],
7841 pre_apply_scripts: Vec::new(),
7842 pre_reconcile_scripts: Vec::new(),
7843 post_reconcile_scripts: Vec::new(),
7844 on_change_scripts: Vec::new(),
7845 system: HashMap::new(),
7846 depends: vec![],
7847 dir: PathBuf::from("."),
7848 }];
7849
7850 let result = Reconciler::detect_file_conflicts(&file_actions, &modules);
7851 assert!(
7852 result.is_ok(),
7853 "Skip/Delete actions should be excluded from conflict detection: {:?}",
7854 result.err()
7855 );
7856
7857 let create_actions = vec![FileAction::Create {
7859 source: file_b,
7860 target: shared_target,
7861 origin: "local".to_string(),
7862 strategy: crate::config::FileStrategy::Copy,
7863 source_hash: None,
7864 }];
7865 assert!(
7866 Reconciler::detect_file_conflicts(&create_actions, &modules).is_err(),
7867 "Create with different content at same target should conflict (proves Skip exclusion is meaningful)"
7868 );
7869 }
7870
7871 #[test]
7872 fn content_hash_if_exists_returns_none_for_missing() {
7873 let hash = super::content_hash_if_exists(Path::new("/nonexistent/file"));
7874 assert!(hash.is_none());
7875 }
7876
7877 #[test]
7878 fn content_hash_if_exists_returns_hash_for_existing() {
7879 let dir = tempfile::tempdir().unwrap();
7880 let file = dir.path().join("test.txt");
7881 std::fs::write(&file, "hello").unwrap();
7882 let hash = super::content_hash_if_exists(&file);
7883 assert!(hash.is_some());
7884 let hash2 = super::content_hash_if_exists(&file);
7886 assert_eq!(hash, hash2);
7887 }
7888
7889 #[test]
7890 fn merge_module_env_aliases_merges_correctly() {
7891 let profile_env = vec![crate::config::EnvVar {
7892 name: "A".into(),
7893 value: "1".into(),
7894 }];
7895 let profile_aliases = vec![crate::config::ShellAlias {
7896 name: "g".into(),
7897 command: "git".into(),
7898 }];
7899 let modules = vec![ResolvedModule {
7900 name: "mod1".into(),
7901 packages: vec![],
7902 files: vec![],
7903 env: vec![
7904 crate::config::EnvVar {
7905 name: "A".into(),
7906 value: "2".into(),
7907 },
7908 crate::config::EnvVar {
7909 name: "B".into(),
7910 value: "3".into(),
7911 },
7912 ],
7913 aliases: vec![crate::config::ShellAlias {
7914 name: "g".into(),
7915 command: "git status".into(),
7916 }],
7917 post_apply_scripts: vec![],
7918 pre_apply_scripts: vec![],
7919 pre_reconcile_scripts: vec![],
7920 post_reconcile_scripts: vec![],
7921 on_change_scripts: vec![],
7922 system: HashMap::new(),
7923 depends: vec![],
7924 dir: PathBuf::from("."),
7925 }];
7926
7927 let (env, aliases) =
7928 super::merge_module_env_aliases(&profile_env, &profile_aliases, &modules);
7929 assert_eq!(env.len(), 2);
7931 assert_eq!(env.iter().find(|e| e.name == "A").unwrap().value, "2");
7932 assert_eq!(env.iter().find(|e| e.name == "B").unwrap().value, "3");
7933 assert_eq!(aliases.len(), 1);
7935 assert_eq!(aliases[0].command, "git status");
7936 }
7937
7938 #[test]
7939 fn generate_powershell_env_escapes_single_quotes() {
7940 let env = vec![crate::config::EnvVar {
7941 name: "MSG".into(),
7942 value: "it's a test".into(),
7943 }];
7944 let content = super::generate_powershell_env_content(&env, &[]);
7945 assert!(content.contains("$env:MSG = 'it''s a test'"));
7947 }
7948
7949 #[test]
7950 fn generate_fish_env_escapes_single_quotes() {
7951 let env = vec![crate::config::EnvVar {
7952 name: "MSG".into(),
7953 value: "it's a test".into(),
7954 }];
7955 let content = super::generate_fish_env_content(&env, &[]);
7956 assert!(content.contains("set -gx MSG 'it\\'s a test'"));
7957 }
7958
7959 #[test]
7960 fn reconcile_context_equality() {
7961 assert_eq!(ReconcileContext::Apply, ReconcileContext::Apply);
7962 assert_eq!(ReconcileContext::Reconcile, ReconcileContext::Reconcile);
7963 assert_ne!(ReconcileContext::Apply, ReconcileContext::Reconcile);
7964 }
7965
7966 #[test]
7967 #[cfg(unix)]
7968 fn apply_on_change_skipped_when_skip_scripts_true() {
7969 let dir = tempfile::tempdir().unwrap();
7970 let source = dir.path().join("source.txt");
7971 let target = dir.path().join("target.txt");
7972 let marker = dir.path().join("on_change_marker_skip");
7973
7974 std::fs::write(&source, "data").unwrap();
7975
7976 let state = test_state();
7977 let mut registry = ProviderRegistry::new();
7978 registry.default_file_strategy = crate::config::FileStrategy::Copy;
7979
7980 let reconciler = Reconciler::new(®istry, &state);
7981 let mut resolved = make_empty_resolved();
7982 resolved.merged.scripts.on_change =
7983 vec![ScriptEntry::Simple(format!("touch {}", marker.display()))];
7984
7985 let file_actions = vec![FileAction::Create {
7986 source: source.clone(),
7987 target: target.clone(),
7988 origin: "local".to_string(),
7989 strategy: crate::config::FileStrategy::Copy,
7990 source_hash: None,
7991 }];
7992
7993 let plan = reconciler
7994 .plan(
7995 &resolved,
7996 file_actions,
7997 Vec::new(),
7998 Vec::new(),
7999 ReconcileContext::Apply,
8000 )
8001 .unwrap();
8002
8003 let printer = test_printer();
8004 let result = reconciler
8006 .apply(
8007 &plan,
8008 &resolved,
8009 dir.path(),
8010 &printer,
8011 None,
8012 &[],
8013 ReconcileContext::Apply,
8014 true, )
8016 .unwrap();
8017
8018 assert_eq!(result.status, ApplyStatus::Success);
8019 assert!(
8021 !marker.exists(),
8022 "onChange should be skipped when skip_scripts=true"
8023 );
8024 assert!(target.exists());
8026 }
8027
8028 struct BootstrappablePackageManager {
8032 name: String,
8033 bootstrapped: std::sync::Mutex<bool>,
8034 installed: std::sync::Mutex<HashSet<String>>,
8035 }
8036
8037 impl BootstrappablePackageManager {
8038 fn new(name: &str) -> Self {
8039 Self {
8040 name: name.to_string(),
8041 bootstrapped: std::sync::Mutex::new(false),
8042 installed: std::sync::Mutex::new(HashSet::new()),
8043 }
8044 }
8045 }
8046
8047 impl PackageManager for BootstrappablePackageManager {
8048 fn name(&self) -> &str {
8049 &self.name
8050 }
8051 fn is_available(&self) -> bool {
8052 *self.bootstrapped.lock().unwrap()
8053 }
8054 fn can_bootstrap(&self) -> bool {
8055 true
8056 }
8057 fn bootstrap(&self, _printer: &Printer) -> Result<()> {
8058 *self.bootstrapped.lock().unwrap() = true;
8059 Ok(())
8060 }
8061 fn installed_packages(&self) -> Result<HashSet<String>> {
8062 Ok(self.installed.lock().unwrap().clone())
8063 }
8064 fn install(&self, packages: &[String], _printer: &Printer) -> Result<()> {
8065 let mut installed = self.installed.lock().unwrap();
8066 for p in packages {
8067 installed.insert(p.clone());
8068 }
8069 Ok(())
8070 }
8071 fn uninstall(&self, packages: &[String], _printer: &Printer) -> Result<()> {
8072 let mut installed = self.installed.lock().unwrap();
8073 for p in packages {
8074 installed.remove(p);
8075 }
8076 Ok(())
8077 }
8078 fn update(&self, _printer: &Printer) -> Result<()> {
8079 Ok(())
8080 }
8081 fn available_version(&self, _package: &str) -> Result<Option<String>> {
8082 Ok(None)
8083 }
8084 }
8085
8086 #[test]
8087 fn apply_package_bootstrap_makes_manager_available() {
8088 let state = test_state();
8089 let mut registry = ProviderRegistry::new();
8090 registry
8091 .package_managers
8092 .push(Box::new(BootstrappablePackageManager::new("snap")));
8093
8094 let reconciler = Reconciler::new(®istry, &state);
8095 let resolved = make_empty_resolved();
8096
8097 let plan = Plan {
8098 phases: vec![Phase {
8099 name: PhaseName::Packages,
8100 actions: vec![Action::Package(PackageAction::Bootstrap {
8101 manager: "snap".to_string(),
8102 method: "auto".to_string(),
8103 origin: "local".to_string(),
8104 })],
8105 }],
8106 warnings: vec![],
8107 };
8108
8109 let printer = test_printer();
8110 let result = reconciler
8111 .apply(
8112 &plan,
8113 &resolved,
8114 Path::new("."),
8115 &printer,
8116 Some(&PhaseName::Packages),
8117 &[],
8118 ReconcileContext::Apply,
8119 false,
8120 )
8121 .unwrap();
8122
8123 assert_eq!(result.status, ApplyStatus::Success);
8124 assert_eq!(result.action_results.len(), 1);
8125 assert!(result.action_results[0].success);
8126 assert!(
8127 result.action_results[0].description.contains("bootstrap"),
8128 "desc: {}",
8129 result.action_results[0].description
8130 );
8131
8132 assert!(registry.package_managers[0].is_available());
8134 }
8135
8136 #[test]
8137 fn apply_package_bootstrap_unknown_manager_errors() {
8138 let state = test_state();
8139 let registry = ProviderRegistry::new(); let reconciler = Reconciler::new(®istry, &state);
8141 let resolved = make_empty_resolved();
8142
8143 let plan = Plan {
8144 phases: vec![Phase {
8145 name: PhaseName::Packages,
8146 actions: vec![Action::Package(PackageAction::Bootstrap {
8147 manager: "nonexistent".to_string(),
8148 method: "auto".to_string(),
8149 origin: "local".to_string(),
8150 })],
8151 }],
8152 warnings: vec![],
8153 };
8154
8155 let printer = test_printer();
8156 let result = reconciler
8157 .apply(
8158 &plan,
8159 &resolved,
8160 Path::new("."),
8161 &printer,
8162 Some(&PhaseName::Packages),
8163 &[],
8164 ReconcileContext::Apply,
8165 false,
8166 )
8167 .unwrap();
8168
8169 assert_eq!(result.status, ApplyStatus::Failed);
8171 assert_eq!(result.failed(), 1);
8172 assert!(result.action_results[0].error.is_some());
8173 }
8174
8175 #[test]
8176 fn apply_package_install_unknown_manager_errors() {
8177 let state = test_state();
8178 let registry = ProviderRegistry::new(); let reconciler = Reconciler::new(®istry, &state);
8180 let resolved = make_empty_resolved();
8181
8182 let plan = Plan {
8183 phases: vec![Phase {
8184 name: PhaseName::Packages,
8185 actions: vec![Action::Package(PackageAction::Install {
8186 manager: "nonexistent".to_string(),
8187 packages: vec!["foo".to_string()],
8188 origin: "local".to_string(),
8189 })],
8190 }],
8191 warnings: vec![],
8192 };
8193
8194 let printer = test_printer();
8195 let result = reconciler
8196 .apply(
8197 &plan,
8198 &resolved,
8199 Path::new("."),
8200 &printer,
8201 Some(&PhaseName::Packages),
8202 &[],
8203 ReconcileContext::Apply,
8204 false,
8205 )
8206 .unwrap();
8207
8208 assert_eq!(result.status, ApplyStatus::Failed);
8209 assert_eq!(result.failed(), 1);
8210 }
8211
8212 #[test]
8213 fn apply_package_uninstall_unknown_manager_errors() {
8214 let state = test_state();
8215 let registry = ProviderRegistry::new();
8216 let reconciler = Reconciler::new(®istry, &state);
8217 let resolved = make_empty_resolved();
8218
8219 let plan = Plan {
8220 phases: vec![Phase {
8221 name: PhaseName::Packages,
8222 actions: vec![Action::Package(PackageAction::Uninstall {
8223 manager: "nonexistent".to_string(),
8224 packages: vec!["foo".to_string()],
8225 origin: "local".to_string(),
8226 })],
8227 }],
8228 warnings: vec![],
8229 };
8230
8231 let printer = test_printer();
8232 let result = reconciler
8233 .apply(
8234 &plan,
8235 &resolved,
8236 Path::new("."),
8237 &printer,
8238 Some(&PhaseName::Packages),
8239 &[],
8240 ReconcileContext::Apply,
8241 false,
8242 )
8243 .unwrap();
8244
8245 assert_eq!(result.status, ApplyStatus::Failed);
8246 assert_eq!(result.failed(), 1);
8247 }
8248
8249 struct TestSecretBackend {
8252 decrypted_value: String,
8253 }
8254
8255 impl crate::providers::SecretBackend for TestSecretBackend {
8256 fn name(&self) -> &str {
8257 "test-sops"
8258 }
8259 fn is_available(&self) -> bool {
8260 true
8261 }
8262 fn encrypt_file(&self, _path: &std::path::Path) -> Result<()> {
8263 Ok(())
8264 }
8265 fn decrypt_file(&self, _path: &std::path::Path) -> Result<secrecy::SecretString> {
8266 Ok(secrecy::SecretString::from(self.decrypted_value.clone()))
8267 }
8268 fn edit_file(&self, _path: &std::path::Path) -> Result<()> {
8269 Ok(())
8270 }
8271 }
8272
8273 #[test]
8274 fn apply_secret_decrypt_writes_decrypted_file() {
8275 let dir = tempfile::tempdir().unwrap();
8276 let source = dir.path().join("token.enc");
8277 let target = dir.path().join("token.txt");
8278 std::fs::write(&source, "encrypted-data").unwrap();
8279
8280 let state = test_state();
8281 let mut registry = ProviderRegistry::new();
8282 registry.secret_backend = Some(Box::new(TestSecretBackend {
8283 decrypted_value: "my-secret-token".to_string(),
8284 }));
8285
8286 let reconciler = Reconciler::new(®istry, &state);
8287 let resolved = make_empty_resolved();
8288
8289 let plan = Plan {
8290 phases: vec![Phase {
8291 name: PhaseName::Secrets,
8292 actions: vec![Action::Secret(SecretAction::Decrypt {
8293 source: source.clone(),
8294 target: target.clone(),
8295 backend: "test-sops".to_string(),
8296 origin: "local".to_string(),
8297 })],
8298 }],
8299 warnings: vec![],
8300 };
8301
8302 let printer = test_printer();
8303 let result = reconciler
8304 .apply(
8305 &plan,
8306 &resolved,
8307 dir.path(),
8308 &printer,
8309 Some(&PhaseName::Secrets),
8310 &[],
8311 ReconcileContext::Apply,
8312 false,
8313 )
8314 .unwrap();
8315
8316 assert_eq!(result.status, ApplyStatus::Success);
8317 assert_eq!(result.action_results.len(), 1);
8318 assert!(result.action_results[0].success);
8319 assert!(
8320 result.action_results[0].description.contains("decrypt"),
8321 "desc: {}",
8322 result.action_results[0].description
8323 );
8324
8325 let content = std::fs::read_to_string(&target).unwrap();
8327 assert_eq!(content, "my-secret-token");
8328 }
8329
8330 #[test]
8331 fn apply_secret_decrypt_no_backend_errors() {
8332 let dir = tempfile::tempdir().unwrap();
8333 let source = dir.path().join("token.enc");
8334 let target = dir.path().join("token.txt");
8335 std::fs::write(&source, "encrypted-data").unwrap();
8336
8337 let state = test_state();
8338 let registry = ProviderRegistry::new(); let reconciler = Reconciler::new(®istry, &state);
8341 let resolved = make_empty_resolved();
8342
8343 let plan = Plan {
8344 phases: vec![Phase {
8345 name: PhaseName::Secrets,
8346 actions: vec![Action::Secret(SecretAction::Decrypt {
8347 source: source.clone(),
8348 target: target.clone(),
8349 backend: "sops".to_string(),
8350 origin: "local".to_string(),
8351 })],
8352 }],
8353 warnings: vec![],
8354 };
8355
8356 let printer = test_printer();
8357 let result = reconciler
8358 .apply(
8359 &plan,
8360 &resolved,
8361 dir.path(),
8362 &printer,
8363 Some(&PhaseName::Secrets),
8364 &[],
8365 ReconcileContext::Apply,
8366 false,
8367 )
8368 .unwrap();
8369
8370 assert_eq!(result.status, ApplyStatus::Failed);
8371 assert_eq!(result.failed(), 1);
8372 }
8373
8374 #[test]
8375 fn apply_secret_resolve_writes_provider_value_to_file() {
8376 let dir = tempfile::tempdir().unwrap();
8377 let target = dir.path().join("resolved-secret.txt");
8378
8379 let state = test_state();
8380 let mut registry = ProviderRegistry::new();
8381 registry.secret_providers.push(Box::new(MockSecretProvider {
8382 provider_name: "vault".to_string(),
8383 value: "provider-secret-value".to_string(),
8384 }));
8385
8386 let reconciler = Reconciler::new(®istry, &state);
8387 let resolved = make_empty_resolved();
8388
8389 let plan = Plan {
8390 phases: vec![Phase {
8391 name: PhaseName::Secrets,
8392 actions: vec![Action::Secret(SecretAction::Resolve {
8393 provider: "vault".to_string(),
8394 reference: "secret/data/app#key".to_string(),
8395 target: target.clone(),
8396 origin: "local".to_string(),
8397 })],
8398 }],
8399 warnings: vec![],
8400 };
8401
8402 let printer = test_printer();
8403 let result = reconciler
8404 .apply(
8405 &plan,
8406 &resolved,
8407 dir.path(),
8408 &printer,
8409 Some(&PhaseName::Secrets),
8410 &[],
8411 ReconcileContext::Apply,
8412 false,
8413 )
8414 .unwrap();
8415
8416 assert_eq!(result.status, ApplyStatus::Success);
8417 assert!(result.action_results[0].success);
8418 assert!(
8419 result.action_results[0].description.contains("resolve"),
8420 "desc: {}",
8421 result.action_results[0].description
8422 );
8423
8424 let content = std::fs::read_to_string(&target).unwrap();
8425 assert_eq!(content, "provider-secret-value");
8426 }
8427
8428 #[test]
8429 fn apply_secret_resolve_unknown_provider_errors() {
8430 let dir = tempfile::tempdir().unwrap();
8431 let target = dir.path().join("nope.txt");
8432
8433 let state = test_state();
8434 let registry = ProviderRegistry::new(); let reconciler = Reconciler::new(®istry, &state);
8437 let resolved = make_empty_resolved();
8438
8439 let plan = Plan {
8440 phases: vec![Phase {
8441 name: PhaseName::Secrets,
8442 actions: vec![Action::Secret(SecretAction::Resolve {
8443 provider: "vault".to_string(),
8444 reference: "secret/data/app#key".to_string(),
8445 target: target.clone(),
8446 origin: "local".to_string(),
8447 })],
8448 }],
8449 warnings: vec![],
8450 };
8451
8452 let printer = test_printer();
8453 let result = reconciler
8454 .apply(
8455 &plan,
8456 &resolved,
8457 dir.path(),
8458 &printer,
8459 Some(&PhaseName::Secrets),
8460 &[],
8461 ReconcileContext::Apply,
8462 false,
8463 )
8464 .unwrap();
8465
8466 assert_eq!(result.status, ApplyStatus::Failed);
8467 assert_eq!(result.failed(), 1);
8468 }
8469
8470 #[test]
8471 fn apply_secret_resolve_env_collects_env_vars() {
8472 let state = test_state();
8473 let mut registry = ProviderRegistry::new();
8474 registry.secret_providers.push(Box::new(MockSecretProvider {
8475 provider_name: "vault".to_string(),
8476 value: "env-secret-value".to_string(),
8477 }));
8478
8479 let reconciler = Reconciler::new(®istry, &state);
8480 let resolved = make_empty_resolved();
8481
8482 let plan = Plan {
8483 phases: vec![Phase {
8484 name: PhaseName::Secrets,
8485 actions: vec![Action::Secret(SecretAction::ResolveEnv {
8486 provider: "vault".to_string(),
8487 reference: "secret/data/gh#token".to_string(),
8488 envs: vec!["GH_TOKEN".to_string(), "GITHUB_TOKEN".to_string()],
8489 origin: "local".to_string(),
8490 })],
8491 }],
8492 warnings: vec![],
8493 };
8494
8495 let printer = test_printer();
8496 let result = reconciler
8497 .apply(
8498 &plan,
8499 &resolved,
8500 Path::new("."),
8501 &printer,
8502 Some(&PhaseName::Secrets),
8503 &[],
8504 ReconcileContext::Apply,
8505 false,
8506 )
8507 .unwrap();
8508
8509 assert_eq!(result.status, ApplyStatus::Success);
8510 assert!(result.action_results[0].success);
8511 assert!(
8512 result.action_results[0].description.contains("resolve-env"),
8513 "desc: {}",
8514 result.action_results[0].description
8515 );
8516 }
8517
8518 #[test]
8519 fn apply_secret_resolve_env_unknown_provider_errors() {
8520 let state = test_state();
8521 let registry = ProviderRegistry::new(); let reconciler = Reconciler::new(®istry, &state);
8524 let resolved = make_empty_resolved();
8525
8526 let plan = Plan {
8527 phases: vec![Phase {
8528 name: PhaseName::Secrets,
8529 actions: vec![Action::Secret(SecretAction::ResolveEnv {
8530 provider: "vault".to_string(),
8531 reference: "secret/data/gh#token".to_string(),
8532 envs: vec!["GH_TOKEN".to_string()],
8533 origin: "local".to_string(),
8534 })],
8535 }],
8536 warnings: vec![],
8537 };
8538
8539 let printer = test_printer();
8540 let result = reconciler
8541 .apply(
8542 &plan,
8543 &resolved,
8544 Path::new("."),
8545 &printer,
8546 Some(&PhaseName::Secrets),
8547 &[],
8548 ReconcileContext::Apply,
8549 false,
8550 )
8551 .unwrap();
8552
8553 assert_eq!(result.status, ApplyStatus::Failed);
8554 assert_eq!(result.failed(), 1);
8555 }
8556
8557 #[test]
8558 fn apply_secret_skip_succeeds() {
8559 let state = test_state();
8560 let registry = ProviderRegistry::new();
8561 let reconciler = Reconciler::new(®istry, &state);
8562 let resolved = make_empty_resolved();
8563
8564 let plan = Plan {
8565 phases: vec![Phase {
8566 name: PhaseName::Secrets,
8567 actions: vec![Action::Secret(SecretAction::Skip {
8568 source: "vault://test".to_string(),
8569 reason: "not available".to_string(),
8570 origin: "local".to_string(),
8571 })],
8572 }],
8573 warnings: vec![],
8574 };
8575
8576 let printer = test_printer();
8577 let result = reconciler
8578 .apply(
8579 &plan,
8580 &resolved,
8581 Path::new("."),
8582 &printer,
8583 Some(&PhaseName::Secrets),
8584 &[],
8585 ReconcileContext::Apply,
8586 false,
8587 )
8588 .unwrap();
8589
8590 assert_eq!(result.status, ApplyStatus::Success);
8591 assert!(result.action_results[0].success);
8592 assert!(result.action_results[0].description.contains("skip"));
8593 }
8594
8595 #[test]
8598 fn apply_file_delete_action_removes_file() {
8599 let dir = tempfile::tempdir().unwrap();
8600 let target = dir.path().join("to-delete.txt");
8601 std::fs::write(&target, "delete me").unwrap();
8602 assert!(target.exists());
8603
8604 let state = test_state();
8605 let mut registry = ProviderRegistry::new();
8606 registry.default_file_strategy = crate::config::FileStrategy::Copy;
8607
8608 let reconciler = Reconciler::new(®istry, &state);
8609 let resolved = make_empty_resolved();
8610
8611 let plan = Plan {
8612 phases: vec![Phase {
8613 name: PhaseName::Files,
8614 actions: vec![Action::File(FileAction::Delete {
8615 target: target.clone(),
8616 origin: "local".to_string(),
8617 })],
8618 }],
8619 warnings: vec![],
8620 };
8621
8622 let printer = test_printer();
8623 let result = reconciler
8624 .apply(
8625 &plan,
8626 &resolved,
8627 dir.path(),
8628 &printer,
8629 Some(&PhaseName::Files),
8630 &[],
8631 ReconcileContext::Apply,
8632 false,
8633 )
8634 .unwrap();
8635
8636 assert_eq!(result.status, ApplyStatus::Success);
8637 assert!(!target.exists(), "file should be deleted");
8638 assert!(
8639 result.action_results[0].description.contains("delete"),
8640 "desc: {}",
8641 result.action_results[0].description
8642 );
8643 }
8644
8645 #[test]
8646 #[cfg(unix)]
8647 fn apply_file_set_permissions_action() {
8648 let dir = tempfile::tempdir().unwrap();
8649 let target = dir.path().join("script.sh");
8650 std::fs::write(&target, "#!/bin/sh\necho hi").unwrap();
8651
8652 let state = test_state();
8653 let registry = ProviderRegistry::new();
8654 let reconciler = Reconciler::new(®istry, &state);
8655 let resolved = make_empty_resolved();
8656
8657 let plan = Plan {
8658 phases: vec![Phase {
8659 name: PhaseName::Files,
8660 actions: vec![Action::File(FileAction::SetPermissions {
8661 target: target.clone(),
8662 mode: 0o755,
8663 origin: "local".to_string(),
8664 })],
8665 }],
8666 warnings: vec![],
8667 };
8668
8669 let printer = test_printer();
8670 let result = reconciler
8671 .apply(
8672 &plan,
8673 &resolved,
8674 dir.path(),
8675 &printer,
8676 Some(&PhaseName::Files),
8677 &[],
8678 ReconcileContext::Apply,
8679 false,
8680 )
8681 .unwrap();
8682
8683 assert_eq!(result.status, ApplyStatus::Success);
8684 assert!(result.action_results[0].success);
8685 assert!(
8686 result.action_results[0].description.contains("chmod"),
8687 "desc: {}",
8688 result.action_results[0].description
8689 );
8690
8691 use std::os::unix::fs::PermissionsExt;
8693 let meta = std::fs::metadata(&target).unwrap();
8694 assert_eq!(meta.permissions().mode() & 0o777, 0o755);
8695 }
8696
8697 #[test]
8698 fn apply_file_skip_action_succeeds() {
8699 let state = test_state();
8700 let registry = ProviderRegistry::new();
8701 let reconciler = Reconciler::new(®istry, &state);
8702 let resolved = make_empty_resolved();
8703
8704 let plan = Plan {
8705 phases: vec![Phase {
8706 name: PhaseName::Files,
8707 actions: vec![Action::File(FileAction::Skip {
8708 target: PathBuf::from("/nonexistent"),
8709 reason: "unchanged".to_string(),
8710 origin: "local".to_string(),
8711 })],
8712 }],
8713 warnings: vec![],
8714 };
8715
8716 let printer = test_printer();
8717 let result = reconciler
8718 .apply(
8719 &plan,
8720 &resolved,
8721 Path::new("."),
8722 &printer,
8723 Some(&PhaseName::Files),
8724 &[],
8725 ReconcileContext::Apply,
8726 false,
8727 )
8728 .unwrap();
8729
8730 assert_eq!(result.status, ApplyStatus::Success);
8731 assert!(result.action_results[0].success);
8732 assert!(result.action_results[0].description.contains("skip"));
8733 }
8734
8735 #[test]
8736 fn apply_file_update_action_overwrites_target() {
8737 let dir = tempfile::tempdir().unwrap();
8738 let source = dir.path().join("new-content.txt");
8739 let target = dir.path().join("existing.txt");
8740 std::fs::write(&source, "updated content").unwrap();
8741 std::fs::write(&target, "old content").unwrap();
8742
8743 let state = test_state();
8744 let mut registry = ProviderRegistry::new();
8745 registry.default_file_strategy = crate::config::FileStrategy::Copy;
8746
8747 let reconciler = Reconciler::new(®istry, &state);
8748 let resolved = make_empty_resolved();
8749
8750 let plan = Plan {
8751 phases: vec![Phase {
8752 name: PhaseName::Files,
8753 actions: vec![Action::File(FileAction::Update {
8754 source: source.clone(),
8755 target: target.clone(),
8756 diff: "diff output".to_string(),
8757 origin: "local".to_string(),
8758 strategy: crate::config::FileStrategy::Copy,
8759 source_hash: None,
8760 })],
8761 }],
8762 warnings: vec![],
8763 };
8764
8765 let printer = test_printer();
8766 let result = reconciler
8767 .apply(
8768 &plan,
8769 &resolved,
8770 dir.path(),
8771 &printer,
8772 Some(&PhaseName::Files),
8773 &[],
8774 ReconcileContext::Apply,
8775 false,
8776 )
8777 .unwrap();
8778
8779 assert_eq!(result.status, ApplyStatus::Success);
8780 let content = std::fs::read_to_string(&target).unwrap();
8781 assert_eq!(content, "updated content");
8782 assert!(
8783 result.action_results[0].description.contains("update"),
8784 "desc: {}",
8785 result.action_results[0].description
8786 );
8787 }
8788
8789 struct TestSystemConfigurator {
8793 configurator_name: String,
8794 applied: std::sync::Mutex<bool>,
8795 }
8796
8797 impl TestSystemConfigurator {
8798 fn new(name: &str) -> Self {
8799 Self {
8800 configurator_name: name.to_string(),
8801 applied: std::sync::Mutex::new(false),
8802 }
8803 }
8804 }
8805
8806 impl crate::providers::SystemConfigurator for TestSystemConfigurator {
8807 fn name(&self) -> &str {
8808 &self.configurator_name
8809 }
8810 fn is_available(&self) -> bool {
8811 true
8812 }
8813 fn current_state(&self) -> Result<serde_yaml::Value> {
8814 Ok(serde_yaml::Value::Null)
8815 }
8816 fn diff(&self, _desired: &serde_yaml::Value) -> Result<Vec<crate::providers::SystemDrift>> {
8817 Ok(vec![crate::providers::SystemDrift {
8818 key: "test.key".to_string(),
8819 expected: "desired-val".to_string(),
8820 actual: "current-val".to_string(),
8821 }])
8822 }
8823 fn apply(&self, _desired: &serde_yaml::Value, _printer: &Printer) -> Result<()> {
8824 *self.applied.lock().unwrap() = true;
8825 Ok(())
8826 }
8827 }
8828
8829 #[test]
8830 fn apply_system_set_value_calls_configurator() {
8831 let state = test_state();
8832 let mut registry = ProviderRegistry::new();
8833 registry
8834 .system_configurators
8835 .push(Box::new(TestSystemConfigurator::new("sysctl")));
8836
8837 let reconciler = Reconciler::new(®istry, &state);
8838 let mut resolved = make_empty_resolved();
8839 resolved.merged.system.insert(
8841 "sysctl".to_string(),
8842 serde_yaml::from_str("{net.ipv4.ip_forward: 1}").unwrap(),
8843 );
8844
8845 let plan = Plan {
8846 phases: vec![Phase {
8847 name: PhaseName::System,
8848 actions: vec![Action::System(SystemAction::SetValue {
8849 configurator: "sysctl".to_string(),
8850 key: "net.ipv4.ip_forward".to_string(),
8851 desired: "1".to_string(),
8852 current: "0".to_string(),
8853 origin: "local".to_string(),
8854 })],
8855 }],
8856 warnings: vec![],
8857 };
8858
8859 let printer = test_printer();
8860 let result = reconciler
8861 .apply(
8862 &plan,
8863 &resolved,
8864 Path::new("."),
8865 &printer,
8866 Some(&PhaseName::System),
8867 &[],
8868 ReconcileContext::Apply,
8869 false,
8870 )
8871 .unwrap();
8872
8873 assert_eq!(result.status, ApplyStatus::Success);
8874 assert!(result.action_results[0].success);
8875 assert!(
8876 result.action_results[0]
8877 .description
8878 .contains("system:sysctl"),
8879 "desc: {}",
8880 result.action_results[0].description
8881 );
8882 }
8883
8884 #[test]
8885 fn apply_system_skip_logs_warning() {
8886 let state = test_state();
8887 let registry = ProviderRegistry::new();
8888 let reconciler = Reconciler::new(®istry, &state);
8889 let resolved = make_empty_resolved();
8890
8891 let plan = Plan {
8892 phases: vec![Phase {
8893 name: PhaseName::System,
8894 actions: vec![Action::System(SystemAction::Skip {
8895 configurator: "customThing".to_string(),
8896 reason: "no configurator registered".to_string(),
8897 origin: "local".to_string(),
8898 })],
8899 }],
8900 warnings: vec![],
8901 };
8902
8903 let printer = test_printer();
8904 let result = reconciler
8905 .apply(
8906 &plan,
8907 &resolved,
8908 Path::new("."),
8909 &printer,
8910 Some(&PhaseName::System),
8911 &[],
8912 ReconcileContext::Apply,
8913 false,
8914 )
8915 .unwrap();
8916
8917 assert_eq!(result.status, ApplyStatus::Success);
8918 assert!(result.action_results[0].success);
8919 assert!(
8920 result.action_results[0].description.contains("skipped"),
8921 "desc: {}",
8922 result.action_results[0].description
8923 );
8924 }
8925
8926 #[test]
8927 fn plan_system_generates_skip_for_unregistered_configurator() {
8928 let state = test_state();
8929 let registry = ProviderRegistry::new(); let reconciler = Reconciler::new(®istry, &state);
8931
8932 let mut profile = MergedProfile::default();
8933 profile.system.insert(
8934 "unknownConf".to_string(),
8935 serde_yaml::from_str("{key: value}").unwrap(),
8936 );
8937
8938 let actions = reconciler.plan_system(&profile, &[]).unwrap();
8939 assert_eq!(actions.len(), 1);
8940 match &actions[0] {
8941 Action::System(SystemAction::Skip {
8942 configurator,
8943 reason,
8944 ..
8945 }) => {
8946 assert_eq!(configurator, "unknownConf");
8947 assert!(reason.contains("no configurator registered"));
8948 }
8949 other => panic!("Expected SystemAction::Skip, got {:?}", other),
8950 }
8951 }
8952
8953 #[test]
8956 fn apply_module_install_packages_calls_manager() {
8957 let state = test_state();
8958 let mut registry = ProviderRegistry::new();
8959 registry
8960 .package_managers
8961 .push(Box::new(TrackingPackageManager::new("brew")));
8962
8963 let reconciler = Reconciler::new(®istry, &state);
8964 let resolved = make_empty_resolved();
8965
8966 let modules = vec![ResolvedModule {
8967 name: "nvim".to_string(),
8968 packages: vec![ResolvedPackage {
8969 canonical_name: "neovim".to_string(),
8970 resolved_name: "neovim".to_string(),
8971 manager: "brew".to_string(),
8972 version: None,
8973 script: None,
8974 }],
8975 files: vec![],
8976 env: vec![],
8977 aliases: vec![],
8978 post_apply_scripts: vec![],
8979 pre_apply_scripts: Vec::new(),
8980 pre_reconcile_scripts: Vec::new(),
8981 post_reconcile_scripts: Vec::new(),
8982 on_change_scripts: Vec::new(),
8983 system: HashMap::new(),
8984 depends: vec![],
8985 dir: PathBuf::from("."),
8986 }];
8987
8988 let plan = Plan {
8989 phases: vec![Phase {
8990 name: PhaseName::Modules,
8991 actions: vec![Action::Module(ModuleAction {
8992 module_name: "nvim".to_string(),
8993 kind: ModuleActionKind::InstallPackages {
8994 resolved: vec![ResolvedPackage {
8995 canonical_name: "neovim".to_string(),
8996 resolved_name: "neovim".to_string(),
8997 manager: "brew".to_string(),
8998 version: None,
8999 script: None,
9000 }],
9001 },
9002 })],
9003 }],
9004 warnings: vec![],
9005 };
9006
9007 let printer = test_printer();
9008 let result = reconciler
9009 .apply(
9010 &plan,
9011 &resolved,
9012 Path::new("."),
9013 &printer,
9014 Some(&PhaseName::Modules),
9015 &modules,
9016 ReconcileContext::Apply,
9017 false,
9018 )
9019 .unwrap();
9020
9021 assert_eq!(result.status, ApplyStatus::Success);
9022 assert!(result.action_results[0].success);
9023 assert!(
9024 result.action_results[0]
9025 .description
9026 .contains("module:nvim:packages"),
9027 "desc: {}",
9028 result.action_results[0].description
9029 );
9030
9031 let installed = registry.package_managers[0].installed_packages().unwrap();
9033 assert!(installed.contains("neovim"));
9034 }
9035
9036 #[test]
9037 fn apply_module_deploy_files_creates_target() {
9038 let dir = tempfile::tempdir().unwrap();
9039 let source_file = dir.path().join("module-source.txt");
9040 let target_file = dir.path().join("subdir/module-target.txt");
9041 std::fs::write(&source_file, "module content").unwrap();
9042
9043 let state = test_state();
9044 let mut registry = ProviderRegistry::new();
9045 registry.default_file_strategy = crate::config::FileStrategy::Copy;
9046
9047 let reconciler = Reconciler::new(®istry, &state);
9048 let resolved = make_empty_resolved();
9049
9050 let modules = vec![ResolvedModule {
9051 name: "mymod".to_string(),
9052 packages: vec![],
9053 files: vec![ResolvedFile {
9054 source: source_file.clone(),
9055 target: target_file.clone(),
9056 is_git_source: false,
9057 strategy: Some(crate::config::FileStrategy::Copy),
9058 encryption: None,
9059 }],
9060 env: vec![],
9061 aliases: vec![],
9062 post_apply_scripts: vec![],
9063 pre_apply_scripts: Vec::new(),
9064 pre_reconcile_scripts: Vec::new(),
9065 post_reconcile_scripts: Vec::new(),
9066 on_change_scripts: Vec::new(),
9067 system: HashMap::new(),
9068 depends: vec![],
9069 dir: dir.path().to_path_buf(),
9070 }];
9071
9072 let plan = Plan {
9073 phases: vec![Phase {
9074 name: PhaseName::Modules,
9075 actions: vec![Action::Module(ModuleAction {
9076 module_name: "mymod".to_string(),
9077 kind: ModuleActionKind::DeployFiles {
9078 files: vec![ResolvedFile {
9079 source: source_file.clone(),
9080 target: target_file.clone(),
9081 is_git_source: false,
9082 strategy: Some(crate::config::FileStrategy::Copy),
9083 encryption: None,
9084 }],
9085 },
9086 })],
9087 }],
9088 warnings: vec![],
9089 };
9090
9091 let printer = test_printer();
9092 let result = reconciler
9093 .apply(
9094 &plan,
9095 &resolved,
9096 dir.path(),
9097 &printer,
9098 Some(&PhaseName::Modules),
9099 &modules,
9100 ReconcileContext::Apply,
9101 false,
9102 )
9103 .unwrap();
9104
9105 assert_eq!(result.status, ApplyStatus::Success);
9106 assert!(result.action_results[0].success);
9107 assert!(target_file.exists(), "target file should be deployed");
9108 assert_eq!(
9109 std::fs::read_to_string(&target_file).unwrap(),
9110 "module content"
9111 );
9112 }
9113
9114 #[test]
9115 #[cfg(unix)]
9116 fn apply_module_deploy_files_symlink_strategy() {
9117 let dir = tempfile::tempdir().unwrap();
9118 let source_file = dir.path().join("source.txt");
9119 let target_file = dir.path().join("link-target.txt");
9120 std::fs::write(&source_file, "linked content").unwrap();
9121
9122 let state = test_state();
9123 let mut registry = ProviderRegistry::new();
9124 registry.default_file_strategy = crate::config::FileStrategy::Symlink;
9125
9126 let reconciler = Reconciler::new(®istry, &state);
9127 let resolved = make_empty_resolved();
9128
9129 let modules = vec![ResolvedModule {
9130 name: "linkmod".to_string(),
9131 packages: vec![],
9132 files: vec![ResolvedFile {
9133 source: source_file.clone(),
9134 target: target_file.clone(),
9135 is_git_source: false,
9136 strategy: None, encryption: None,
9138 }],
9139 env: vec![],
9140 aliases: vec![],
9141 post_apply_scripts: vec![],
9142 pre_apply_scripts: Vec::new(),
9143 pre_reconcile_scripts: Vec::new(),
9144 post_reconcile_scripts: Vec::new(),
9145 on_change_scripts: Vec::new(),
9146 system: HashMap::new(),
9147 depends: vec![],
9148 dir: dir.path().to_path_buf(),
9149 }];
9150
9151 let plan = Plan {
9152 phases: vec![Phase {
9153 name: PhaseName::Modules,
9154 actions: vec![Action::Module(ModuleAction {
9155 module_name: "linkmod".to_string(),
9156 kind: ModuleActionKind::DeployFiles {
9157 files: vec![ResolvedFile {
9158 source: source_file.clone(),
9159 target: target_file.clone(),
9160 is_git_source: false,
9161 strategy: None,
9162 encryption: None,
9163 }],
9164 },
9165 })],
9166 }],
9167 warnings: vec![],
9168 };
9169
9170 let printer = test_printer();
9171 let result = reconciler
9172 .apply(
9173 &plan,
9174 &resolved,
9175 dir.path(),
9176 &printer,
9177 Some(&PhaseName::Modules),
9178 &modules,
9179 ReconcileContext::Apply,
9180 false,
9181 )
9182 .unwrap();
9183
9184 assert_eq!(result.status, ApplyStatus::Success);
9185 assert!(target_file.is_symlink(), "target should be a symlink");
9186 assert_eq!(
9187 std::fs::read_to_string(&target_file).unwrap(),
9188 "linked content"
9189 );
9190 }
9191
9192 #[test]
9193 fn apply_module_skip_reports_skipped() {
9194 let state = test_state();
9195 let registry = ProviderRegistry::new();
9196 let reconciler = Reconciler::new(®istry, &state);
9197 let resolved = make_empty_resolved();
9198
9199 let plan = Plan {
9200 phases: vec![Phase {
9201 name: PhaseName::Modules,
9202 actions: vec![Action::Module(ModuleAction {
9203 module_name: "broken".to_string(),
9204 kind: ModuleActionKind::Skip {
9205 reason: "dependency not met".to_string(),
9206 },
9207 })],
9208 }],
9209 warnings: vec![],
9210 };
9211
9212 let printer = test_printer();
9213 let result = reconciler
9214 .apply(
9215 &plan,
9216 &resolved,
9217 Path::new("."),
9218 &printer,
9219 Some(&PhaseName::Modules),
9220 &[],
9221 ReconcileContext::Apply,
9222 false,
9223 )
9224 .unwrap();
9225
9226 assert_eq!(result.status, ApplyStatus::Success);
9227 assert!(result.action_results[0].success);
9228 assert!(
9229 result.action_results[0].description.contains("skip"),
9230 "desc: {}",
9231 result.action_results[0].description
9232 );
9233 }
9234
9235 #[test]
9236 fn apply_module_install_packages_bootstraps_when_needed() {
9237 let state = test_state();
9238 let mut registry = ProviderRegistry::new();
9239 registry
9240 .package_managers
9241 .push(Box::new(BootstrappablePackageManager::new("brew")));
9242
9243 let reconciler = Reconciler::new(®istry, &state);
9244 let resolved = make_empty_resolved();
9245
9246 let modules = vec![ResolvedModule {
9247 name: "tools".to_string(),
9248 packages: vec![ResolvedPackage {
9249 canonical_name: "jq".to_string(),
9250 resolved_name: "jq".to_string(),
9251 manager: "brew".to_string(),
9252 version: None,
9253 script: None,
9254 }],
9255 files: vec![],
9256 env: vec![],
9257 aliases: vec![],
9258 post_apply_scripts: vec![],
9259 pre_apply_scripts: Vec::new(),
9260 pre_reconcile_scripts: Vec::new(),
9261 post_reconcile_scripts: Vec::new(),
9262 on_change_scripts: Vec::new(),
9263 system: HashMap::new(),
9264 depends: vec![],
9265 dir: PathBuf::from("."),
9266 }];
9267
9268 let plan = Plan {
9269 phases: vec![Phase {
9270 name: PhaseName::Modules,
9271 actions: vec![Action::Module(ModuleAction {
9272 module_name: "tools".to_string(),
9273 kind: ModuleActionKind::InstallPackages {
9274 resolved: vec![ResolvedPackage {
9275 canonical_name: "jq".to_string(),
9276 resolved_name: "jq".to_string(),
9277 manager: "brew".to_string(),
9278 version: None,
9279 script: None,
9280 }],
9281 },
9282 })],
9283 }],
9284 warnings: vec![],
9285 };
9286
9287 let printer = test_printer();
9288 let result = reconciler
9289 .apply(
9290 &plan,
9291 &resolved,
9292 Path::new("."),
9293 &printer,
9294 Some(&PhaseName::Modules),
9295 &modules,
9296 ReconcileContext::Apply,
9297 false,
9298 )
9299 .unwrap();
9300
9301 assert_eq!(result.status, ApplyStatus::Success);
9302 assert!(result.action_results[0].success);
9303
9304 assert!(registry.package_managers[0].is_available());
9306 assert!(
9307 registry.package_managers[0]
9308 .installed_packages()
9309 .unwrap()
9310 .contains("jq")
9311 );
9312 }
9313
9314 #[test]
9317 #[cfg(unix)]
9318 fn rollback_restores_symlink_target() {
9319 let dir = tempfile::tempdir().unwrap();
9320 let target = dir.path().join("link-file");
9321 let link_dest = dir.path().join("original-dest.txt");
9322 let file_path = target.display().to_string();
9323 std::fs::write(&link_dest, "link content").unwrap();
9324
9325 let state = test_state();
9326
9327 let apply_id_1 = state
9329 .record_apply("test", "hash1", ApplyStatus::Success, None)
9330 .unwrap();
9331 std::os::unix::fs::symlink(&link_dest, &target).unwrap();
9332 assert!(target.is_symlink());
9333 let resource_id = format!("file:create:{}", target.display());
9334 let jid1 = state
9335 .journal_begin(apply_id_1, 0, "files", "file", &resource_id, None)
9336 .unwrap();
9337 state.journal_complete(jid1, None, None).unwrap();
9338
9339 let file_state = crate::capture_file_state(&target).unwrap().unwrap();
9341 assert!(file_state.is_symlink);
9342 let apply_id_2 = state
9343 .record_apply("test", "hash2", ApplyStatus::Success, None)
9344 .unwrap();
9345 state
9346 .store_file_backup(apply_id_2, &file_path, &file_state)
9347 .unwrap();
9348 let update_resource_id = format!("file:update:{}", target.display());
9349 let jid2 = state
9350 .journal_begin(apply_id_2, 0, "files", "file", &update_resource_id, None)
9351 .unwrap();
9352 state.journal_complete(jid2, None, None).unwrap();
9353
9354 std::fs::remove_file(&target).unwrap();
9356 std::fs::write(&target, "replaced").unwrap();
9357 assert!(!target.is_symlink());
9358
9359 let registry = ProviderRegistry::new();
9361 let reconciler = Reconciler::new(®istry, &state);
9362 let printer = test_printer();
9363
9364 let rollback_result = reconciler.rollback_apply(apply_id_1, &printer).unwrap();
9365
9366 assert_eq!(rollback_result.files_restored, 1);
9367 assert!(target.is_symlink(), "symlink should be restored");
9368 assert_eq!(
9369 std::fs::read_link(&target).unwrap(),
9370 link_dest,
9371 "symlink should point to original destination"
9372 );
9373 }
9374
9375 #[test]
9378 fn plan_modules_encryption_always_with_symlink_skips() {
9379 let state = test_state();
9380 let mut registry = ProviderRegistry::new();
9381 registry.default_file_strategy = crate::config::FileStrategy::Symlink;
9382 let reconciler = Reconciler::new(®istry, &state);
9383
9384 let modules = vec![ResolvedModule {
9385 name: "secrets-mod".to_string(),
9386 packages: vec![],
9387 files: vec![ResolvedFile {
9388 source: PathBuf::from("/nonexistent/secret.enc"),
9389 target: PathBuf::from("/home/user/.secret"),
9390 is_git_source: false,
9391 strategy: None, encryption: Some(crate::config::EncryptionSpec {
9393 backend: "sops".to_string(),
9394 mode: crate::config::EncryptionMode::Always,
9395 }),
9396 }],
9397 env: vec![],
9398 aliases: vec![],
9399 post_apply_scripts: vec![],
9400 pre_apply_scripts: Vec::new(),
9401 pre_reconcile_scripts: Vec::new(),
9402 post_reconcile_scripts: Vec::new(),
9403 on_change_scripts: Vec::new(),
9404 system: HashMap::new(),
9405 depends: vec![],
9406 dir: PathBuf::from("."),
9407 }];
9408
9409 let actions = reconciler.plan_modules(&modules, ReconcileContext::Apply);
9410 assert_eq!(actions.len(), 1);
9412 match &actions[0] {
9413 Action::Module(ma) => match &ma.kind {
9414 ModuleActionKind::Skip { reason } => {
9415 assert!(
9416 reason.contains("encryption mode Always incompatible"),
9417 "got: {reason}"
9418 );
9419 }
9420 other => panic!("Expected Skip, got {:?}", other),
9421 },
9422 other => panic!("Expected Module action, got {:?}", other),
9423 }
9424 }
9425
9426 #[test]
9427 fn plan_modules_encryption_always_with_copy_proceeds() {
9428 let dir = tempfile::tempdir().unwrap();
9429 let source = dir.path().join("secret.enc");
9431 std::fs::write(
9432 &source,
9433 "{\"sops\":{\"mac\":\"abc123\",\"lastmodified\":\"2024-01-01T00:00:00Z\",\"version\":\"3.0\"}, \"data\": \"ENC[AES256_GCM,data:abc]\"}",
9434 )
9435 .unwrap();
9436
9437 let state = test_state();
9438 let mut registry = ProviderRegistry::new();
9439 registry.default_file_strategy = crate::config::FileStrategy::Copy;
9440 let reconciler = Reconciler::new(®istry, &state);
9441
9442 let modules = vec![ResolvedModule {
9443 name: "secrets-mod".to_string(),
9444 packages: vec![],
9445 files: vec![ResolvedFile {
9446 source: source.clone(),
9447 target: PathBuf::from("/home/user/.secret"),
9448 is_git_source: false,
9449 strategy: Some(crate::config::FileStrategy::Copy),
9450 encryption: Some(crate::config::EncryptionSpec {
9451 backend: "sops".to_string(),
9452 mode: crate::config::EncryptionMode::Always,
9453 }),
9454 }],
9455 env: vec![],
9456 aliases: vec![],
9457 post_apply_scripts: vec![],
9458 pre_apply_scripts: Vec::new(),
9459 pre_reconcile_scripts: Vec::new(),
9460 post_reconcile_scripts: Vec::new(),
9461 on_change_scripts: Vec::new(),
9462 system: HashMap::new(),
9463 depends: vec![],
9464 dir: dir.path().to_path_buf(),
9465 }];
9466
9467 let actions = reconciler.plan_modules(&modules, ReconcileContext::Apply);
9468 assert_eq!(actions.len(), 1);
9470 match &actions[0] {
9471 Action::Module(ma) => match &ma.kind {
9472 ModuleActionKind::DeployFiles { files } => {
9473 assert_eq!(files.len(), 1);
9474 }
9475 other => panic!("Expected DeployFiles, got {:?}", other),
9476 },
9477 other => panic!("Expected Module action, got {:?}", other),
9478 }
9479 }
9480
9481 #[test]
9482 fn plan_modules_encryption_file_not_encrypted_skips() {
9483 let dir = tempfile::tempdir().unwrap();
9484 let source = dir.path().join("plain.txt");
9486 std::fs::write(&source, "plain text content").unwrap();
9487
9488 let state = test_state();
9489 let mut registry = ProviderRegistry::new();
9490 registry.default_file_strategy = crate::config::FileStrategy::Copy;
9491 let reconciler = Reconciler::new(®istry, &state);
9492
9493 let modules = vec![ResolvedModule {
9494 name: "secrets-mod".to_string(),
9495 packages: vec![],
9496 files: vec![ResolvedFile {
9497 source: source.clone(),
9498 target: PathBuf::from("/home/user/.secret"),
9499 is_git_source: false,
9500 strategy: Some(crate::config::FileStrategy::Copy),
9501 encryption: Some(crate::config::EncryptionSpec {
9502 backend: "sops".to_string(),
9503 mode: crate::config::EncryptionMode::Always,
9504 }),
9505 }],
9506 env: vec![],
9507 aliases: vec![],
9508 post_apply_scripts: vec![],
9509 pre_apply_scripts: Vec::new(),
9510 pre_reconcile_scripts: Vec::new(),
9511 post_reconcile_scripts: Vec::new(),
9512 on_change_scripts: Vec::new(),
9513 system: HashMap::new(),
9514 depends: vec![],
9515 dir: dir.path().to_path_buf(),
9516 }];
9517
9518 let actions = reconciler.plan_modules(&modules, ReconcileContext::Apply);
9519 assert_eq!(actions.len(), 1);
9521 match &actions[0] {
9522 Action::Module(ma) => match &ma.kind {
9523 ModuleActionKind::Skip { reason } => {
9524 assert!(
9525 reason.contains("requires encryption") && reason.contains("not encrypted"),
9526 "got: {reason}"
9527 );
9528 }
9529 other => panic!("Expected Skip, got {:?}", other),
9530 },
9531 other => panic!("Expected Module action, got {:?}", other),
9532 }
9533 }
9534
9535 #[test]
9538 #[cfg(unix)]
9539 fn apply_script_action_executes_and_records_output() {
9540 let dir = tempfile::tempdir().unwrap();
9541 let marker = dir.path().join("script-ran");
9542
9543 let state = test_state();
9544 let registry = ProviderRegistry::new();
9545 let reconciler = Reconciler::new(®istry, &state);
9546 let mut resolved = make_empty_resolved();
9547
9548 resolved.merged.scripts.post_apply =
9550 vec![ScriptEntry::Simple(format!("touch {}", marker.display()))];
9551
9552 let plan = reconciler
9553 .plan(
9554 &resolved,
9555 Vec::new(),
9556 Vec::new(),
9557 Vec::new(),
9558 ReconcileContext::Apply,
9559 )
9560 .unwrap();
9561
9562 let printer = test_printer();
9563 let result = reconciler
9564 .apply(
9565 &plan,
9566 &resolved,
9567 dir.path(),
9568 &printer,
9569 None,
9570 &[],
9571 ReconcileContext::Apply,
9572 false,
9573 )
9574 .unwrap();
9575
9576 let script_result = result
9578 .action_results
9579 .iter()
9580 .find(|r| r.description.contains("script:"));
9581 assert!(script_result.is_some(), "script action should be recorded");
9582 assert!(script_result.unwrap().success);
9583 assert!(marker.exists(), "script should have run and created marker");
9584 }
9585
9586 #[test]
9589 #[cfg(unix)]
9590 fn apply_module_run_script_executes_in_module_dir() {
9591 let dir = tempfile::tempdir().unwrap();
9592 let marker = dir.path().join("module-script-ran");
9593
9594 let state = test_state();
9595 let registry = ProviderRegistry::new();
9596 let reconciler = Reconciler::new(®istry, &state);
9597 let resolved = make_empty_resolved();
9598
9599 let modules = vec![ResolvedModule {
9600 name: "testmod".to_string(),
9601 packages: vec![],
9602 files: vec![],
9603 env: vec![],
9604 aliases: vec![],
9605 pre_apply_scripts: Vec::new(),
9606 post_apply_scripts: vec![ScriptEntry::Simple(format!("touch {}", marker.display()))],
9607 pre_reconcile_scripts: Vec::new(),
9608 post_reconcile_scripts: Vec::new(),
9609 on_change_scripts: Vec::new(),
9610 system: HashMap::new(),
9611 depends: vec![],
9612 dir: dir.path().to_path_buf(),
9613 }];
9614
9615 let plan = Plan {
9616 phases: vec![Phase {
9617 name: PhaseName::Modules,
9618 actions: vec![Action::Module(ModuleAction {
9619 module_name: "testmod".to_string(),
9620 kind: ModuleActionKind::RunScript {
9621 script: ScriptEntry::Simple(format!("touch {}", marker.display())),
9622 phase: ScriptPhase::PostApply,
9623 },
9624 })],
9625 }],
9626 warnings: vec![],
9627 };
9628
9629 let printer = test_printer();
9630 let result = reconciler
9631 .apply(
9632 &plan,
9633 &resolved,
9634 dir.path(),
9635 &printer,
9636 Some(&PhaseName::Modules),
9637 &modules,
9638 ReconcileContext::Apply,
9639 false,
9640 )
9641 .unwrap();
9642
9643 assert_eq!(result.status, ApplyStatus::Success);
9644 assert!(result.action_results[0].success);
9645 assert!(marker.exists(), "module script should have created marker");
9646 assert!(
9647 result.action_results[0]
9648 .description
9649 .contains("module:testmod:script"),
9650 "desc: {}",
9651 result.action_results[0].description
9652 );
9653 }
9654
9655 #[test]
9658 fn generate_fish_env_content_basic() {
9659 let env = vec![
9660 crate::config::EnvVar {
9661 name: "EDITOR".into(),
9662 value: "nvim".into(),
9663 },
9664 crate::config::EnvVar {
9665 name: "CARGO_HOME".into(),
9666 value: "/home/user/.cargo".into(),
9667 },
9668 ];
9669 let aliases = vec![crate::config::ShellAlias {
9670 name: "g".into(),
9671 command: "git".into(),
9672 }];
9673 let content = super::generate_fish_env_content(&env, &aliases);
9674 assert!(content.starts_with("# managed by cfgd"));
9675 assert!(content.contains("set -gx EDITOR 'nvim'"));
9676 assert!(content.contains("set -gx CARGO_HOME '/home/user/.cargo'"));
9677 assert!(content.contains("abbr -a g 'git'"));
9678 }
9679
9680 #[test]
9681 fn generate_powershell_env_content_with_env_ref() {
9682 let env = vec![crate::config::EnvVar {
9683 name: "MY_PATH".into(),
9684 value: r"C:\tools;$env:PATH".into(),
9685 }];
9686 let content = super::generate_powershell_env_content(&env, &[]);
9687 assert!(
9689 content.contains(r#"$env:MY_PATH = "C:\tools;$env:PATH""#),
9690 "content: {}",
9691 content
9692 );
9693 }
9694
9695 #[test]
9696 fn generate_powershell_env_function_alias() {
9697 let aliases = vec![crate::config::ShellAlias {
9699 name: "ll".into(),
9700 command: "Get-ChildItem -Force".into(),
9701 }];
9702 let content = super::generate_powershell_env_content(&[], &aliases);
9703 assert!(content.contains("function ll {"));
9704 assert!(content.contains("Get-ChildItem -Force @args"));
9705 }
9706
9707 #[test]
9708 fn generate_fish_env_path_splitting() {
9709 let env = vec![crate::config::EnvVar {
9711 name: "PATH".into(),
9712 value: "/usr/bin:/usr/local/bin:$PATH".into(),
9713 }];
9714 let content = super::generate_fish_env_content(&env, &[]);
9715 assert!(
9716 content.contains("set -gx PATH '/usr/bin' '/usr/local/bin' '$PATH'"),
9717 "content: {}",
9718 content
9719 );
9720 }
9721
9722 #[test]
9725 fn build_script_env_all_phases() {
9726 let phases_and_expected = [
9728 (ScriptPhase::PreApply, "preApply"),
9729 (ScriptPhase::PostApply, "postApply"),
9730 (ScriptPhase::PreReconcile, "preReconcile"),
9731 (ScriptPhase::PostReconcile, "postReconcile"),
9732 (ScriptPhase::OnDrift, "onDrift"),
9733 (ScriptPhase::OnChange, "onChange"),
9734 ];
9735
9736 for (phase, expected_name) in &phases_and_expected {
9737 let env = super::build_script_env(
9738 std::path::Path::new("/etc/cfgd"),
9739 "default",
9740 ReconcileContext::Apply,
9741 phase,
9742 false,
9743 None,
9744 None,
9745 );
9746 let map: HashMap<String, String> = env.into_iter().collect();
9747 assert_eq!(
9748 map.get("CFGD_PHASE").unwrap(),
9749 expected_name,
9750 "phase {:?} should produce CFGD_PHASE={}",
9751 phase,
9752 expected_name
9753 );
9754 }
9755 }
9756
9757 #[test]
9758 fn build_script_env_dry_run_true_propagates() {
9759 let env = super::build_script_env(
9760 std::path::Path::new("/cfg"),
9761 "laptop",
9762 ReconcileContext::Apply,
9763 &ScriptPhase::PreApply,
9764 true,
9765 None,
9766 None,
9767 );
9768 let map: HashMap<String, String> = env.into_iter().collect();
9769 assert_eq!(map.get("CFGD_DRY_RUN").unwrap(), "true");
9770 }
9771
9772 #[test]
9773 fn build_script_env_reconcile_context() {
9774 let env = super::build_script_env(
9775 std::path::Path::new("/cfg"),
9776 "server",
9777 ReconcileContext::Reconcile,
9778 &ScriptPhase::PostReconcile,
9779 false,
9780 None,
9781 None,
9782 );
9783 let map: HashMap<String, String> = env.into_iter().collect();
9784 assert_eq!(map.get("CFGD_CONTEXT").unwrap(), "reconcile");
9785 assert_eq!(map.get("CFGD_PHASE").unwrap(), "postReconcile");
9786 assert_eq!(map.get("CFGD_PROFILE").unwrap(), "server");
9787 }
9788
9789 #[test]
9790 fn build_script_env_module_name_without_dir() {
9791 let env = super::build_script_env(
9793 std::path::Path::new("/cfg"),
9794 "default",
9795 ReconcileContext::Apply,
9796 &ScriptPhase::PreApply,
9797 false,
9798 Some("zsh"),
9799 None,
9800 );
9801 let map: HashMap<String, String> = env.into_iter().collect();
9802 assert_eq!(map.get("CFGD_MODULE_NAME").unwrap(), "zsh");
9803 assert!(
9804 !map.contains_key("CFGD_MODULE_DIR"),
9805 "CFGD_MODULE_DIR should not be set when module_dir is None"
9806 );
9807 }
9808
9809 #[test]
9810 fn build_script_env_count_base_vars() {
9811 let env = super::build_script_env(
9813 std::path::Path::new("/x"),
9814 "p",
9815 ReconcileContext::Apply,
9816 &ScriptPhase::PreApply,
9817 false,
9818 None,
9819 None,
9820 );
9821 assert_eq!(env.len(), 5, "base env should have 5 entries");
9822
9823 let env_with_module = super::build_script_env(
9825 std::path::Path::new("/x"),
9826 "p",
9827 ReconcileContext::Apply,
9828 &ScriptPhase::PreApply,
9829 false,
9830 Some("m"),
9831 Some(std::path::Path::new("/modules/m")),
9832 );
9833 assert_eq!(
9834 env_with_module.len(),
9835 7,
9836 "env with module info should have 7 entries"
9837 );
9838 }
9839
9840 #[test]
9843 fn verify_empty_profile_returns_no_results() {
9844 let state = test_state();
9845 let registry = ProviderRegistry::new();
9846 let resolved = make_empty_resolved();
9847 let printer = test_printer();
9848
9849 let results = verify(&resolved, ®istry, &state, &printer, &[]).unwrap();
9850 assert!(
9851 results.is_empty(),
9852 "empty profile with no modules should produce no verify results, got: {:?}",
9853 results
9854 );
9855 }
9856
9857 #[test]
9858 fn verify_file_target_exists() {
9859 let state = test_state();
9860 let registry = ProviderRegistry::new();
9861 let printer = test_printer();
9862 let tmp = tempfile::tempdir().unwrap();
9863
9864 let target_path = tmp.path().join("existing.conf");
9866 std::fs::write(&target_path, "content").unwrap();
9867
9868 let mut resolved = make_empty_resolved();
9869 resolved.merged.files.managed.push(ManagedFileSpec {
9870 source: "source.conf".to_string(),
9871 target: target_path.clone(),
9872 strategy: None,
9873 private: false,
9874 origin: None,
9875 encryption: None,
9876 permissions: None,
9877 });
9878
9879 let results = verify(&resolved, ®istry, &state, &printer, &[]).unwrap();
9880 let file_result = results
9881 .iter()
9882 .find(|r| r.resource_type == "file")
9883 .expect("should have a file verify result");
9884 assert!(file_result.matches, "existing file should match");
9885 assert_eq!(file_result.expected, "present");
9886 assert_eq!(file_result.actual, "present");
9887 }
9888
9889 #[test]
9890 fn verify_file_target_missing() {
9891 let state = test_state();
9892 let registry = ProviderRegistry::new();
9893 let printer = test_printer();
9894
9895 let mut resolved = make_empty_resolved();
9896 resolved.merged.files.managed.push(ManagedFileSpec {
9897 source: "source.conf".to_string(),
9898 target: PathBuf::from("/tmp/cfgd-test-nonexistent-file-39485738"),
9899 strategy: None,
9900 private: false,
9901 origin: None,
9902 encryption: None,
9903 permissions: None,
9904 });
9905
9906 let results = verify(&resolved, ®istry, &state, &printer, &[]).unwrap();
9907 let file_result = results
9908 .iter()
9909 .find(|r| r.resource_type == "file")
9910 .expect("should have a file verify result");
9911 assert!(!file_result.matches, "missing file should not match");
9912 assert_eq!(file_result.expected, "present");
9913 assert_eq!(file_result.actual, "missing");
9914 }
9915
9916 #[test]
9917 fn verify_module_file_target_missing_causes_drift() {
9918 let state = test_state();
9919 let registry = ProviderRegistry::new();
9920 let printer = test_printer();
9921 let resolved = make_empty_resolved();
9922
9923 let modules = vec![ResolvedModule {
9924 name: "test-mod".to_string(),
9925 packages: vec![],
9926 files: vec![ResolvedFile {
9927 source: PathBuf::from("/src/config"),
9928 target: PathBuf::from("/tmp/cfgd-test-nonexistent-module-file-29384"),
9929 is_git_source: false,
9930 strategy: None,
9931 encryption: None,
9932 }],
9933 env: vec![],
9934 aliases: vec![],
9935 post_apply_scripts: vec![],
9936 pre_apply_scripts: Vec::new(),
9937 pre_reconcile_scripts: Vec::new(),
9938 post_reconcile_scripts: Vec::new(),
9939 on_change_scripts: Vec::new(),
9940 system: HashMap::new(),
9941 depends: vec![],
9942 dir: PathBuf::from("."),
9943 }];
9944
9945 let results = verify(&resolved, ®istry, &state, &printer, &modules).unwrap();
9946
9947 let drift = results
9949 .iter()
9950 .find(|r| r.resource_type == "module" && !r.matches);
9951 assert!(
9952 drift.is_some(),
9953 "missing module file target should cause drift"
9954 );
9955 let d = drift.unwrap();
9956 assert_eq!(d.expected, "present");
9957 assert_eq!(d.actual, "missing");
9958 assert!(d.resource_id.contains("test-mod"));
9959 }
9960
9961 #[test]
9962 fn verify_module_file_target_exists_no_drift() {
9963 let state = test_state();
9964 let registry = ProviderRegistry::new();
9965 let printer = test_printer();
9966 let resolved = make_empty_resolved();
9967 let tmp = tempfile::tempdir().unwrap();
9968
9969 let target_path = tmp.path().join("module-config");
9970 std::fs::write(&target_path, "content").unwrap();
9971
9972 let modules = vec![ResolvedModule {
9973 name: "files-mod".to_string(),
9974 packages: vec![],
9975 files: vec![ResolvedFile {
9976 source: PathBuf::from("/src/config"),
9977 target: target_path,
9978 is_git_source: false,
9979 strategy: None,
9980 encryption: None,
9981 }],
9982 env: vec![],
9983 aliases: vec![],
9984 post_apply_scripts: vec![],
9985 pre_apply_scripts: Vec::new(),
9986 pre_reconcile_scripts: Vec::new(),
9987 post_reconcile_scripts: Vec::new(),
9988 on_change_scripts: Vec::new(),
9989 system: HashMap::new(),
9990 depends: vec![],
9991 dir: PathBuf::from("."),
9992 }];
9993
9994 let results = verify(&resolved, ®istry, &state, &printer, &modules).unwrap();
9995
9996 let healthy = results
9998 .iter()
9999 .find(|r| r.resource_type == "module" && r.resource_id == "files-mod");
10000 assert!(healthy.is_some(), "module should have a healthy result");
10001 assert!(healthy.unwrap().matches);
10002 }
10003
10004 #[test]
10005 fn verify_multiple_packages_mixed_status() {
10006 let state = test_state();
10007 let mut registry = ProviderRegistry::new();
10008
10009 registry.package_managers.push(Box::new(
10011 MockPackageManager::new("apt").with_installed(&["git"]),
10012 ));
10013
10014 let mut resolved = make_empty_resolved();
10015 resolved.merged.packages.apt = Some(crate::config::AptSpec {
10016 file: None,
10017 packages: vec!["git".to_string(), "tmux".to_string()],
10018 });
10019
10020 let printer = test_printer();
10021 let results = verify(&resolved, ®istry, &state, &printer, &[]).unwrap();
10022
10023 let git_result = results
10024 .iter()
10025 .find(|r| r.resource_id == "apt:git")
10026 .expect("should have git result");
10027 assert!(git_result.matches);
10028 assert_eq!(git_result.expected, "installed");
10029 assert_eq!(git_result.actual, "installed");
10030
10031 let tmux_result = results
10032 .iter()
10033 .find(|r| r.resource_id == "apt:tmux")
10034 .expect("should have tmux result");
10035 assert!(!tmux_result.matches);
10036 assert_eq!(tmux_result.expected, "installed");
10037 assert_eq!(tmux_result.actual, "missing");
10038 }
10039
10040 #[test]
10043 fn format_action_description_env_write_file() {
10044 let action = Action::Env(EnvAction::WriteEnvFile {
10045 path: PathBuf::from("/home/user/.cfgd.env"),
10046 content: "export FOO=bar\n".to_string(),
10047 });
10048 let desc = format_action_description(&action);
10049 assert_eq!(desc, "env:write:/home/user/.cfgd.env");
10050 }
10051
10052 #[test]
10053 fn format_action_description_env_inject_source() {
10054 let action = Action::Env(EnvAction::InjectSourceLine {
10055 rc_path: PathBuf::from("/home/user/.zshrc"),
10056 line: "source ~/.cfgd.env".to_string(),
10057 });
10058 let desc = format_action_description(&action);
10059 assert_eq!(desc, "env:inject:/home/user/.zshrc");
10060 }
10061
10062 #[test]
10063 fn format_action_description_script_run_entry() {
10064 let action = Action::Script(ScriptAction::Run {
10065 entry: ScriptEntry::Simple("echo hello".to_string()),
10066 phase: ScriptPhase::PreApply,
10067 origin: "local".to_string(),
10068 });
10069 let desc = format_action_description(&action);
10070 assert_eq!(desc, "script:preApply:echo hello");
10071 }
10072
10073 #[test]
10074 fn format_action_description_system_set_value_sysctl() {
10075 let action = Action::System(SystemAction::SetValue {
10076 configurator: "sysctl".to_string(),
10077 key: "net.ipv4.ip_forward".to_string(),
10078 desired: "1".to_string(),
10079 current: "0".to_string(),
10080 origin: "local".to_string(),
10081 });
10082 let desc = format_action_description(&action);
10083 assert_eq!(desc, "system:sysctl.net.ipv4.ip_forward");
10084 }
10085
10086 #[test]
10087 fn format_action_description_system_skip_sysctl() {
10088 let action = Action::System(SystemAction::Skip {
10089 configurator: "sysctl".to_string(),
10090 reason: "not available".to_string(),
10091 origin: "local".to_string(),
10092 });
10093 let desc = format_action_description(&action);
10094 assert_eq!(desc, "system:sysctl:skip");
10095 }
10096
10097 #[test]
10098 fn format_action_description_module_install_multiple_packages() {
10099 let action = Action::Module(ModuleAction {
10100 module_name: "neovim".to_string(),
10101 kind: ModuleActionKind::InstallPackages {
10102 resolved: vec![
10103 ResolvedPackage {
10104 canonical_name: "neovim".to_string(),
10105 resolved_name: "neovim".to_string(),
10106 manager: "brew".to_string(),
10107 version: None,
10108 script: None,
10109 },
10110 ResolvedPackage {
10111 canonical_name: "ripgrep".to_string(),
10112 resolved_name: "ripgrep".to_string(),
10113 manager: "brew".to_string(),
10114 version: None,
10115 script: None,
10116 },
10117 ],
10118 },
10119 });
10120 let desc = format_action_description(&action);
10121 assert_eq!(desc, "module:neovim:packages:neovim,ripgrep");
10122 }
10123
10124 #[test]
10125 fn format_action_description_module_deploy_two_files() {
10126 let action = Action::Module(ModuleAction {
10127 module_name: "nvim".to_string(),
10128 kind: ModuleActionKind::DeployFiles {
10129 files: vec![
10130 ResolvedFile {
10131 source: PathBuf::from("/src/init.lua"),
10132 target: PathBuf::from("/home/.config/nvim/init.lua"),
10133 is_git_source: false,
10134 strategy: None,
10135 encryption: None,
10136 },
10137 ResolvedFile {
10138 source: PathBuf::from("/src/plugins.lua"),
10139 target: PathBuf::from("/home/.config/nvim/plugins.lua"),
10140 is_git_source: false,
10141 strategy: None,
10142 encryption: None,
10143 },
10144 ],
10145 },
10146 });
10147 let desc = format_action_description(&action);
10148 assert_eq!(desc, "module:nvim:files:2");
10149 }
10150
10151 #[test]
10152 fn format_action_description_module_run_post_apply_script() {
10153 let action = Action::Module(ModuleAction {
10154 module_name: "rust".to_string(),
10155 kind: ModuleActionKind::RunScript {
10156 script: ScriptEntry::Simple("./setup.sh".to_string()),
10157 phase: ScriptPhase::PostApply,
10158 },
10159 });
10160 let desc = format_action_description(&action);
10161 assert_eq!(desc, "module:rust:script");
10162 }
10163
10164 #[test]
10165 fn format_action_description_module_skip_dependency() {
10166 let action = Action::Module(ModuleAction {
10167 module_name: "rust".to_string(),
10168 kind: ModuleActionKind::Skip {
10169 reason: "dependency not met".to_string(),
10170 },
10171 });
10172 let desc = format_action_description(&action);
10173 assert_eq!(desc, "module:rust:skip");
10174 }
10175
10176 #[test]
10177 fn format_action_description_package_bootstrap() {
10178 let action = Action::Package(PackageAction::Bootstrap {
10179 manager: "brew".to_string(),
10180 method: "curl".to_string(),
10181 origin: "local".to_string(),
10182 });
10183 let desc = format_action_description(&action);
10184 assert_eq!(desc, "package:brew:bootstrap");
10185 }
10186
10187 #[test]
10188 fn format_action_description_package_uninstall() {
10189 let action = Action::Package(PackageAction::Uninstall {
10190 manager: "apt".to_string(),
10191 packages: vec!["vim".to_string(), "nano".to_string()],
10192 origin: "local".to_string(),
10193 });
10194 let desc = format_action_description(&action);
10195 assert_eq!(desc, "package:apt:uninstall:vim,nano");
10196 }
10197
10198 #[test]
10199 fn format_action_description_file_set_permissions() {
10200 let action = Action::File(FileAction::SetPermissions {
10201 target: PathBuf::from("/etc/config.yaml"),
10202 mode: 0o600,
10203 origin: "local".to_string(),
10204 });
10205 let desc = format_action_description(&action);
10206 assert_eq!(desc, "file:chmod:0o600:/etc/config.yaml");
10207 }
10208
10209 #[test]
10212 fn phase_name_all_variants_roundtrip() {
10213 let variants = [
10214 ("pre-scripts", PhaseName::PreScripts, "Pre-Scripts"),
10215 ("env", PhaseName::Env, "Environment"),
10216 ("modules", PhaseName::Modules, "Modules"),
10217 ("packages", PhaseName::Packages, "Packages"),
10218 ("system", PhaseName::System, "System"),
10219 ("files", PhaseName::Files, "Files"),
10220 ("secrets", PhaseName::Secrets, "Secrets"),
10221 ("post-scripts", PhaseName::PostScripts, "Post-Scripts"),
10222 ];
10223
10224 for (s, expected_variant, display) in &variants {
10225 let parsed = PhaseName::from_str(s).unwrap();
10226 assert_eq!(&parsed, expected_variant);
10227 assert_eq!(parsed.as_str(), *s);
10228 assert_eq!(parsed.display_name(), *display);
10229 }
10230 }
10231
10232 #[test]
10233 fn phase_name_unknown_returns_err() {
10234 let result = PhaseName::from_str("unknown-phase");
10235 assert!(result.is_err());
10236 let err = result.unwrap_err();
10237 assert!(
10238 err.contains("unknown phase"),
10239 "error should mention unknown phase: {}",
10240 err
10241 );
10242 }
10243
10244 #[test]
10247 fn script_phase_display_names() {
10248 assert_eq!(ScriptPhase::PreApply.display_name(), "preApply");
10249 assert_eq!(ScriptPhase::PostApply.display_name(), "postApply");
10250 assert_eq!(ScriptPhase::PreReconcile.display_name(), "preReconcile");
10251 assert_eq!(ScriptPhase::PostReconcile.display_name(), "postReconcile");
10252 assert_eq!(ScriptPhase::OnDrift.display_name(), "onDrift");
10253 assert_eq!(ScriptPhase::OnChange.display_name(), "onChange");
10254 }
10255
10256 #[test]
10259 fn verify_env_file_matches_when_content_equal() {
10260 let state = test_state();
10261 let tmp = tempfile::tempdir().unwrap();
10262 let env_path = tmp.path().join("test.env");
10263 let expected = "export FOO=\"bar\"\n";
10264 std::fs::write(&env_path, expected).unwrap();
10265
10266 let mut results = Vec::new();
10267 super::verify_env_file(&env_path, expected, &state, &mut results);
10268
10269 assert_eq!(results.len(), 1);
10270 assert!(results[0].matches);
10271 assert_eq!(results[0].resource_type, "env");
10272 assert_eq!(results[0].expected, "current");
10273 assert_eq!(results[0].actual, "current");
10274 }
10275
10276 #[test]
10277 fn verify_env_file_stale_when_content_differs() {
10278 let state = test_state();
10279 let tmp = tempfile::tempdir().unwrap();
10280 let env_path = tmp.path().join("test.env");
10281 std::fs::write(&env_path, "old content").unwrap();
10282
10283 let mut results = Vec::new();
10284 super::verify_env_file(&env_path, "new content", &state, &mut results);
10285
10286 assert_eq!(results.len(), 1);
10287 assert!(!results[0].matches);
10288 assert_eq!(results[0].expected, "current");
10289 assert_eq!(results[0].actual, "stale");
10290 }
10291
10292 #[test]
10293 fn verify_env_file_missing_when_file_absent() {
10294 let state = test_state();
10295 let tmp = tempfile::tempdir().unwrap();
10296 let env_path = tmp.path().join("nonexistent.env");
10297
10298 let mut results = Vec::new();
10299 super::verify_env_file(&env_path, "expected content", &state, &mut results);
10300
10301 assert_eq!(results.len(), 1);
10302 assert!(!results[0].matches);
10303 assert_eq!(results[0].expected, "present");
10304 assert_eq!(results[0].actual, "missing");
10305 }
10306
10307 #[test]
10310 fn merge_module_env_aliases_empty() {
10311 let (env, aliases) = super::merge_module_env_aliases(&[], &[], &[]);
10312 assert!(env.is_empty());
10313 assert!(aliases.is_empty());
10314 }
10315
10316 #[test]
10317 fn merge_module_env_aliases_combines_profile_and_modules() {
10318 let profile_env = vec![crate::config::EnvVar {
10319 name: "EDITOR".into(),
10320 value: "vim".into(),
10321 }];
10322 let profile_aliases = vec![crate::config::ShellAlias {
10323 name: "g".into(),
10324 command: "git".into(),
10325 }];
10326 let modules = vec![ResolvedModule {
10327 name: "test".to_string(),
10328 packages: vec![],
10329 files: vec![],
10330 env: vec![crate::config::EnvVar {
10331 name: "PAGER".into(),
10332 value: "less".into(),
10333 }],
10334 aliases: vec![crate::config::ShellAlias {
10335 name: "ll".into(),
10336 command: "ls -la".into(),
10337 }],
10338 post_apply_scripts: vec![],
10339 pre_apply_scripts: Vec::new(),
10340 pre_reconcile_scripts: Vec::new(),
10341 post_reconcile_scripts: Vec::new(),
10342 on_change_scripts: Vec::new(),
10343 system: HashMap::new(),
10344 depends: vec![],
10345 dir: PathBuf::from("."),
10346 }];
10347
10348 let (env, aliases) =
10349 super::merge_module_env_aliases(&profile_env, &profile_aliases, &modules);
10350 assert_eq!(env.len(), 2);
10351 assert_eq!(aliases.len(), 2);
10352
10353 assert!(env.iter().any(|e| e.name == "EDITOR"));
10355 assert!(env.iter().any(|e| e.name == "PAGER"));
10356 assert!(aliases.iter().any(|a| a.name == "g"));
10357 assert!(aliases.iter().any(|a| a.name == "ll"));
10358 }
10359
10360 #[test]
10361 fn merge_module_env_aliases_module_overrides_profile() {
10362 let profile_env = vec![crate::config::EnvVar {
10363 name: "EDITOR".into(),
10364 value: "vim".into(),
10365 }];
10366 let modules = vec![ResolvedModule {
10367 name: "test".to_string(),
10368 packages: vec![],
10369 files: vec![],
10370 env: vec![crate::config::EnvVar {
10371 name: "EDITOR".into(),
10372 value: "nvim".into(),
10373 }],
10374 aliases: vec![],
10375 post_apply_scripts: vec![],
10376 pre_apply_scripts: Vec::new(),
10377 pre_reconcile_scripts: Vec::new(),
10378 post_reconcile_scripts: Vec::new(),
10379 on_change_scripts: Vec::new(),
10380 system: HashMap::new(),
10381 depends: vec![],
10382 dir: PathBuf::from("."),
10383 }];
10384
10385 let (env, _) = super::merge_module_env_aliases(&profile_env, &[], &modules);
10386 let editor = env.iter().find(|e| e.name == "EDITOR").unwrap();
10388 assert_eq!(
10389 editor.value, "nvim",
10390 "module should override profile env var"
10391 );
10392 }
10393
10394 #[test]
10397 fn apply_module_deploy_files_hardlink_strategy() {
10398 let dir = tempfile::tempdir().unwrap();
10399 let source_file = dir.path().join("source.txt");
10400 let target_file = dir.path().join("hardlink-target.txt");
10401 std::fs::write(&source_file, "hardlinked content").unwrap();
10402
10403 let state = test_state();
10404 let mut registry = ProviderRegistry::new();
10405 registry.default_file_strategy = crate::config::FileStrategy::Hardlink;
10406
10407 let reconciler = Reconciler::new(®istry, &state);
10408 let resolved = make_empty_resolved();
10409
10410 let plan = Plan {
10411 phases: vec![Phase {
10412 name: PhaseName::Modules,
10413 actions: vec![Action::Module(ModuleAction {
10414 module_name: "hardmod".to_string(),
10415 kind: ModuleActionKind::DeployFiles {
10416 files: vec![ResolvedFile {
10417 source: source_file.clone(),
10418 target: target_file.clone(),
10419 is_git_source: false,
10420 strategy: Some(crate::config::FileStrategy::Hardlink),
10421 encryption: None,
10422 }],
10423 },
10424 })],
10425 }],
10426 warnings: vec![],
10427 };
10428
10429 let modules = vec![ResolvedModule {
10430 name: "hardmod".to_string(),
10431 packages: vec![],
10432 files: vec![ResolvedFile {
10433 source: source_file.clone(),
10434 target: target_file.clone(),
10435 is_git_source: false,
10436 strategy: Some(crate::config::FileStrategy::Hardlink),
10437 encryption: None,
10438 }],
10439 env: vec![],
10440 aliases: vec![],
10441 post_apply_scripts: vec![],
10442 pre_apply_scripts: Vec::new(),
10443 pre_reconcile_scripts: Vec::new(),
10444 post_reconcile_scripts: Vec::new(),
10445 on_change_scripts: Vec::new(),
10446 system: HashMap::new(),
10447 depends: vec![],
10448 dir: dir.path().to_path_buf(),
10449 }];
10450
10451 let printer = test_printer();
10452 let result = reconciler
10453 .apply(
10454 &plan,
10455 &resolved,
10456 dir.path(),
10457 &printer,
10458 Some(&PhaseName::Modules),
10459 &modules,
10460 ReconcileContext::Apply,
10461 false,
10462 )
10463 .unwrap();
10464
10465 assert_eq!(result.status, ApplyStatus::Success);
10466 assert!(
10467 !target_file.is_symlink(),
10468 "hardlink should not be a symlink"
10469 );
10470 assert_eq!(
10471 std::fs::read_to_string(&target_file).unwrap(),
10472 "hardlinked content"
10473 );
10474 #[cfg(unix)]
10476 {
10477 assert!(
10478 crate::is_same_inode(&source_file, &target_file),
10479 "source and target should share the same inode"
10480 );
10481 }
10482 }
10483
10484 #[test]
10487 fn apply_module_deploy_files_copy_strategy() {
10488 let dir = tempfile::tempdir().unwrap();
10489 let source_file = dir.path().join("source.txt");
10490 let target_file = dir.path().join("copy-target.txt");
10491 std::fs::write(&source_file, "copied content").unwrap();
10492
10493 let state = test_state();
10494 let mut registry = ProviderRegistry::new();
10495 registry.default_file_strategy = crate::config::FileStrategy::Copy;
10496
10497 let reconciler = Reconciler::new(®istry, &state);
10498 let resolved = make_empty_resolved();
10499
10500 let plan = Plan {
10501 phases: vec![Phase {
10502 name: PhaseName::Modules,
10503 actions: vec![Action::Module(ModuleAction {
10504 module_name: "copymod".to_string(),
10505 kind: ModuleActionKind::DeployFiles {
10506 files: vec![ResolvedFile {
10507 source: source_file.clone(),
10508 target: target_file.clone(),
10509 is_git_source: false,
10510 strategy: Some(crate::config::FileStrategy::Copy),
10511 encryption: None,
10512 }],
10513 },
10514 })],
10515 }],
10516 warnings: vec![],
10517 };
10518
10519 let modules = vec![ResolvedModule {
10520 name: "copymod".to_string(),
10521 packages: vec![],
10522 files: vec![ResolvedFile {
10523 source: source_file.clone(),
10524 target: target_file.clone(),
10525 is_git_source: false,
10526 strategy: Some(crate::config::FileStrategy::Copy),
10527 encryption: None,
10528 }],
10529 env: vec![],
10530 aliases: vec![],
10531 post_apply_scripts: vec![],
10532 pre_apply_scripts: Vec::new(),
10533 pre_reconcile_scripts: Vec::new(),
10534 post_reconcile_scripts: Vec::new(),
10535 on_change_scripts: Vec::new(),
10536 system: HashMap::new(),
10537 depends: vec![],
10538 dir: dir.path().to_path_buf(),
10539 }];
10540
10541 let printer = test_printer();
10542 let result = reconciler
10543 .apply(
10544 &plan,
10545 &resolved,
10546 dir.path(),
10547 &printer,
10548 Some(&PhaseName::Modules),
10549 &modules,
10550 ReconcileContext::Apply,
10551 false,
10552 )
10553 .unwrap();
10554
10555 assert_eq!(result.status, ApplyStatus::Success);
10556 assert!(!target_file.is_symlink(), "copy should not be a symlink");
10557 assert_eq!(
10558 std::fs::read_to_string(&target_file).unwrap(),
10559 "copied content"
10560 );
10561 #[cfg(unix)]
10563 {
10564 assert!(
10565 !crate::is_same_inode(&source_file, &target_file),
10566 "copy should have a different inode"
10567 );
10568 }
10569 }
10570
10571 #[test]
10574 fn apply_module_deploy_files_directory_copy_strategy() {
10575 let dir = tempfile::tempdir().unwrap();
10576 let source_dir = dir.path().join("src-dir");
10577 std::fs::create_dir(&source_dir).unwrap();
10578 std::fs::write(source_dir.join("a.txt"), "aaa").unwrap();
10579 std::fs::write(source_dir.join("b.txt"), "bbb").unwrap();
10580
10581 let target_dir = dir.path().join("target-dir");
10582
10583 let state = test_state();
10584 let mut registry = ProviderRegistry::new();
10585 registry.default_file_strategy = crate::config::FileStrategy::Copy;
10586
10587 let reconciler = Reconciler::new(®istry, &state);
10588 let resolved = make_empty_resolved();
10589
10590 let plan = Plan {
10591 phases: vec![Phase {
10592 name: PhaseName::Modules,
10593 actions: vec![Action::Module(ModuleAction {
10594 module_name: "dirmod".to_string(),
10595 kind: ModuleActionKind::DeployFiles {
10596 files: vec![ResolvedFile {
10597 source: source_dir.clone(),
10598 target: target_dir.clone(),
10599 is_git_source: false,
10600 strategy: Some(crate::config::FileStrategy::Copy),
10601 encryption: None,
10602 }],
10603 },
10604 })],
10605 }],
10606 warnings: vec![],
10607 };
10608
10609 let modules = vec![ResolvedModule {
10610 name: "dirmod".to_string(),
10611 packages: vec![],
10612 files: vec![ResolvedFile {
10613 source: source_dir.clone(),
10614 target: target_dir.clone(),
10615 is_git_source: false,
10616 strategy: Some(crate::config::FileStrategy::Copy),
10617 encryption: None,
10618 }],
10619 env: vec![],
10620 aliases: vec![],
10621 post_apply_scripts: vec![],
10622 pre_apply_scripts: Vec::new(),
10623 pre_reconcile_scripts: Vec::new(),
10624 post_reconcile_scripts: Vec::new(),
10625 on_change_scripts: Vec::new(),
10626 system: HashMap::new(),
10627 depends: vec![],
10628 dir: dir.path().to_path_buf(),
10629 }];
10630
10631 let printer = test_printer();
10632 let result = reconciler
10633 .apply(
10634 &plan,
10635 &resolved,
10636 dir.path(),
10637 &printer,
10638 Some(&PhaseName::Modules),
10639 &modules,
10640 ReconcileContext::Apply,
10641 false,
10642 )
10643 .unwrap();
10644
10645 assert_eq!(result.status, ApplyStatus::Success);
10646 assert!(target_dir.is_dir(), "target should be a directory");
10647 assert!(!target_dir.is_symlink(), "copy should not be a symlink");
10648 assert_eq!(
10649 std::fs::read_to_string(target_dir.join("a.txt")).unwrap(),
10650 "aaa"
10651 );
10652 assert_eq!(
10653 std::fs::read_to_string(target_dir.join("b.txt")).unwrap(),
10654 "bbb"
10655 );
10656 }
10657
10658 #[test]
10661 fn apply_module_deploy_files_overwrites_existing_file() {
10662 let dir = tempfile::tempdir().unwrap();
10663 let source_file = dir.path().join("source.txt");
10664 let target_file = dir.path().join("target.txt");
10665 std::fs::write(&source_file, "new content").unwrap();
10666 std::fs::write(&target_file, "old content").unwrap();
10667
10668 let state = test_state();
10669 let mut registry = ProviderRegistry::new();
10670 registry.default_file_strategy = crate::config::FileStrategy::Copy;
10671
10672 let reconciler = Reconciler::new(®istry, &state);
10673 let resolved = make_empty_resolved();
10674
10675 let plan = Plan {
10676 phases: vec![Phase {
10677 name: PhaseName::Modules,
10678 actions: vec![Action::Module(ModuleAction {
10679 module_name: "overmod".to_string(),
10680 kind: ModuleActionKind::DeployFiles {
10681 files: vec![ResolvedFile {
10682 source: source_file.clone(),
10683 target: target_file.clone(),
10684 is_git_source: false,
10685 strategy: Some(crate::config::FileStrategy::Copy),
10686 encryption: None,
10687 }],
10688 },
10689 })],
10690 }],
10691 warnings: vec![],
10692 };
10693
10694 let modules = vec![ResolvedModule {
10695 name: "overmod".to_string(),
10696 packages: vec![],
10697 files: vec![],
10698 env: vec![],
10699 aliases: vec![],
10700 post_apply_scripts: vec![],
10701 pre_apply_scripts: Vec::new(),
10702 pre_reconcile_scripts: Vec::new(),
10703 post_reconcile_scripts: Vec::new(),
10704 on_change_scripts: Vec::new(),
10705 system: HashMap::new(),
10706 depends: vec![],
10707 dir: dir.path().to_path_buf(),
10708 }];
10709
10710 let printer = test_printer();
10711 let result = reconciler
10712 .apply(
10713 &plan,
10714 &resolved,
10715 dir.path(),
10716 &printer,
10717 Some(&PhaseName::Modules),
10718 &modules,
10719 ReconcileContext::Apply,
10720 false,
10721 )
10722 .unwrap();
10723
10724 assert_eq!(result.status, ApplyStatus::Success);
10725 assert_eq!(
10726 std::fs::read_to_string(&target_file).unwrap(),
10727 "new content",
10728 "existing file should be overwritten"
10729 );
10730 }
10731
10732 #[test]
10735 #[cfg(unix)]
10736 fn apply_module_on_change_script_runs_when_module_has_changes() {
10737 let dir = tempfile::tempdir().unwrap();
10738 let source_file = dir.path().join("source.txt");
10739 let target_file = dir.path().join("target.txt");
10740 std::fs::write(&source_file, "content").unwrap();
10741 let marker = dir.path().join("onchange-ran");
10742
10743 let state = test_state();
10744 let mut registry = ProviderRegistry::new();
10745 registry.default_file_strategy = crate::config::FileStrategy::Copy;
10746
10747 let reconciler = Reconciler::new(®istry, &state);
10748 let resolved = make_empty_resolved();
10749
10750 let plan = Plan {
10751 phases: vec![Phase {
10752 name: PhaseName::Modules,
10753 actions: vec![Action::Module(ModuleAction {
10754 module_name: "changemod".to_string(),
10755 kind: ModuleActionKind::DeployFiles {
10756 files: vec![ResolvedFile {
10757 source: source_file.clone(),
10758 target: target_file.clone(),
10759 is_git_source: false,
10760 strategy: Some(crate::config::FileStrategy::Copy),
10761 encryption: None,
10762 }],
10763 },
10764 })],
10765 }],
10766 warnings: vec![],
10767 };
10768
10769 let modules = vec![ResolvedModule {
10770 name: "changemod".to_string(),
10771 packages: vec![],
10772 files: vec![],
10773 env: vec![],
10774 aliases: vec![],
10775 post_apply_scripts: vec![],
10776 pre_apply_scripts: Vec::new(),
10777 pre_reconcile_scripts: Vec::new(),
10778 post_reconcile_scripts: Vec::new(),
10779 on_change_scripts: vec![crate::config::ScriptEntry::Simple(format!(
10780 "touch {}",
10781 marker.display()
10782 ))],
10783 system: HashMap::new(),
10784 depends: vec![],
10785 dir: dir.path().to_path_buf(),
10786 }];
10787
10788 let printer = test_printer();
10789 let result = reconciler
10790 .apply(
10791 &plan,
10792 &resolved,
10793 dir.path(),
10794 &printer,
10795 None, &modules,
10797 ReconcileContext::Apply,
10798 false,
10799 )
10800 .unwrap();
10801
10802 assert_eq!(result.status, ApplyStatus::Success);
10803 assert!(
10804 marker.exists(),
10805 "module onChange script should have created marker file"
10806 );
10807 }
10808
10809 #[test]
10810 #[cfg(unix)]
10811 fn apply_module_on_change_script_does_not_run_when_no_changes() {
10812 let dir = tempfile::tempdir().unwrap();
10813 let marker = dir.path().join("onchange-ran");
10814
10815 let state = test_state();
10816 let registry = ProviderRegistry::new();
10817 let reconciler = Reconciler::new(®istry, &state);
10818 let resolved = make_empty_resolved();
10819
10820 let plan = Plan {
10822 phases: vec![],
10823 warnings: vec![],
10824 };
10825
10826 let modules = vec![ResolvedModule {
10827 name: "nochangemod".to_string(),
10828 packages: vec![],
10829 files: vec![],
10830 env: vec![],
10831 aliases: vec![],
10832 post_apply_scripts: vec![],
10833 pre_apply_scripts: Vec::new(),
10834 pre_reconcile_scripts: Vec::new(),
10835 post_reconcile_scripts: Vec::new(),
10836 on_change_scripts: vec![crate::config::ScriptEntry::Simple(format!(
10837 "touch {}",
10838 marker.display()
10839 ))],
10840 system: HashMap::new(),
10841 depends: vec![],
10842 dir: dir.path().to_path_buf(),
10843 }];
10844
10845 let printer = test_printer();
10846 let result = reconciler
10847 .apply(
10848 &plan,
10849 &resolved,
10850 dir.path(),
10851 &printer,
10852 None,
10853 &modules,
10854 ReconcileContext::Apply,
10855 false,
10856 )
10857 .unwrap();
10858
10859 assert_eq!(result.status, ApplyStatus::Success);
10860 assert!(
10861 !marker.exists(),
10862 "module onChange should NOT run when module had no changes"
10863 );
10864 }
10865
10866 #[test]
10869 fn rollback_restores_file_with_correct_content() {
10870 let dir = tempfile::tempdir().unwrap();
10871 let file_path = dir.path().join("managed.txt");
10872
10873 let state = test_state();
10874 let registry = ProviderRegistry::new();
10875 let reconciler = Reconciler::new(®istry, &state);
10876
10877 let file_state = crate::FileState {
10879 content: b"original content".to_vec(),
10880 content_hash: crate::sha256_hex(b"original content"),
10881 permissions: Some(0o644),
10882 is_symlink: false,
10883 symlink_target: None,
10884 oversized: false,
10885 };
10886 let apply_id_1 = state
10887 .record_apply("default", "plan-hash-1", ApplyStatus::InProgress, None)
10888 .unwrap();
10889 state
10890 .store_file_backup(apply_id_1, &file_path.display().to_string(), &file_state)
10891 .unwrap();
10892 state
10893 .update_apply_status(apply_id_1, ApplyStatus::Success, Some("{}"))
10894 .unwrap();
10895
10896 let new_state = crate::FileState {
10898 content: b"modified content".to_vec(),
10899 content_hash: crate::sha256_hex(b"modified content"),
10900 permissions: Some(0o644),
10901 is_symlink: false,
10902 symlink_target: None,
10903 oversized: false,
10904 };
10905 let apply_id_2 = state
10906 .record_apply("default", "plan-hash-2", ApplyStatus::InProgress, None)
10907 .unwrap();
10908 state
10909 .store_file_backup(apply_id_2, &file_path.display().to_string(), &new_state)
10910 .unwrap();
10911 state
10912 .update_apply_status(apply_id_2, ApplyStatus::Success, Some("{}"))
10913 .unwrap();
10914
10915 std::fs::write(&file_path, "modified content").unwrap();
10917
10918 let printer = test_printer();
10919 let result = reconciler.rollback_apply(apply_id_1, &printer).unwrap();
10920
10921 assert!(
10922 result.files_restored > 0,
10923 "should restore at least one file"
10924 );
10925 assert_eq!(
10926 std::fs::read_to_string(&file_path).unwrap(),
10927 "original content",
10928 "file should be restored to apply-1 state"
10929 );
10930 }
10931
10932 #[test]
10935 fn rollback_removes_file_created_after_target_apply() {
10936 let dir = tempfile::tempdir().unwrap();
10937 let created_file = dir.path().join("new-file.txt");
10938
10939 let state = test_state();
10940 let registry = ProviderRegistry::new();
10941 let reconciler = Reconciler::new(®istry, &state);
10942
10943 let apply_id_1 = state
10945 .record_apply("default", "hash-1", ApplyStatus::InProgress, None)
10946 .unwrap();
10947 state
10948 .update_apply_status(apply_id_1, ApplyStatus::Success, None)
10949 .unwrap();
10950
10951 let apply_id_2 = state
10953 .record_apply("default", "hash-2", ApplyStatus::InProgress, None)
10954 .unwrap();
10955 let j_id = state
10956 .journal_begin(
10957 apply_id_2,
10958 0,
10959 "files",
10960 "file",
10961 &format!("file:create:{}", created_file.display()),
10962 None,
10963 )
10964 .unwrap();
10965 state.journal_complete(j_id, None, None).unwrap();
10966 state
10967 .update_apply_status(apply_id_2, ApplyStatus::Success, None)
10968 .unwrap();
10969
10970 std::fs::write(&created_file, "new content").unwrap();
10972 assert!(created_file.exists());
10973
10974 let printer = test_printer();
10976 let result = reconciler.rollback_apply(apply_id_1, &printer).unwrap();
10977
10978 assert!(
10979 !created_file.exists(),
10980 "file created after target apply should be removed"
10981 );
10982 assert!(
10983 result.files_removed > 0,
10984 "files_removed should reflect the deletion"
10985 );
10986 }
10987
10988 #[test]
10989 fn rollback_keeps_file_that_existed_at_target_apply() {
10990 let dir = tempfile::tempdir().unwrap();
10991 let existing_file = dir.path().join("existing.txt");
10992
10993 let state = test_state();
10994 let registry = ProviderRegistry::new();
10995 let reconciler = Reconciler::new(®istry, &state);
10996
10997 let apply_id_1 = state
10999 .record_apply("default", "hash-1", ApplyStatus::InProgress, None)
11000 .unwrap();
11001 let j_id = state
11002 .journal_begin(
11003 apply_id_1,
11004 0,
11005 "files",
11006 "file",
11007 &format!("file:create:{}", existing_file.display()),
11008 None,
11009 )
11010 .unwrap();
11011 state.journal_complete(j_id, None, None).unwrap();
11012 let file_state = crate::FileState {
11014 content: b"original".to_vec(),
11015 content_hash: crate::sha256_hex(b"original"),
11016 permissions: Some(0o644),
11017 is_symlink: false,
11018 symlink_target: None,
11019 oversized: false,
11020 };
11021 state
11022 .store_file_backup(
11023 apply_id_1,
11024 &existing_file.display().to_string(),
11025 &file_state,
11026 )
11027 .unwrap();
11028 state
11029 .update_apply_status(apply_id_1, ApplyStatus::Success, None)
11030 .unwrap();
11031
11032 let apply_id_2 = state
11034 .record_apply("default", "hash-2", ApplyStatus::InProgress, None)
11035 .unwrap();
11036 let j_id = state
11037 .journal_begin(
11038 apply_id_2,
11039 0,
11040 "files",
11041 "file",
11042 &format!("file:create:{}", existing_file.display()),
11043 None,
11044 )
11045 .unwrap();
11046 state.journal_complete(j_id, None, None).unwrap();
11047 state
11048 .update_apply_status(apply_id_2, ApplyStatus::Success, None)
11049 .unwrap();
11050
11051 std::fs::write(&existing_file, "modified").unwrap();
11053
11054 let printer = test_printer();
11056 let result = reconciler.rollback_apply(apply_id_1, &printer).unwrap();
11057
11058 assert!(
11059 existing_file.exists(),
11060 "file that existed at target apply should NOT be removed"
11061 );
11062 assert_eq!(
11063 std::fs::read_to_string(&existing_file).unwrap(),
11064 "original",
11065 "file should be restored to target apply state"
11066 );
11067 assert!(result.files_restored > 0);
11068 }
11069
11070 #[test]
11071 fn rollback_collects_non_file_actions_from_subsequent_applies() {
11072 let state = test_state();
11073 let registry = ProviderRegistry::new();
11074 let reconciler = Reconciler::new(®istry, &state);
11075
11076 let apply_id_1 = state
11078 .record_apply("default", "hash-1", ApplyStatus::InProgress, None)
11079 .unwrap();
11080 state
11081 .update_apply_status(apply_id_1, ApplyStatus::Success, None)
11082 .unwrap();
11083
11084 let apply_id_2 = state
11086 .record_apply("default", "hash-2", ApplyStatus::InProgress, None)
11087 .unwrap();
11088 let j1 = state
11089 .journal_begin(apply_id_2, 0, "Packages", "install", "brew:ripgrep", None)
11090 .unwrap();
11091 state.journal_complete(j1, None, None).unwrap();
11092 let j2 = state
11093 .journal_begin(
11094 apply_id_2,
11095 1,
11096 "PostScripts",
11097 "script",
11098 "script:post:setup.sh",
11099 None,
11100 )
11101 .unwrap();
11102 state.journal_complete(j2, None, None).unwrap();
11103 state
11104 .update_apply_status(apply_id_2, ApplyStatus::Success, None)
11105 .unwrap();
11106
11107 let printer = test_printer();
11109 let result = reconciler.rollback_apply(apply_id_1, &printer).unwrap();
11110
11111 assert!(
11113 result
11114 .non_file_actions
11115 .contains(&"brew:ripgrep".to_string()),
11116 "should list package action for manual review: {:?}",
11117 result.non_file_actions
11118 );
11119 assert!(
11120 result
11121 .non_file_actions
11122 .contains(&"script:post:setup.sh".to_string()),
11123 "should list script action for manual review: {:?}",
11124 result.non_file_actions
11125 );
11126 }
11127
11128 #[test]
11131 fn verify_system_configurator_reports_drift() {
11132 struct DriftingConfigurator;
11133
11134 impl crate::providers::SystemConfigurator for DriftingConfigurator {
11135 fn name(&self) -> &str {
11136 "sysctl"
11137 }
11138 fn is_available(&self) -> bool {
11139 true
11140 }
11141 fn current_state(&self) -> crate::errors::Result<serde_yaml::Value> {
11142 Ok(serde_yaml::Value::Null)
11143 }
11144 fn diff(
11145 &self,
11146 _: &serde_yaml::Value,
11147 ) -> crate::errors::Result<Vec<crate::providers::SystemDrift>> {
11148 Ok(vec![
11149 crate::providers::SystemDrift {
11150 key: "vm.swappiness".to_string(),
11151 expected: "10".to_string(),
11152 actual: "60".to_string(),
11153 },
11154 crate::providers::SystemDrift {
11155 key: "net.ipv4.ip_forward".to_string(),
11156 expected: "1".to_string(),
11157 actual: "0".to_string(),
11158 },
11159 ])
11160 }
11161 fn apply(&self, _: &serde_yaml::Value, _: &Printer) -> crate::errors::Result<()> {
11162 Ok(())
11163 }
11164 }
11165
11166 let state = test_state();
11167 let mut registry = ProviderRegistry::new();
11168 registry
11169 .system_configurators
11170 .push(Box::new(DriftingConfigurator));
11171
11172 let mut system = HashMap::new();
11173 system.insert(
11174 "sysctl".to_string(),
11175 serde_yaml::to_value(serde_yaml::Mapping::new()).unwrap(),
11176 );
11177 let merged = crate::config::MergedProfile {
11178 system,
11179 ..Default::default()
11180 };
11181 let resolved = crate::config::ResolvedProfile {
11182 layers: vec![crate::config::ProfileLayer {
11183 source: "local".to_string(),
11184 profile_name: "default".to_string(),
11185 priority: 0,
11186 policy: crate::config::LayerPolicy::Local,
11187 spec: Default::default(),
11188 }],
11189 merged,
11190 };
11191
11192 let printer = test_printer();
11193 let results = verify(&resolved, ®istry, &state, &printer, &[]).unwrap();
11194
11195 let drift_results: Vec<_> = results
11197 .iter()
11198 .filter(|r| r.resource_type == "system" && !r.matches)
11199 .collect();
11200 assert_eq!(
11201 drift_results.len(),
11202 2,
11203 "should report drift for each sysctl key, got: {:?}",
11204 drift_results
11205 );
11206 assert!(
11207 drift_results
11208 .iter()
11209 .any(|r| r.resource_id == "sysctl.vm.swappiness"),
11210 "should report sysctl.vm.swappiness drift"
11211 );
11212 assert!(
11213 drift_results
11214 .iter()
11215 .any(|r| r.resource_id == "sysctl.net.ipv4.ip_forward"),
11216 "should report sysctl.net.ipv4.ip_forward drift"
11217 );
11218 let swap = drift_results
11220 .iter()
11221 .find(|r| r.resource_id == "sysctl.vm.swappiness")
11222 .unwrap();
11223 assert_eq!(swap.expected, "10");
11224 assert_eq!(swap.actual, "60");
11225 }
11226
11227 #[test]
11228 fn verify_system_configurator_reports_healthy_when_no_drift() {
11229 struct HealthyConfigurator;
11230
11231 impl crate::providers::SystemConfigurator for HealthyConfigurator {
11232 fn name(&self) -> &str {
11233 "sysctl"
11234 }
11235 fn is_available(&self) -> bool {
11236 true
11237 }
11238 fn current_state(&self) -> crate::errors::Result<serde_yaml::Value> {
11239 Ok(serde_yaml::Value::Null)
11240 }
11241 fn diff(
11242 &self,
11243 _: &serde_yaml::Value,
11244 ) -> crate::errors::Result<Vec<crate::providers::SystemDrift>> {
11245 Ok(vec![])
11246 }
11247 fn apply(&self, _: &serde_yaml::Value, _: &Printer) -> crate::errors::Result<()> {
11248 Ok(())
11249 }
11250 }
11251
11252 let state = test_state();
11253 let mut registry = ProviderRegistry::new();
11254 registry
11255 .system_configurators
11256 .push(Box::new(HealthyConfigurator));
11257
11258 let mut system = HashMap::new();
11259 system.insert(
11260 "sysctl".to_string(),
11261 serde_yaml::to_value(serde_yaml::Mapping::new()).unwrap(),
11262 );
11263 let merged = crate::config::MergedProfile {
11264 system,
11265 ..Default::default()
11266 };
11267 let resolved = crate::config::ResolvedProfile {
11268 layers: vec![crate::config::ProfileLayer {
11269 source: "local".to_string(),
11270 profile_name: "default".to_string(),
11271 priority: 0,
11272 policy: crate::config::LayerPolicy::Local,
11273 spec: Default::default(),
11274 }],
11275 merged,
11276 };
11277
11278 let printer = test_printer();
11279 let results = verify(&resolved, ®istry, &state, &printer, &[]).unwrap();
11280
11281 let sysctl_results: Vec<_> = results
11282 .iter()
11283 .filter(|r| r.resource_type == "system")
11284 .collect();
11285 assert_eq!(
11286 sysctl_results.len(),
11287 1,
11288 "should have one healthy result for sysctl"
11289 );
11290 assert!(
11291 sysctl_results[0].matches,
11292 "sysctl should report as matching (no drift)"
11293 );
11294 assert_eq!(sysctl_results[0].resource_id, "sysctl");
11295 }
11296}