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 let host = self
106 .loader
107 .load_host(hostname)
108 .with_context(|| format!("failed to load host config for '{hostname}'"))?;
109
110 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 merged_vars = vars::merge_vars(&merged_vars, &host.vars);
131
132 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 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 let role_names: Vec<&str> = host.roles.iter().map(|s| s.as_str()).collect();
145
146 let packages_dir = self.loader.packages_dir();
148 let mut pending: Vec<PendingAction> = Vec::new();
149
150 for pkg_name in &resolved {
151 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 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 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 let mut current_pkg: Option<String> = None;
235 let mut skip_pkg: Option<String> = None;
236
237 for p in &pending {
238 if current_pkg.as_deref() != Some(&p.pkg_name) {
240 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 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 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 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 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 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 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 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 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(), 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 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 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 if !dry_run && self.state_dir.is_some() {
578 state.save()?;
579 }
580
581 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
600pub 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}