Skip to main content

cfgd_core/reconciler/
mod.rs

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/// Whether the reconciler is running in CLI apply mode or daemon reconcile mode.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum ReconcileContext {
19    Apply,
20    Reconcile,
21}
22
23/// Ordered reconciliation phases.
24#[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/// Environment file action — write ~/.cfgd.env or inject source line into shell rc.
83#[derive(Debug, Serialize)]
84pub enum EnvAction {
85    /// Write the generated env file (bash/zsh or fish).
86    WriteEnvFile {
87        path: std::path::PathBuf,
88        content: String,
89    },
90    /// Inject a source line into a shell rc file (idempotent).
91    InjectSourceLine {
92        rc_path: std::path::PathBuf,
93        line: String,
94    },
95}
96
97/// A unified action across all resource types.
98#[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/// Module-level action — first-class phase, not flattened into packages/files.
110#[derive(Debug, Serialize)]
111pub struct ModuleAction {
112    pub module_name: String,
113    pub kind: ModuleActionKind,
114}
115
116/// What kind of module action to take.
117#[derive(Debug, Serialize)]
118pub enum ModuleActionKind {
119    /// Install/update packages resolved from a module.
120    InstallPackages {
121        resolved: Vec<crate::modules::ResolvedPackage>,
122    },
123    /// Deploy files from a module.
124    DeployFiles {
125        files: Vec<crate::modules::ResolvedFile>,
126    },
127    /// Run a module lifecycle script.
128    RunScript {
129        script: ScriptEntry,
130        phase: ScriptPhase,
131    },
132    /// Skip a module (dependency not met, user declined, etc.).
133    Skip { reason: String },
134}
135
136/// System configuration action.
137#[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/// Script execution action.
154#[derive(Debug, Serialize)]
155pub enum ScriptAction {
156    Run {
157        entry: ScriptEntry,
158        phase: ScriptPhase,
159        origin: String,
160    },
161}
162
163/// When a script runs relative to reconciliation.
164#[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/// A phase in the reconciliation plan.
188#[derive(Debug, Serialize)]
189pub struct Phase {
190    pub name: PhaseName,
191    pub actions: Vec<Action>,
192}
193
194/// A complete reconciliation plan.
195#[derive(Debug, Serialize)]
196pub struct Plan {
197    pub phases: Vec<Phase>,
198    /// Warnings about shell rc conflicts (env/alias defined before cfgd source line).
199    #[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    /// Serialize the plan to a stable string for hashing.
213    /// Uses serde_json serialization instead of Debug formatting for stability
214    /// across compiler versions.
215    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/// Result of applying a single action.
229#[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/// Result of an entire apply operation.
239#[derive(Debug, Serialize)]
240pub struct ApplyResult {
241    pub action_results: Vec<ActionResult>,
242    pub status: ApplyStatus,
243    /// The apply_id in the state store — used for rollback.
244    pub apply_id: i64,
245}
246
247/// Result of a rollback operation.
248#[derive(Debug, Serialize)]
249pub struct RollbackResult {
250    pub files_restored: usize,
251    pub files_removed: usize,
252    /// Non-file actions that were not rolled back (require manual review).
253    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
266/// The unified reconciler. Generates plans and applies them.
267pub 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    /// Generate a reconciliation plan.
278    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        // Conflict detection: check for multiple sources targeting the same path
287        Self::detect_file_conflicts(&file_actions, &module_actions)?;
288
289        let mut phases = Vec::new();
290
291        // Phase 0: PreScripts — pre-apply or pre-reconcile hooks.
292        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        // Phase 1: Env — write ~/.cfgd.env and inject shell rc source line.
300        // Runs early so that env vars (including PATH for bootstrapped managers)
301        // are available to all subsequent phases.
302        let (env_actions, warnings) = Self::plan_env(
303            &resolved.merged.env,
304            &resolved.merged.aliases,
305            &module_actions,
306            &[], // Secret envs are not yet resolved at plan time; they are
307                 // injected during the apply phase after ResolveEnv actions run.
308        );
309        phases.push(Phase {
310            name: PhaseName::Env,
311            actions: env_actions,
312        });
313
314        // Phase 2: Modules — module packages, files, and post-apply scripts.
315        // Packages are grouped with system/native managers first, then
316        // bootstrappable managers, so build deps are installed before
317        // packages that need them.
318        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        // Phase 3: Packages — profile-level packages, installed after modules
325        // so module deps are available.
326        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        // Phase 4: System — runs after packages so required binaries exist
333        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        // Phase 5: Files
340        let fa = file_actions.into_iter().map(Action::File).collect();
341        phases.push(Phase {
342            name: PhaseName::Files,
343            actions: fa,
344        });
345
346        // Phase 6: Secrets
347        let secret_actions = self.plan_secrets(&resolved.merged);
348        phases.push(Phase {
349            name: PhaseName::Secrets,
350            actions: secret_actions,
351        });
352
353        // Phase 7: PostScripts — post-apply or post-reconcile hooks.
354        phases.push(Phase {
355            name: PhaseName::PostScripts,
356            actions: post_script_actions,
357        });
358
359        Ok(Plan { phases, warnings })
360    }
361
362    /// Check for file target conflicts across profile files and module files.
363    /// Two sources targeting the same path with identical content is allowed;
364    /// different content is an error.
365    fn detect_file_conflicts(
366        file_actions: &[FileAction],
367        modules: &[ResolvedModule],
368    ) -> Result<()> {
369        // Map of target path → (source description, content hash)
370        let mut targets: HashMap<PathBuf, (String, Option<String>)> = HashMap::new();
371
372        // Collect from profile file actions
373        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        // Collect from module file deploy actions
396        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        // Build effective system map: start from profile, deep-merge each module in order.
425        // Module values override profile values at leaf level (consistent with env/aliases).
426        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        // Check for system keys with no registered configurator
454        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    /// Plan env file generation from merged profile + module env vars and aliases.
473    /// Returns (actions, warnings) — warnings for shell rc conflicts.
474    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        // Append secret-backed env vars after regular envs.
495        // These are resolved secret values injected into the env file.
496        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            // PowerShell env file — always generated on Windows
511            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            // Inject dot-source line into PowerShell profiles
519            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 Git Bash is available, also generate bash env file
532            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            // No rc conflict detection on Windows
547            Vec::new()
548        } else {
549            // Unix: bash/zsh env file + source line
550            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            // Check for conflicts with existing definitions in the shell rc file
569            detect_rc_env_conflicts(&rc_path, &merged, &merged_aliases)
570        };
571
572        // Fish shell: only generate fish env if fish is the user's shell
573        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(); // OK: file may not exist yet
579            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            // Check if it's a provider reference
604            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                    // File-targeting action when a target path is set
615                    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                    // Env injection action when envs are specified
625                    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 neither target nor envs, skip (shouldn't happen due to validation)
635                    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                // SOPS/age encrypted file — only for file targets
651                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                // Env-only secret without a provider reference — SOPS can't resolve
674                // individual values for env injection
675                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            // Select pre/post scripts based on context
742            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            // Pre-scripts for this module
758            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            // Packages: group by manager for efficient batch install
769            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            // Sort managers: system/native managers first (apt, dnf, pacman, etc.),
779            // then bootstrappable managers (brew, snap). This ensures build dependencies
780            // are installed before packages that might need them.
781            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,  // available (native) first
790                    Some(m) if m.can_bootstrap() => 1, // bootstrappable second
791                    _ => 2,                            // unknown last
792                }
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            // Files — validate encryption requirements before deploying
806            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            // Post-scripts for this module
877            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    /// Update module state in state.db after a successful apply.
892    fn update_module_state(
893        &self,
894        modules: &[ResolvedModule],
895        apply_id: i64,
896        results: &[ActionResult],
897    ) -> Result<()> {
898        for module in modules {
899            // Check if any module action for this module failed
900            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            // Hash the resolved packages list
907            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            // Hash the file targets
925            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            // Collect git source info
936            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    /// Apply a plan, executing each phase in order.
966    /// Failed actions are logged and skipped — they don't abort the entire apply.
967    #[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        // Record apply up front as "in-progress" so the journal can reference it
980        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                // Capture file state before overwrite (for backup)
1011                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                // Journal: record action start
1023                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                        // Check if this is a script action with continueOnError
1061                        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                // If a pre-script failed without continueOnError, abort
1109                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        // --- Secret env injection: re-generate env files with resolved secret env vars ---
1129        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        // --- onChange detection: run profile onChange scripts if anything changed ---
1165        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        // --- Module-level onChange: run per-module onChange scripts if that module had changes ---
1217        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        // Update apply status from "in-progress" placeholder to final
1290        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        // Update managed resources
1300        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        // Update module state and file manifests for successfully applied modules
1310        self.update_module_state(module_actions, apply_id, &results)?;
1311
1312        // Post-apply snapshot: capture the resolved content of all managed file
1313        // targets (following symlinks). This ensures rollback can restore the
1314        // exact content visible at this point, even for symlink-deployed files
1315        // where the source may be modified in-place between applies.
1316        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    /// Roll back completed file actions from a previous apply.
1354    ///
1355    /// Restores files from backups in reverse order. Newly created files (no backup)
1356    /// are deleted. Package installs and system changes are NOT rolled back — they
1357    /// are listed in the output as requiring manual review.
1358    pub fn rollback_apply(&self, apply_id: i64, printer: &Printer) -> Result<RollbackResult> {
1359        // Rollback restores the system to the state that existed AFTER the target apply.
1360        //
1361        // Primary source: post-apply snapshots stored with the target apply_id.
1362        // These capture the resolved content of all managed files (following symlinks)
1363        // at the moment the target apply completed. For each file path, the LAST
1364        // backup entry (highest id) for the target apply is the post-apply snapshot.
1365        //
1366        // Fallback: for files not covered by the target apply's snapshots, use the
1367        // earliest backup from applies AFTER the target (pre-action backups from
1368        // later applies, which represent the state right after the target).
1369        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        // Build a map of file_path -> last backup from the target apply
1374        // (last = post-apply snapshot, which has the highest id)
1375        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        // Collect non-file actions from subsequent applies
1385        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        // Track which file paths we've already restored (avoid duplicate restores)
1395        let mut restored_paths = std::collections::HashSet::new();
1396
1397        // Phase 1: restore from target apply's post-apply snapshots
1398        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        // Phase 2: fallback to earliest backup after target for remaining paths
1410        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        // Phase 3: handle files created by subsequent applies but not in target's snapshot
1425        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            // If the file is in the target apply's snapshot, it was already handled in phase 1.
1446            // If not, check the journal to see if it existed at the target apply.
1447            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        // Record rollback as a new apply
1475        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                    // Already injected
1562                    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                // Find in ALL managers (not just available — it isn't available yet)
1619                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            // Fallback: use CfgdFileManager directly via the existing files module logic
1693            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                // Each secret source resolves to exactly ONE value.
1801                // All env names in `envs` receive the same resolved value.
1802                // Expose the secret at the boundary where we need the plaintext for env injection.
1803                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        // Find the module dir from the resolved modules list
1877        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                // Packages in each InstallPackages action are already grouped by
1885                // manager in plan_modules(), so just collect names and install.
1886                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                        // Script-based install: run each package's script via execute_script
1891                        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                        // Find the manager — check all registered, not just available
1928                        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                            // Bootstrap if needed
1936                            if !pm.is_available() && pm.can_bootstrap() {
1937                                pm.bootstrap(printer)?;
1938
1939                                // Persist bootstrapped manager's PATH to ~/.cfgd.env
1940                                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                            // Update package index before installing
1977                            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                    // Use the per-file strategy override if set, otherwise
2000                    // fall back to the global file-strategy from cfgd.yaml (default: symlink).
2001                    let strategy = file.strategy.unwrap_or(self.registry.default_file_strategy);
2002
2003                    // Backup existing target before overwriting
2004                    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                    // Remove existing target before deploying
2015                    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                    // Record in module file manifest
2049                    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
2116/// Verify all managed resources match their desired state.
2117pub 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    // Verify modules — check that module packages are installed
2127    // Cache installed-packages per manager to avoid N+1 queries
2128    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            // Script-based packages can't be verified via installed_packages() —
2135            // trust the apply log (if the script succeeded, it's installed).
2136            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        // Check module file targets exist
2173        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    // Verify packages
2208    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    // Verify system configurators
2244    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    // Verify files by checking managed file targets exist with expected content
2280    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: check ~/.cfgd.env matches expected content
2302    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/// Result of verifying a single resource.
2314#[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
2337/// Verify env file and shell rc source line match expected state.
2338// NOTE: Secret-backed env vars (from SecretSpec.envs) are not included in
2339// verification because they require provider resolution. This means cfgd status
2340// may report env file drift after secret envs are written. This will be addressed
2341// when compliance snapshots track secret env metadata.
2342fn 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        // Verify PowerShell env file
2357        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        // Verify PowerShell profile injection
2362        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 Git Bash available, also verify bash env file
2396        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        // Unix: verify bash/zsh env file
2403        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        // Check shell rc source line
2408        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    // Check fish env file only if fish is the user's shell
2443    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
2452/// Verify a single env file's content matches expected.
2453fn 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
2508// ---------------------------------------------------------------------------
2509// Unified script executor
2510// ---------------------------------------------------------------------------
2511
2512/// Default timeout for module-level scripts.
2513const 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
2516/// Build environment variables injected into every script invocation.
2517pub(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
2551/// Unified script executor for all hook types at both profile and module level.
2552///
2553/// Returns (description, changed, captured_output). All scripts set changed=true.
2554pub(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        // File path — check executable bit, run directly (OS handles shebang)
2588        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        // Inline command — pass through sh -c on Unix, cmd.exe /C on Windows
2612        #[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); // New process group so we can kill all children
2620            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    // Inject environment variables
2632    for (key, value) in env_vars {
2633        cmd.env(key, value);
2634    }
2635
2636    let label = format!("Running script: {}", run_str);
2637
2638    // Execute with timeout
2639    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    // Spinner with live output display (same pattern as Printer::run_with_progress)
2646    let pb = printer.spinner(&label);
2647
2648    // Channel for live display + Arc buffers for final capture.
2649    // Reader threads feed both so we get live scrolling output AND full capture.
2650    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        // Drain any pending output lines and update the spinner display
2717        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                // Wait for reader threads to finish draining
2739                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                // Check absolute timeout
2775                if elapsed > effective_timeout {
2776                    kill_reason = Some(("timed out", effective_timeout));
2777                }
2778                // Check idle timeout (no output for N seconds)
2779                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                    // Join reader threads so we capture partial output
2791                    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
2818/// Kill a script's process group (SIGTERM + grace period + SIGKILL).
2819fn 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        // Negative PID targets the entire process group
2825        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
2836/// Default `continue_on_error` behavior per script phase.
2837/// Pre-hooks abort on failure; post-hooks, onChange, onDrift continue.
2838fn 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
2848/// Resolve the effective `continue_on_error` for a script entry in a given phase.
2849fn 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
2859/// Combine stdout and stderr into a single captured output string.
2860/// Returns `None` if both are empty.
2861fn 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
2880/// Format a human-readable description of an action.
2881pub 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
2971/// Outcome of a single file restoration during rollback.
2972enum RestoreOutcome {
2973    Restored,
2974    Removed,
2975    Skipped,
2976    Failed,
2977}
2978
2979/// Restore a single file from a backup record. Used by `rollback_apply`.
2980fn restore_file_from_backup(
2981    target: &std::path::Path,
2982    bk: &crate::state::FileBackupRecord,
2983    printer: &crate::output::Printer,
2984) -> RestoreOutcome {
2985    // Backup has content — write it (works for both regular files and symlink snapshots
2986    // where the resolved content was captured)
2987    if !bk.oversized && !bk.content.is_empty() {
2988        // Check if the current resolved content already matches the backup — skip if so
2989        if let Ok(Some(current)) = crate::capture_file_resolved_state(target)
2990            && current.content == bk.content
2991        {
2992            return RestoreOutcome::Skipped;
2993        }
2994        // Remove existing target (might be a symlink or regular file)
2995        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        // Restore permissions if recorded
3010        if let Some(mode) = bk.permissions {
3011            let _ = crate::set_file_permissions(target, mode);
3012        }
3013        return RestoreOutcome::Restored;
3014    }
3015
3016    // Symlink with no content (only link target recorded — legacy backup)
3017    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    // Empty content, not symlink, not oversized — file didn't exist before
3033    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
3048/// Extract the target file path from an action, if it writes to a file.
3049/// Used for pre-apply backup capture.
3050fn 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        // Module deploys multiple files — backup handled per-file in apply_module_action
3059        _ => None,
3060    }
3061}
3062
3063/// Generate bash/zsh env file content from merged env vars and aliases.
3064fn 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()); // trailing newline
3092    lines.join("\n")
3093}
3094
3095/// Generate fish env file content from merged env vars and aliases.
3096fn 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            // Fish uses space-separated list for PATH, not colon-separated.
3108            // Each part is single-quoted to prevent expansion.
3109            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            // Single-quote to prevent fish command substitution via ()
3117            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
3139/// Generate PowerShell env file content from merged env vars and aliases.
3140fn 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            // Value references other env vars — double-quote with PS escaping
3152            lines.push(format!(
3153                "$env:{} = \"{}\"",
3154                ev.name,
3155                ev.value.replace('"', "`\"")
3156            ));
3157        } else {
3158            // Single-quote prevents all PS interpolation
3159            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            // Simple alias — use Set-Alias
3173            lines.push(format!(
3174                "Set-Alias -Name {} -Value {}",
3175                alias.name, alias.command
3176            ));
3177        } else {
3178            // Complex alias — use function wrapper
3179            lines.push(format!(
3180                "function {} {{ {} @args }}",
3181                alias.name, alias.command
3182            ));
3183        }
3184    }
3185    lines.push(String::new()); // trailing newline
3186    lines.join("\n")
3187}
3188
3189/// Scan a shell rc file for `export` and `alias` definitions that appear before
3190/// the cfgd source line. If any match a cfgd-managed name with a different value,
3191/// return warnings advising the user to move the definition after the source line.
3192fn 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    // Only look at lines before the cfgd source line
3203    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    // Build lookup maps for cfgd-managed values
3215    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        // Match: export NAME=VALUE
3228        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        // Match: alias NAME=VALUE or alias NAME="VALUE"
3244        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
3263/// Strip surrounding single or double quotes from a shell value.
3264fn 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
3279/// Append source provenance suffix for non-local origins.
3280fn provenance_suffix(origin: &str) -> String {
3281    if origin.is_empty() || origin == "local" {
3282        String::new()
3283    } else {
3284        format!(" <- {origin}")
3285    }
3286}
3287
3288/// Format plan phase items for display.
3289pub 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
3465/// Format a module action for plan display.
3466fn format_module_action_item(action: &ModuleAction) -> String {
3467    match &action.kind {
3468        ModuleActionKind::InstallPackages { resolved } => {
3469            // Group by manager for display
3470            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            // Remove existing target before deploying
3557            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
3587// Allow FileAction to be cloned for the trait-based apply path
3588impl 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(&registry, &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(&registry, &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(&registry, &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(&registry, &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        // Pre-scripts phase should have the pre_reconcile script
3759        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        // Post-scripts phase should have the post_reconcile script
3767        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(&registry, &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        // Empty plan — no actions means success with 0 results
3807        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); // Skip items are now shown
3849        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, &registry, &state, &printer, &[]).unwrap();
3870
3871        // ripgrep should be present, bat should be missing
3872        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    // --- Module integration tests ---
3935
3936    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(&registry, &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        // Module phase should have at least 1 action (InstallPackages)
3964        assert!(!module_phase.actions.is_empty());
3965
3966        // Check that actions are ModuleAction
3967        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(&registry, &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(&registry, &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(&registry, &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        // node packages + nvim packages = 2 actions
4159        assert_eq!(module_phase.actions.len(), 2);
4160
4161        // First action should be for "node" (leaf dependency)
4162        match &module_phase.actions[0] {
4163            Action::Module(ma) => assert_eq!(ma.module_name, "node"),
4164            _ => panic!("expected Module action"),
4165        }
4166        // Second for "nvim"
4167        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        // Should show alias info for fd→fd-find
4204        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(&registry, &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        // Module state should be recorded
4309        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        // Update
4331        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        // List all
4348        let all = state.module_states().unwrap();
4349        assert_eq!(all.len(), 1);
4350
4351        // Remove
4352        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        // ripgrep is NOT installed — should drift
4362        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, &registry, &state, &printer, &modules).unwrap();
4371
4372        // Should have a drift result for ripgrep
4373        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        // nvim/neovim should not appear as drift since it's installed
4380        let ok = results
4381            .iter()
4382            .find(|r| r.resource_type == "module" && r.resource_id == "nvim/neovim");
4383        assert!(ok.is_none()); // no drift entry for installed packages
4384    }
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, &registry, &state, &printer, &modules).unwrap();
4436
4437        // All packages installed → should get a single "healthy" result
4438        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        // No drift entries
4446        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        // Script-based packages should not cause false drift reports since
4456        // "script" isn't a registered package manager in the registry.
4457        let state = test_state();
4458        let registry = ProviderRegistry::new(); // no managers
4459
4460        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, &registry, &state, &printer, &modules).unwrap();
4486
4487        // Script packages should be skipped in verification, so module should be healthy
4488        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        // No drift entries for script packages
4496        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(&registry, &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(&registry, &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        // Prove the identical-content check is meaningful: different content WOULD conflict
4706        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        // Prove this is meaningful: same target with different content WOULD conflict
4782        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"), // same as file_actions target
4788                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        // PATH contains $, so double-quoted to allow expansion
4825        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        // plan_env merges and generates actions — the merged env should have EDITOR=nvim
4878        let tmp = tempfile::tempdir().unwrap();
4879        let (actions, _warnings) =
4880            Reconciler::plan_env_with_home(&profile_env, &[], &modules, &[], tmp.path());
4881        // With non-empty env, there should be at least a WriteEnvFile action
4882        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        // Write the expected content to a temp file to simulate "already applied"
4896        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        // plan_env checks the real ~/.cfgd.env path, not our temp file,
4902        // so it will still generate actions. This test validates the content generation.
4903        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        // Find the WriteEnvFile action and check it has "nvim" not "vi"
4995        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    // --- Secret env injection tests ---
5022
5023    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(&registry, &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        // Should produce exactly one ResolveEnv action
5061        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(&registry, &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        // Should produce both a Resolve and a ResolveEnv action
5092        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        // With non-empty secret envs, there should be at least a WriteEnvFile action
5115        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(&regular_env, &[], &[], &secret_envs, tmp.path());
5132
5133        // Find the WriteEnvFile action and check its content
5134        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                // Secret envs should appear after regular envs
5146                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    // --- Shell rc conflict detection tests ---
5159
5160    #[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    // --- PowerShell env generation tests ---
5271
5272    #[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        // PATH references $env: so double-quoted to allow expansion
5288        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        // No $env: reference, so single-quoted (PS single quotes don't need escaping except ')
5317        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        // Only header + trailing newline
5325        assert_eq!(content.lines().count(), 1);
5326    }
5327
5328    // --- Apply execution path tests ---
5329
5330    /// A mock package manager that tracks which packages were installed/uninstalled.
5331    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(&registry, &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        // Verify install was actually called on the tracking mock
5450        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(&registry, &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(&registry, &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        // Verify the state store has a record
5545        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(&registry, &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        // First apply
5572        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        // Second apply
5586        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        // Each apply should get a unique, incrementing ID
5600        assert!(result2.apply_id > result1.apply_id);
5601
5602        // Verify via state store
5603        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        // Verify file was written
5633        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        // Pre-write identical content
5652        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        // Should report skipped
5663        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        // Pre-write content that already mentions cfgd.env
5690        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(&registry, &state);
5740        let resolved = make_empty_resolved();
5741
5742        // Plan: install ripgrep and fd via brew
5743        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        // Apply
5761        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        // State store should show the apply
5780        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        // Managed resources should be recorded
5786        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(&registry, &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        // Verify the summary JSON in the state store
5834        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(&registry, &state);
5852        let resolved = make_empty_resolved();
5853
5854        // Create a plan with package actions
5855        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        // Apply with filter set to Env phase — should skip Packages
5874        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        // No actions executed because Env phase is empty and Packages phase was filtered out
5889        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(&registry, &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        // Apply with filter set to Packages phase — should run the install
5922        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(&registry, &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        // Verify file was created
5991        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(&registry, &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        // Verify both managers had their install called
6053        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(&registry, &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        // Pre-hooks default to false (abort on failure)
6161        assert!(!super::default_continue_on_error(&ScriptPhase::PreApply));
6162        assert!(!super::default_continue_on_error(
6163            &ScriptPhase::PreReconcile
6164        ));
6165        // Post-hooks and event hooks default to true (continue on failure)
6166        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        // Should be true even for pre-apply (which defaults to false)
6183        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        // Should be false even for post-apply (which defaults to true)
6195        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(&registry, &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(&registry, &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        // Script prints once then sleeps forever — idle timeout should kill it
6501        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    // --- Rollback tests ---
6524
6525    #[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        // Rollback restores to the state AFTER the target apply.
6532        // Setup: apply 1 writes "v1 content", apply 2 modifies to "v2 content"
6533        // (capturing "v1 content" as backup). Rollback to apply 1 → "v1 content".
6534        let state = test_state();
6535
6536        // Apply 1: creates file with v1 content
6537        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        // Apply 2: modifies file to v2 content. Backup captures v1 content.
6548        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        // Rollback to apply 1 — should restore v1 content
6563        let registry = ProviderRegistry::new();
6564        let reconciler = Reconciler::new(&registry, &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        // Rollback to the most recent apply with no subsequent applies
6579        // should produce no changes (system is already at that state).
6580        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(&registry, &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        // Apply 2 has a package action (non-file) after apply 1
6603        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(&registry, &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(&registry, &state);
6638        let printer = test_printer();
6639        reconciler.rollback_apply(apply_id, &printer).unwrap();
6640
6641        // The rollback should have created a new apply entry
6642        let last = state.last_apply().unwrap().unwrap();
6643        assert_eq!(last.profile, "rollback");
6644        assert!(last.id > apply_id);
6645    }
6646
6647    // --- Partial apply tests ---
6648
6649    /// A package manager that always fails on install.
6650    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        // One working manager, one failing
6702        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(&registry, &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        // Verify state store records partial status
6754        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(&registry, &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    // --- continueOnError script tests ---
6810
6811    #[test]
6812    #[cfg(unix)]
6813    fn apply_continue_on_error_post_script_continues() {
6814        // A post-apply script with continueOnError=true should not abort the apply
6815        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(&registry, &state);
6822        let mut resolved = make_empty_resolved();
6823
6824        // Post-apply script that fails but has continueOnError=true
6825        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        // Package install succeeded, post-script failed but continued
6863        assert_eq!(result.status, ApplyStatus::Partial);
6864        assert_eq!(result.succeeded(), 1); // package install
6865        assert_eq!(result.failed(), 1); // failed post-script
6866
6867        // Verify the failed action is the script
6868        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        // A pre-apply script with continueOnError=false should abort the entire apply
6880        let state = test_state();
6881        let registry = ProviderRegistry::new();
6882        let reconciler = Reconciler::new(&registry, &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        // Pre-script failure with continueOnError=false should return an error
6915        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        // Post-apply scripts default to continueOnError=true (no explicit flag)
6927        let state = test_state();
6928        let registry = ProviderRegistry::new();
6929        let reconciler = Reconciler::new(&registry, &state);
6930
6931        let mut resolved = make_empty_resolved();
6932        // Simple entry — no explicit continueOnError, defaults to true for post phase
6933        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        // Post-script fails but default continueOnError=true means we get a result
6960        assert_eq!(result.status, ApplyStatus::Failed);
6961        assert_eq!(result.failed(), 1);
6962    }
6963
6964    // --- onChange script execution tests ---
6965
6966    #[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(&registry, &state);
6981        let mut resolved = make_empty_resolved();
6982
6983        // Set up an onChange script that creates a marker file
6984        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        // The file action should have triggered the onChange script
7022        assert!(
7023            marker.exists(),
7024            "onChange marker file should exist, proving the onChange script ran"
7025        );
7026
7027        // The file should have been deployed
7028        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(&registry, &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        // Empty plan — no file changes, no package changes
7047        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        // No changes occurred, so onChange should NOT have run
7073        assert!(
7074            !marker.exists(),
7075            "onChange marker should NOT exist when no changes occurred"
7076        );
7077    }
7078
7079    // --- Pure function / decision logic tests to cover uncovered lines ---
7080
7081    #[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(&registry, &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(); // no backend, no providers
7464        let reconciler = Reconciler::new(&registry, &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(); // no providers, no backend
7492        let reconciler = Reconciler::new(&registry, &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(); // no providers registered
7520        let reconciler = Reconciler::new(&registry, &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(&registry, &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        // Should produce a Decrypt action for the file target AND a Skip for env injection
7583        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(&registry, &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(&registry, &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        // Reconcile context should use pre/post reconcile scripts, not apply scripts
7651        let actions = reconciler.plan_modules(&modules, ReconcileContext::Reconcile);
7652        assert_eq!(actions.len(), 2); // pre-reconcile + post-reconcile
7653
7654        // First action should be pre-reconcile
7655        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        // Second action should be post-reconcile
7667        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        // Module targets the same path as Skip — should NOT conflict because
7827        // Skip/Delete actions are excluded from conflict detection
7828        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        // Prove this matters: if the Skip were a Create with different content, it WOULD conflict
7858        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        // Same content should give same hash
7885        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        // Module overrides profile: A=2 (module wins), B=3 (new)
7930        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        // Module overrides alias: g="git status" (module wins)
7934        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        // Single quotes in values are doubled in PS
7946        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(&registry, &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        // skip_scripts = true
8005        let result = reconciler
8006            .apply(
8007                &plan,
8008                &resolved,
8009                dir.path(),
8010                &printer,
8011                None,
8012                &[],
8013                ReconcileContext::Apply,
8014                true, // skip_scripts
8015            )
8016            .unwrap();
8017
8018        assert_eq!(result.status, ApplyStatus::Success);
8019        // onChange should NOT have run because skip_scripts=true
8020        assert!(
8021            !marker.exists(),
8022            "onChange should be skipped when skip_scripts=true"
8023        );
8024        // But the file action should still have been applied
8025        assert!(target.exists());
8026    }
8027
8028    // --- apply_package_action: Bootstrap path ---
8029
8030    /// A package manager that starts unavailable but becomes available after bootstrap.
8031    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(&registry, &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        // Manager should now be available
8133        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(); // no managers
8140        let reconciler = Reconciler::new(&registry, &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        // Should fail — unknown manager
8170        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(); // no managers
8179        let reconciler = Reconciler::new(&registry, &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(&registry, &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    // --- apply_secret_action: Decrypt, Resolve, ResolveEnv ---
8250
8251    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(&registry, &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        // Verify decrypted file was written
8326        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(); // no backend
8339
8340        let reconciler = Reconciler::new(&registry, &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(&registry, &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(); // no providers
8435
8436        let reconciler = Reconciler::new(&registry, &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(&registry, &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(); // no providers
8522
8523        let reconciler = Reconciler::new(&registry, &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(&registry, &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    // --- apply_file_action: Delete and SetPermissions ---
8596
8597    #[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(&registry, &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(&registry, &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        // Verify permissions
8692        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(&registry, &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(&registry, &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    // --- apply_system_action: SetValue and Skip ---
8790
8791    /// A mock system configurator that tracks apply calls.
8792    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(&registry, &state);
8838        let mut resolved = make_empty_resolved();
8839        // Put desired system config in the profile
8840        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(&registry, &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(); // no configurators
8930        let reconciler = Reconciler::new(&registry, &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    // --- apply_module_action: InstallPackages, DeployFiles, Skip ---
8954
8955    #[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(&registry, &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        // Verify install was called
9032        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(&registry, &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(&registry, &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, // uses default = Symlink
9137                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(&registry, &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(&registry, &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        // Manager should have been bootstrapped and package installed
9305        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    // --- rollback_apply: symlink restore (restore to state after target apply) ---
9315
9316    #[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        // Apply 1: creates the symlink
9328        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        // Apply 2: replaces symlink with a regular file. Backup captures symlink state.
9340        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        // Replace the symlink with a regular file (simulating apply 2)
9355        std::fs::remove_file(&target).unwrap();
9356        std::fs::write(&target, "replaced").unwrap();
9357        assert!(!target.is_symlink());
9358
9359        // Rollback to apply 1 — should restore the symlink
9360        let registry = ProviderRegistry::new();
9361        let reconciler = Reconciler::new(&registry, &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    // --- plan_modules: encryption validation ---
9376
9377    #[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(&registry, &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, // defaults to Symlink
9392                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        // Should produce a Skip action because encryption=Always + symlink is incompatible
9411        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        // Create a fake SOPS-encrypted file with required `mac` and `lastmodified` keys
9430        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(&registry, &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        // Should produce DeployFiles (encryption=Always + copy is OK, and file has sops marker)
9469        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        // Create a plaintext file (not encrypted)
9485        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(&registry, &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        // Should skip because file requires encryption but isn't encrypted
9520        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    // --- apply_script_action via apply() ---
9536
9537    #[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(&registry, &state);
9546        let mut resolved = make_empty_resolved();
9547
9548        // Post-apply script so it doesn't abort on failure
9549        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        // The script phase should have run
9577        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    // --- apply_module_action: RunScript ---
9587
9588    #[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(&registry, &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    // --- plan_env: Fish and PowerShell content generation ---
9656
9657    #[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        // Contains $env: so should be double-quoted
9688        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        // When an alias command contains a space, PowerShell generates a function instead of Set-Alias
9698        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        // Fish should split PATH values on :
9710        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    // --- build_script_env additional tests ---
9723
9724    #[test]
9725    fn build_script_env_all_phases() {
9726        // Verify that each ScriptPhase variant produces the correct CFGD_PHASE value
9727        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        // module_name provided but module_dir is None
9792        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        // Without module info, should have exactly 5 base vars
9812        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        // With both module name and dir, should have 7
9824        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    // --- verify additional tests ---
9841
9842    #[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, &registry, &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        // Create a file that exists
9865        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, &registry, &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, &registry, &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, &registry, &state, &printer, &modules).unwrap();
9946
9947        // Should have drift for the missing file
9948        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, &registry, &state, &printer, &modules).unwrap();
9995
9996        // Module should be healthy
9997        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        // Only "git" installed, "tmux" missing
10010        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, &registry, &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    // --- format_action_description additional tests ---
10041
10042    #[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    // --- PhaseName tests ---
10210
10211    #[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    // --- ScriptPhase display_name tests ---
10245
10246    #[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    // --- verify_env_file tests ---
10257
10258    #[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    // --- merge_module_env_aliases tests ---
10308
10309    #[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        // Check that both profile and module values are present
10354        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        // merge_env deduplicates by name, last wins
10387        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    // --- Module deploy files: hardlink strategy ---
10395
10396    #[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(&registry, &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        // Verify it's a hardlink by checking inode (Unix)
10475        #[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    // --- Module deploy files: copy strategy ---
10485
10486    #[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(&registry, &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        // Verify it's NOT a hardlink (independent copy)
10562        #[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    // --- Module deploy files: directory with symlink vs copy ---
10572
10573    #[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(&registry, &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    // --- Module deploy files: overwrites existing target ---
10659
10660    #[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(&registry, &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    // --- Module-level onChange script runs when module changes ---
10733
10734    #[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(&registry, &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, // no phase filter — run everything including onChange
10796                &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(&registry, &state);
10818        let resolved = make_empty_resolved();
10819
10820        // Empty plan — no actions, so no module changes
10821        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    // --- Rollback restores file to correct content ---
10867
10868    #[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(&registry, &state);
10876
10877        // Record a first apply with a file backup
10878        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        // Record a second apply that changed the file
10897        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        // Write the current file with apply-2 content
10916        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    // --- Rollback Phase 3: removes files created after target ---
10933
10934    #[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(&registry, &state);
10942
10943        // Apply 1: a simple apply that didn't touch new-file.txt
10944        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        // Apply 2: creates new-file.txt (file didn't exist before)
10952        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        // Write the file to disk (simulating what apply 2 did)
10971        std::fs::write(&created_file, "new content").unwrap();
10972        assert!(created_file.exists());
10973
10974        // Rollback to apply 1 — file didn't exist then, should be removed
10975        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(&registry, &state);
10996
10997        // Apply 1: creates existing.txt (journal records file:create:...)
10998        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        // Store backup so phase 1 handles it
11013        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        // Apply 2: updates existing.txt
11033        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        // Write current state
11052        std::fs::write(&existing_file, "modified").unwrap();
11053
11054        // Rollback to apply 1 — file existed at apply 1, should be restored not removed
11055        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(&registry, &state);
11075
11076        // Apply 1: base state
11077        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        // Apply 2: installs a package and runs a script
11085        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        // Rollback to apply 1
11108        let printer = test_printer();
11109        let result = reconciler.rollback_apply(apply_id_1, &printer).unwrap();
11110
11111        // Non-file actions from subsequent applies should be listed for manual review
11112        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    // --- Verify: system configurator drift detection ---
11129
11130    #[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, &registry, &state, &printer, &[]).unwrap();
11194
11195        // Should have per-key drift entries with resource_type "system"
11196        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        // Verify the expected/actual values are correct
11219        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, &registry, &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}