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 let host = self
97 .loader
98 .load_host(hostname)
99 .with_context(|| format!("failed to load host config for '{hostname}'"))?;
100
101 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 merged_vars = vars::merge_vars(&merged_vars, &host.vars);
122
123 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 let role_names: Vec<&str> = host.roles.iter().map(|s| s.as_str()).collect();
129
130 let packages_dir = self.loader.packages_dir();
132 let mut pending: Vec<PendingAction> = Vec::new();
133
134 for pkg_name in &resolved {
135 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 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 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 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 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 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 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 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 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 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(), 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 if !dry_run && self.state_dir.is_some() {
487 state.save()?;
488 }
489
490 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}