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 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 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 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 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 let role_names: Vec<&str> = host.roles.iter().map(|s| s.as_str()).collect();
121
122 let packages_dir = self.loader.packages_dir();
124 let mut pending: Vec<PendingAction> = Vec::new();
125
126 for pkg_name in &resolved {
127 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 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 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 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 if current_pkg.as_deref() != Some(&p.pkg_name) {
225 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 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 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 let use_symlink = !p.is_system
283 && (p.action.kind == scanner::EntryKind::Base
284 || p.action.kind == scanner::EntryKind::Override);
285
286 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 let effective_force = if existing_targets.contains(&target_path) {
305 true
307 } else if target_path.exists() && !target_path.is_symlink() && !target_path.is_dir()
308 {
309 force
311 } else {
312 force
314 };
315
316 let is_managed = existing_targets.contains(&target_path);
317
318 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 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 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 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 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 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 if !dry_run && self.state_dir.is_some() {
479 if deploy_error.is_some() {
480 let deployed_targets: HashSet<PathBuf> =
482 state.entries().iter().map(|e| e.target.clone()).collect();
483 for old in existing_state.entries() {
484 if !deployed_targets.contains(&old.target) {
485 state.record(old.clone());
486 }
487 }
488 if let Err(e) = state.save() {
489 eprintln!("warning: failed to save partial state: {e}");
490 }
491 } else {
492 state.save()?;
493 }
494 }
495
496 if let Some(e) = deploy_error {
497 return Err(e);
498 }
499
500 Ok(report)
501 }
502}
503
504pub fn expand_path(path: &str, context: Option<&str>) -> Result<String> {
507 shellexpand::full(path)
508 .map(|s| s.into_owned())
509 .map_err(|e| {
510 if let Some(ctx) = context {
511 anyhow::anyhow!("{ctx}: {e}")
512 } else {
513 anyhow::anyhow!("path expansion failed: {e}")
514 }
515 })
516}