Skip to main content

dotm/
orchestrator.rs

1use crate::deployer::{self, DeployResult};
2use crate::hash;
3use crate::loader::ConfigLoader;
4use crate::metadata;
5use crate::resolver;
6use crate::scanner;
7use crate::state::{DeployEntry, DeployState};
8use crate::template;
9use crate::vars;
10use anyhow::{Context, Result, bail};
11use std::collections::{HashMap, HashSet};
12use std::path::{Path, PathBuf};
13
14pub struct Orchestrator {
15    loader: ConfigLoader,
16    target_dir: PathBuf,
17    state_dir: Option<PathBuf>,
18    system_mode: bool,
19    package_filter: Option<String>,
20}
21
22#[derive(Debug, Default)]
23pub struct DeployReport {
24    pub created: Vec<PathBuf>,
25    pub updated: Vec<PathBuf>,
26    pub unchanged: Vec<PathBuf>,
27    pub conflicts: Vec<(PathBuf, String)>,
28    pub dry_run_actions: Vec<PathBuf>,
29    pub orphaned: Vec<PathBuf>,
30    pub pruned: Vec<PathBuf>,
31}
32
33struct PendingAction {
34    pkg_name: String,
35    action: scanner::FileAction,
36    pkg_target: PathBuf,
37    rendered: Option<String>,
38    is_system: bool,
39}
40
41impl Orchestrator {
42    pub fn new(dotfiles_dir: &Path, target_dir: &Path) -> Result<Self> {
43        let loader = ConfigLoader::new(dotfiles_dir)?;
44        Ok(Self {
45            loader,
46            target_dir: target_dir.to_path_buf(),
47            state_dir: None,
48            system_mode: false,
49            package_filter: None,
50        })
51    }
52
53    pub fn with_state_dir(mut self, state_dir: &Path) -> Self {
54        self.state_dir = Some(state_dir.to_path_buf());
55        self
56    }
57
58    pub fn with_system_mode(mut self, system: bool) -> Self {
59        self.system_mode = system;
60        self
61    }
62
63    pub fn with_package_filter(mut self, filter: Option<String>) -> Self {
64        self.package_filter = filter;
65        self
66    }
67
68    pub fn loader(&self) -> &ConfigLoader {
69        &self.loader
70    }
71
72    pub fn deploy(&mut self, hostname: &str, dry_run: bool, force: bool) -> Result<DeployReport> {
73        let mut report = DeployReport::default();
74        let mut state = self
75            .state_dir
76            .as_ref()
77            .map(|d| DeployState::new(d))
78            .unwrap_or_default();
79
80        if self.state_dir.is_some() {
81            state.lock()?;
82        }
83
84        // 1. Load host config and merge vars
85        let host = self
86            .loader
87            .load_host(hostname)
88            .with_context(|| format!("failed to load host config for '{hostname}'"))?;
89
90        let merged_vars = vars::resolve_vars(&self.loader, hostname)?;
91
92        // 2. Collect packages from roles
93        let mut all_requested_packages: Vec<String> = Vec::new();
94
95        for role_name in &host.roles {
96            let role = self
97                .loader
98                .load_role(role_name)
99                .with_context(|| format!("failed to load role '{role_name}'"))?;
100
101            for pkg in &role.packages {
102                if !all_requested_packages.contains(pkg) {
103                    all_requested_packages.push(pkg.clone());
104                }
105            }
106        }
107
108        // 3. Resolve dependencies
109        let requested_refs: Vec<&str> = all_requested_packages.iter().map(|s| s.as_str()).collect();
110        let mut resolved = resolver::resolve_packages(self.loader.root(), &requested_refs)?;
111
112        // 3.5. Apply package filter if set
113        if let Some(ref filter) = self.package_filter {
114            let filter_refs: Vec<&str> = vec![filter.as_str()];
115            let filtered = resolver::resolve_packages(self.loader.root(), &filter_refs)?;
116            resolved.retain(|pkg| filtered.contains(pkg));
117        }
118
119        // 4. Collect role names for override resolution
120        let role_names: Vec<&str> = host.roles.iter().map(|s| s.as_str()).collect();
121
122        // Phase 1: Scan all packages and collect pending actions
123        let packages_dir = self.loader.packages_dir();
124        let mut pending: Vec<PendingAction> = Vec::new();
125
126        for pkg_name in &resolved {
127            // Filter packages based on system mode
128            let is_system = self
129                .loader
130                .root()
131                .packages
132                .get(pkg_name)
133                .map(|c| c.system)
134                .unwrap_or(false);
135            if self.system_mode != is_system {
136                continue;
137            }
138
139            let pkg_dir = packages_dir.join(pkg_name);
140            if !pkg_dir.is_dir() {
141                eprintln!(
142                    "warning: package directory not found: {}",
143                    pkg_dir.display()
144                );
145                continue;
146            }
147
148            let actions = scanner::scan_package(&pkg_dir, hostname, &role_names)?;
149
150            let pkg_target = if let Some(pkg_config) = self.loader.root().packages.get(pkg_name) {
151                if let Some(ref target) = pkg_config.target {
152                    PathBuf::from(expand_path(target, Some(&format!("package '{pkg_name}'")))?)
153                } else {
154                    self.target_dir.clone()
155                }
156            } else {
157                self.target_dir.clone()
158            };
159
160            for action in actions {
161                let rendered = if action.kind == scanner::EntryKind::Template {
162                    let tmpl_content =
163                        std::fs::read_to_string(&action.source).with_context(|| {
164                            format!("failed to read template: {}", action.source.display())
165                        })?;
166                    Some(template::render_template(&tmpl_content, &merged_vars)?)
167                } else {
168                    None
169                };
170
171                pending.push(PendingAction {
172                    pkg_name: pkg_name.clone(),
173                    action,
174                    pkg_target: pkg_target.clone(),
175                    rendered,
176                    is_system,
177                });
178            }
179        }
180
181        // Phase 2: Target-path collision detection
182        let mut target_owners: HashMap<PathBuf, String> = HashMap::new();
183        for p in &pending {
184            let target_path = p.pkg_target.join(&p.action.target_rel_path);
185            if let Some(existing) = target_owners.get(&target_path) {
186                bail!(
187                    "target collision -- packages '{}' and '{}' both deploy {}",
188                    existing,
189                    p.pkg_name,
190                    target_path.display()
191                );
192            }
193            target_owners.insert(target_path, p.pkg_name.clone());
194        }
195
196        // Phase 3: Load existing state for drift detection
197        let existing_state = self
198            .state_dir
199            .as_ref()
200            .map(|d| DeployState::load(d))
201            .transpose()?
202            .unwrap_or_default();
203
204        let existing_hashes: HashMap<PathBuf, &str> = existing_state
205            .entries()
206            .iter()
207            .map(|e| (e.target.clone(), e.content_hash.as_str()))
208            .collect();
209
210        let existing_targets: HashSet<PathBuf> = existing_state
211            .entries()
212            .iter()
213            .map(|e| e.target.clone())
214            .collect();
215
216        // Phase 4: Deploy each action (with per-package hooks)
217        let mut current_pkg: Option<String> = None;
218        let mut skip_pkg: Option<String> = None;
219        let mut deploy_error: Option<anyhow::Error> = None;
220
221        for p in &pending {
222            let file_result: Result<()> = (|| {
223                // Run pre_deploy hook when entering a new package
224                if current_pkg.as_deref() != Some(&p.pkg_name) {
225                    // Run post_deploy for the previous package
226                    if let Some(ref prev_pkg) = current_pkg {
227                        if !dry_run {
228                            if let Some(pkg_config) = self.loader.root().packages.get(prev_pkg) {
229                                if let Some(ref cmd) = pkg_config.post_deploy {
230                                    let pkg_target = pending
231                                        .iter()
232                                        .find(|pp| pp.pkg_name == *prev_pkg)
233                                        .map(|pp| &pp.pkg_target)
234                                        .unwrap();
235                                    if let Err(e) =
236                                        crate::hooks::run_hook(cmd, pkg_target, prev_pkg, "deploy")
237                                    {
238                                        eprintln!("warning: {e}");
239                                    }
240                                }
241                            }
242                        }
243                    }
244
245                    // Run pre_deploy for the new package
246                    if !dry_run {
247                        if let Some(pkg_config) = self.loader.root().packages.get(&p.pkg_name) {
248                            if let Some(ref cmd) = pkg_config.pre_deploy {
249                                if let Err(e) = crate::hooks::run_hook(
250                                    cmd,
251                                    &p.pkg_target,
252                                    &p.pkg_name,
253                                    "deploy",
254                                ) {
255                                    eprintln!(
256                                        "warning: pre_deploy hook failed, skipping package '{}': {e}",
257                                        p.pkg_name
258                                    );
259                                    skip_pkg = Some(p.pkg_name.clone());
260                                    current_pkg = Some(p.pkg_name.clone());
261                                    return Ok(());
262                                }
263                            }
264                        }
265                    }
266                    skip_pkg = None;
267                    current_pkg = Some(p.pkg_name.clone());
268                }
269
270                // Skip all files for a package whose pre_deploy hook failed
271                if skip_pkg.as_deref() == Some(&p.pkg_name) {
272                    report.conflicts.push((
273                        p.pkg_target.join(&p.action.target_rel_path),
274                        "skipped: pre_deploy hook failed".to_string(),
275                    ));
276                    return Ok(());
277                }
278
279                let target_path = p.pkg_target.join(&p.action.target_rel_path);
280
281                // Determine if this is a user-mode symlink deployment
282                let use_symlink = !p.is_system
283                    && (p.action.kind == scanner::EntryKind::Base
284                        || p.action.kind == scanner::EntryKind::Override);
285
286                // Drift detection: only for copies (templates + system-mode files)
287                if !use_symlink && target_path.exists() {
288                    if let Some(&expected_hash) = existing_hashes.get(&target_path) {
289                        let current_hash = hash::hash_file(&target_path)?;
290                        if current_hash != expected_hash && !force {
291                            eprintln!(
292                                "warning: {} has been modified since last deploy, skipping (use --force to overwrite)",
293                                p.action.target_rel_path.display()
294                            );
295                            report
296                                .conflicts
297                                .push((target_path, "modified since last deploy".to_string()));
298                            return Ok(());
299                        }
300                    }
301                }
302
303                // Determine the effective force flag based on the orchestrator decision tree
304                let effective_force = if existing_targets.contains(&target_path) {
305                    // Target is in existing state -- managed re-deploy: skip backup
306                    true
307                } else if target_path.exists() && !target_path.is_symlink() && !target_path.is_dir()
308                {
309                    // Unmanaged regular file -- backup to originals, pass user's force value
310                    force
311                } else {
312                    // Symlink, directory, or nonexistent -- pass through
313                    force
314                };
315
316                let is_managed = existing_targets.contains(&target_path);
317
318                // Backup pre-existing file content and metadata before deploying
319                // Skip backup for managed re-deploys (preserve the original pre-dotm content)
320                let (original_hash, original_owner, original_group, original_mode) =
321                    if !dry_run && !is_managed && target_path.exists() && !target_path.is_symlink()
322                    {
323                        let content = std::fs::read(&target_path)?;
324                        let hash = hash::hash_content(&content);
325                        state.store_original(&hash, &content)?;
326
327                        let (owner, group, mode) = metadata::read_file_metadata(&target_path)?;
328                        (Some(hash), Some(owner), Some(group), Some(mode))
329                    } else {
330                        (None, None, None, None)
331                    };
332
333                // Deploy using the appropriate method
334                let result = if use_symlink {
335                    deployer::deploy_symlink(&p.action, &p.pkg_target, dry_run, effective_force)?
336                } else {
337                    deployer::deploy_copy(
338                        &p.action,
339                        &p.pkg_target,
340                        dry_run,
341                        effective_force,
342                        p.rendered.as_deref(),
343                    )?
344                };
345
346                match result {
347                    DeployResult::Created | DeployResult::Updated => {
348                        // For content_hash: hash the source file for symlinks, hash the target file for copies
349                        let content_hash = if !dry_run {
350                            if use_symlink {
351                                hash::hash_file(&p.action.source)?
352                            } else {
353                                hash::hash_file(&target_path)?
354                            }
355                        } else {
356                            String::new()
357                        };
358
359                        // Resolve and apply metadata (only for system-mode packages)
360                        let resolved = if !dry_run && p.is_system {
361                            if let Some(pkg_config) = self.loader.root().packages.get(&p.pkg_name) {
362                                let rel_path_str = p.action.target_rel_path.to_str().unwrap_or("");
363                                let resolved = metadata::resolve_metadata(pkg_config, rel_path_str);
364
365                                if resolved.owner.is_some() || resolved.group.is_some() {
366                                    if let Err(e) = metadata::apply_ownership(
367                                        &target_path,
368                                        resolved.owner.as_deref(),
369                                        resolved.group.as_deref(),
370                                    ) {
371                                        eprintln!(
372                                            "warning: failed to set ownership on {}: {e}",
373                                            target_path.display()
374                                        );
375                                    }
376                                }
377
378                                if let Some(ref mode) = resolved.mode {
379                                    deployer::apply_permission_override(&target_path, mode)?;
380                                }
381
382                                resolved
383                            } else {
384                                metadata::resolve_metadata(
385                                    &crate::config::PackageConfig::default(),
386                                    "",
387                                )
388                            }
389                        } else {
390                            metadata::resolve_metadata(&crate::config::PackageConfig::default(), "")
391                        };
392
393                        let abs_source = std::fs::canonicalize(&p.action.source)
394                            .unwrap_or_else(|_| p.action.source.clone());
395
396                        state.record(DeployEntry {
397                            target: target_path.clone(),
398                            staged: None,
399                            source: abs_source,
400                            content_hash,
401                            original_hash,
402                            kind: p.action.kind,
403                            package: p.pkg_name.clone(),
404                            owner: resolved.owner,
405                            group: resolved.group,
406                            mode: resolved.mode,
407                            original_owner,
408                            original_group,
409                            original_mode,
410                        });
411
412                        if matches!(result, DeployResult::Updated) {
413                            report.updated.push(target_path.clone());
414                        } else {
415                            report.created.push(target_path.clone());
416                        }
417                    }
418                    DeployResult::Conflict(msg) => {
419                        report.conflicts.push((target_path, msg));
420                    }
421                    DeployResult::DryRun => {
422                        report.dry_run_actions.push(target_path);
423                    }
424                    _ => {}
425                }
426
427                Ok(())
428            })();
429
430            if let Err(e) = file_result {
431                deploy_error = Some(e);
432                break;
433            }
434        }
435
436        // Run post_deploy for the final package
437        if let Some(ref last_pkg) = current_pkg {
438            if !dry_run && skip_pkg.as_deref() != Some(last_pkg) && deploy_error.is_none() {
439                if let Some(pkg_config) = self.loader.root().packages.get(last_pkg) {
440                    if let Some(ref cmd) = pkg_config.post_deploy {
441                        let pkg_target = pending
442                            .iter()
443                            .find(|pp| pp.pkg_name == *last_pkg)
444                            .map(|pp| &pp.pkg_target)
445                            .unwrap();
446                        if let Err(e) = crate::hooks::run_hook(cmd, pkg_target, last_pkg, "deploy")
447                        {
448                            eprintln!("warning: {e}");
449                        }
450                    }
451                }
452            }
453        }
454
455        // Phase 4.5: Detect orphaned files
456        if self.state_dir.is_some() {
457            let new_targets: std::collections::HashSet<PathBuf> = pending
458                .iter()
459                .map(|p| p.pkg_target.join(&p.action.target_rel_path))
460                .collect();
461
462            for old_entry in existing_state.entries() {
463                if !new_targets.contains(&old_entry.target) {
464                    report.orphaned.push(old_entry.target.clone());
465
466                    if !dry_run && self.loader.root().dotm.auto_prune {
467                        if old_entry.target.is_symlink() || old_entry.target.exists() {
468                            let _ = std::fs::remove_file(&old_entry.target);
469                            crate::state::cleanup_empty_parents(&old_entry.target);
470                        }
471                        report.pruned.push(old_entry.target.clone());
472                    }
473                }
474            }
475        }
476
477        // Phase 5: Save state (including partial state on error, so deployed files are tracked)
478        if !dry_run && self.state_dir.is_some() {
479            if deploy_error.is_some() {
480                if let Err(e) = state.save() {
481                    eprintln!("warning: failed to save partial state: {e}");
482                }
483            } else {
484                state.save()?;
485            }
486        }
487
488        if let Some(e) = deploy_error {
489            return Err(e);
490        }
491
492        Ok(report)
493    }
494}
495
496/// Expand shell variables and tilde in a path string.
497/// Errors if a referenced environment variable is not defined.
498pub fn expand_path(path: &str, context: Option<&str>) -> Result<String> {
499    shellexpand::full(path)
500        .map(|s| s.into_owned())
501        .map_err(|e| {
502            if let Some(ctx) = context {
503                anyhow::anyhow!("{ctx}: {e}")
504            } else {
505                anyhow::anyhow!("path expansion failed: {e}")
506            }
507        })
508}