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