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::{bail, Context, Result};
11use std::collections::{HashMap, HashSet};
12use std::path::{Path, PathBuf};
13use toml::map::Map;
14use toml::Value;
15
16pub struct Orchestrator {
17 loader: ConfigLoader,
18 target_dir: PathBuf,
19 state_dir: Option<PathBuf>,
20 system_mode: bool,
21 package_filter: Option<String>,
22}
23
24#[derive(Debug, Default)]
25pub struct DeployReport {
26 pub created: Vec<PathBuf>,
27 pub updated: Vec<PathBuf>,
28 pub unchanged: Vec<PathBuf>,
29 pub conflicts: Vec<(PathBuf, String)>,
30 pub dry_run_actions: Vec<PathBuf>,
31 pub orphaned: Vec<PathBuf>,
32 pub pruned: Vec<PathBuf>,
33}
34
35struct PendingAction {
36 pkg_name: String,
37 action: scanner::FileAction,
38 pkg_target: PathBuf,
39 rendered: Option<String>,
40 is_system: bool,
41}
42
43impl Orchestrator {
44 pub fn new(dotfiles_dir: &Path, target_dir: &Path) -> Result<Self> {
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 system_mode: false,
51 package_filter: None,
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 with_package_filter(mut self, filter: Option<String>) -> Self {
66 self.package_filter = filter;
67 self
68 }
69
70 pub fn loader(&self) -> &ConfigLoader {
71 &self.loader
72 }
73
74 pub fn deploy(&mut self, hostname: &str, dry_run: bool, force: bool) -> Result<DeployReport> {
75 let mut report = DeployReport::default();
76 let mut state = self
77 .state_dir
78 .as_ref()
79 .map(|d| DeployState::new(d))
80 .unwrap_or_default();
81
82 let host = self
84 .loader
85 .load_host(hostname)
86 .with_context(|| format!("failed to load host config for '{hostname}'"))?;
87
88 let mut all_requested_packages: Vec<String> = Vec::new();
90 let mut merged_vars: Map<String, Value> = Map::new();
91
92 for role_name in &host.roles {
93 let role = self
94 .loader
95 .load_role(role_name)
96 .with_context(|| format!("failed to load role '{role_name}'"))?;
97
98 for pkg in &role.packages {
99 if !all_requested_packages.contains(pkg) {
100 all_requested_packages.push(pkg.clone());
101 }
102 }
103
104 merged_vars = vars::merge_vars(&merged_vars, &role.vars);
105 }
106
107 merged_vars = vars::merge_vars(&merged_vars, &host.vars);
109
110 let requested_refs: Vec<&str> = all_requested_packages.iter().map(|s| s.as_str()).collect();
112 let mut resolved = resolver::resolve_packages(self.loader.root(), &requested_refs)?;
113
114 if let Some(ref filter) = self.package_filter {
116 let filter_refs: Vec<&str> = vec![filter.as_str()];
117 let filtered = resolver::resolve_packages(self.loader.root(), &filter_refs)?;
118 resolved.retain(|pkg| filtered.contains(pkg));
119 }
120
121 let role_names: Vec<&str> = host.roles.iter().map(|s| s.as_str()).collect();
123
124 let packages_dir = self.loader.packages_dir();
126 let mut pending: Vec<PendingAction> = Vec::new();
127
128 for pkg_name in &resolved {
129 let is_system = self
131 .loader
132 .root()
133 .packages
134 .get(pkg_name)
135 .map(|c| c.system)
136 .unwrap_or(false);
137 if self.system_mode != is_system {
138 continue;
139 }
140
141 let pkg_dir = packages_dir.join(pkg_name);
142 if !pkg_dir.is_dir() {
143 eprintln!("warning: package directory not found: {}", pkg_dir.display());
144 continue;
145 }
146
147 let actions = scanner::scan_package(&pkg_dir, hostname, &role_names)?;
148
149 let pkg_target = if let Some(pkg_config) = self.loader.root().packages.get(pkg_name) {
150 if let Some(ref target) = pkg_config.target {
151 PathBuf::from(expand_path(target, Some(&format!("package '{pkg_name}'")))?)
152 } else {
153 self.target_dir.clone()
154 }
155 } else {
156 self.target_dir.clone()
157 };
158
159 for action in actions {
160 let rendered = if action.kind == scanner::EntryKind::Template {
161 let tmpl_content = std::fs::read_to_string(&action.source)
162 .with_context(|| format!("failed to read template: {}", action.source.display()))?;
163 Some(template::render_template(&tmpl_content, &merged_vars)?)
164 } else {
165 None
166 };
167
168 pending.push(PendingAction {
169 pkg_name: pkg_name.clone(),
170 action,
171 pkg_target: pkg_target.clone(),
172 rendered,
173 is_system,
174 });
175 }
176 }
177
178 let mut target_owners: HashMap<PathBuf, String> = HashMap::new();
180 for p in &pending {
181 let target_path = p.pkg_target.join(&p.action.target_rel_path);
182 if let Some(existing) = target_owners.get(&target_path) {
183 bail!(
184 "target collision -- packages '{}' and '{}' both deploy {}",
185 existing,
186 p.pkg_name,
187 target_path.display()
188 );
189 }
190 target_owners.insert(target_path, p.pkg_name.clone());
191 }
192
193 let existing_state = self
195 .state_dir
196 .as_ref()
197 .map(|d| DeployState::load(d))
198 .transpose()?
199 .unwrap_or_default();
200
201 let existing_hashes: HashMap<PathBuf, &str> = existing_state
202 .entries()
203 .iter()
204 .map(|e| (e.target.clone(), e.content_hash.as_str()))
205 .collect();
206
207 let existing_targets: HashSet<PathBuf> = existing_state
208 .entries()
209 .iter()
210 .map(|e| e.target.clone())
211 .collect();
212
213 let mut current_pkg: Option<String> = None;
215 let mut skip_pkg: Option<String> = None;
216
217 for p in &pending {
218 if current_pkg.as_deref() != Some(&p.pkg_name) {
220 if let Some(ref prev_pkg) = current_pkg {
222 if !dry_run {
223 if let Some(pkg_config) = self.loader.root().packages.get(prev_pkg) {
224 if let Some(ref cmd) = pkg_config.post_deploy {
225 let pkg_target = pending.iter()
226 .find(|pp| pp.pkg_name == *prev_pkg)
227 .map(|pp| &pp.pkg_target)
228 .unwrap();
229 if let Err(e) = crate::hooks::run_hook(cmd, pkg_target, prev_pkg, "deploy") {
230 eprintln!("warning: {e}");
231 }
232 }
233 }
234 }
235 }
236
237 if !dry_run {
239 if let Some(pkg_config) = self.loader.root().packages.get(&p.pkg_name) {
240 if let Some(ref cmd) = pkg_config.pre_deploy {
241 if let Err(e) = crate::hooks::run_hook(cmd, &p.pkg_target, &p.pkg_name, "deploy") {
242 eprintln!("warning: pre_deploy hook failed, skipping package '{}': {e}", p.pkg_name);
243 skip_pkg = Some(p.pkg_name.clone());
244 current_pkg = Some(p.pkg_name.clone());
245 continue;
246 }
247 }
248 }
249 }
250 skip_pkg = None;
251 current_pkg = Some(p.pkg_name.clone());
252 }
253
254 if skip_pkg.as_deref() == Some(&p.pkg_name) {
256 report.conflicts.push((
257 p.pkg_target.join(&p.action.target_rel_path),
258 "skipped: pre_deploy hook failed".to_string(),
259 ));
260 continue;
261 }
262
263 let target_path = p.pkg_target.join(&p.action.target_rel_path);
264
265 let use_symlink = !p.is_system
267 && (p.action.kind == scanner::EntryKind::Base
268 || p.action.kind == scanner::EntryKind::Override);
269
270 if !use_symlink && target_path.exists() {
272 if let Some(&expected_hash) = existing_hashes.get(&target_path) {
273 let current_hash = hash::hash_file(&target_path)?;
274 if current_hash != expected_hash && !force {
275 eprintln!(
276 "warning: {} has been modified since last deploy, skipping (use --force to overwrite)",
277 p.action.target_rel_path.display()
278 );
279 report.conflicts.push((
280 target_path,
281 "modified since last deploy".to_string(),
282 ));
283 continue;
284 }
285 }
286 }
287
288 let effective_force = if existing_targets.contains(&target_path) {
290 true
292 } else if target_path.exists() && !target_path.is_symlink() && !target_path.is_dir() {
293 force
295 } else {
296 force
298 };
299
300 let is_managed = existing_targets.contains(&target_path);
301
302 let (original_hash, original_owner, original_group, original_mode) =
305 if !dry_run && !is_managed && target_path.exists() && !target_path.is_symlink() {
306 let content = std::fs::read(&target_path)?;
307 let hash = hash::hash_content(&content);
308 state.store_original(&hash, &content)?;
309
310 let (owner, group, mode) = metadata::read_file_metadata(&target_path)?;
311 (Some(hash), Some(owner), Some(group), Some(mode))
312 } else {
313 (None, None, None, None)
314 };
315
316 let result = if use_symlink {
318 deployer::deploy_symlink(
319 &p.action,
320 &p.pkg_target,
321 dry_run,
322 effective_force,
323 )?
324 } else {
325 deployer::deploy_copy(
326 &p.action,
327 &p.pkg_target,
328 dry_run,
329 effective_force,
330 p.rendered.as_deref(),
331 )?
332 };
333
334 match result {
335 DeployResult::Created | DeployResult::Updated => {
336 let content_hash = if !dry_run {
338 if use_symlink {
339 hash::hash_file(&p.action.source)?
340 } else {
341 hash::hash_file(&target_path)?
342 }
343 } else {
344 String::new()
345 };
346
347 let resolved = if !dry_run && p.is_system {
349 if let Some(pkg_config) = self.loader.root().packages.get(&p.pkg_name) {
350 let rel_path_str = p.action.target_rel_path.to_str().unwrap_or("");
351 let resolved = metadata::resolve_metadata(pkg_config, rel_path_str);
352
353 if resolved.owner.is_some() || resolved.group.is_some() {
354 if let Err(e) = metadata::apply_ownership(
355 &target_path,
356 resolved.owner.as_deref(),
357 resolved.group.as_deref(),
358 ) {
359 eprintln!("warning: failed to set ownership on {}: {e}", target_path.display());
360 }
361 }
362
363 if let Some(ref mode) = resolved.mode {
364 deployer::apply_permission_override(&target_path, mode)?;
365 }
366
367 resolved
368 } else {
369 metadata::resolve_metadata(
370 &crate::config::PackageConfig::default(),
371 "",
372 )
373 }
374 } else {
375 metadata::resolve_metadata(
376 &crate::config::PackageConfig::default(),
377 "",
378 )
379 };
380
381 let abs_source = std::fs::canonicalize(&p.action.source)
382 .unwrap_or_else(|_| p.action.source.clone());
383
384 state.record(DeployEntry {
385 target: target_path.clone(),
386 staged: None,
387 source: abs_source,
388 content_hash,
389 original_hash,
390 kind: p.action.kind,
391 package: p.pkg_name.clone(),
392 owner: resolved.owner,
393 group: resolved.group,
394 mode: resolved.mode,
395 original_owner,
396 original_group,
397 original_mode,
398 });
399
400 if matches!(result, DeployResult::Updated) {
401 report.updated.push(target_path.clone());
402 } else {
403 report.created.push(target_path.clone());
404 }
405 }
406 DeployResult::Conflict(msg) => {
407 report.conflicts.push((target_path, msg));
408 }
409 DeployResult::DryRun => {
410 report.dry_run_actions.push(target_path);
411 }
412 _ => {}
413 }
414 }
415
416 if let Some(ref last_pkg) = current_pkg {
418 if !dry_run && skip_pkg.as_deref() != Some(last_pkg) {
419 if let Some(pkg_config) = self.loader.root().packages.get(last_pkg) {
420 if let Some(ref cmd) = pkg_config.post_deploy {
421 let pkg_target = pending.iter()
422 .find(|pp| pp.pkg_name == *last_pkg)
423 .map(|pp| &pp.pkg_target)
424 .unwrap();
425 if let Err(e) = crate::hooks::run_hook(cmd, pkg_target, last_pkg, "deploy") {
426 eprintln!("warning: {e}");
427 }
428 }
429 }
430 }
431 }
432
433 if self.state_dir.is_some() {
435 let new_targets: std::collections::HashSet<PathBuf> = pending
436 .iter()
437 .map(|p| p.pkg_target.join(&p.action.target_rel_path))
438 .collect();
439
440 for old_entry in existing_state.entries() {
441 if !new_targets.contains(&old_entry.target) {
442 report.orphaned.push(old_entry.target.clone());
443
444 if !dry_run && self.loader.root().dotm.auto_prune {
445 if old_entry.target.is_symlink() || old_entry.target.exists() {
446 let _ = std::fs::remove_file(&old_entry.target);
447 crate::state::cleanup_empty_parents(&old_entry.target);
448 }
449 report.pruned.push(old_entry.target.clone());
450 }
451 }
452 }
453 }
454
455 if !dry_run && self.state_dir.is_some() {
457 state.save()?;
458 }
459
460 Ok(report)
461 }
462}
463
464pub fn expand_path(path: &str, context: Option<&str>) -> Result<String> {
467 shellexpand::full(path)
468 .map(|s| s.into_owned())
469 .map_err(|e| {
470 if let Some(ctx) = context {
471 anyhow::anyhow!("{ctx}: {e}")
472 } else {
473 anyhow::anyhow!("path expansion failed: {e}")
474 }
475 })
476}