Skip to main content

dotm/
orchestrator.rs

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