1use std::fs::{self, File};
24use std::path::{Path, PathBuf};
25use std::time::SystemTime;
26
27use serde::{Deserialize, Serialize};
28use thiserror::Error;
29
30use crate::config::{Config, Link, Template};
31use crate::paths::{ResolveError, Resolver};
32
33#[derive(Debug, Error)]
37pub enum PlanError {
38 #[error("resolve dst {dst:?}: {source}")]
40 Resolve {
41 dst: String,
43 #[source]
45 source: ResolveError,
46 },
47
48 #[error("invalid glob pattern {pattern:?}: {reason}")]
50 Glob {
51 pattern: String,
53 reason: String,
55 },
56
57 #[error("unknown platform string {value:?}")]
61 UnknownPlatform {
62 value: String,
64 },
65}
66
67#[derive(Debug, Error)]
69pub enum ExecError {
70 #[error("copy {src:?} -> {dst:?}: {source}")]
72 Io {
73 src: PathBuf,
75 dst: PathBuf,
77 #[source]
79 source: std::io::Error,
80 },
81
82 #[error("source missing: {0:?}")]
84 SourceMissing(PathBuf),
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "lowercase")]
92pub enum EntryKind {
93 Link,
95 Template,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum Action {
102 Copy {
104 src: PathBuf,
106 dst: PathBuf,
108 kind: EntryKind,
110 },
111
112 Conflict {
116 src: PathBuf,
118 dst: PathBuf,
120 kind: EntryKind,
122 },
123}
124
125impl Action {
126 pub fn src(&self) -> &Path {
128 match self {
129 Action::Copy { src, .. } | Action::Conflict { src, .. } => src,
130 }
131 }
132
133 pub fn dst(&self) -> &Path {
135 match self {
136 Action::Copy { dst, .. } | Action::Conflict { dst, .. } => dst,
137 }
138 }
139
140 pub fn kind(&self) -> EntryKind {
142 match self {
143 Action::Copy { kind, .. } | Action::Conflict { kind, .. } => *kind,
144 }
145 }
146}
147
148#[derive(Debug, Default, Clone)]
150pub struct Plan {
151 pub actions: Vec<Action>,
153}
154
155impl Plan {
156 pub fn copy_count(&self) -> usize {
158 self.actions
159 .iter()
160 .filter(|a| matches!(a, Action::Copy { .. }))
161 .count()
162 }
163
164 pub fn conflict_count(&self) -> usize {
166 self.actions
167 .iter()
168 .filter(|a| matches!(a, Action::Conflict { .. }))
169 .count()
170 }
171}
172
173pub fn plan(cfg: &Config, repo_root: &Path, resolver: &Resolver) -> Result<Plan, PlanError> {
181 let mut actions = Vec::new();
182 let current_platform = current_platform_str();
183
184 for link in &cfg.links {
185 if !platform_matches(&link.platform, current_platform)? {
186 continue;
187 }
188 plan_link(link, repo_root, resolver, &mut actions)?;
189 }
190 for tmpl in &cfg.templates {
191 if !platform_matches(&tmpl.platform, current_platform)? {
192 continue;
193 }
194 plan_template(tmpl, repo_root, resolver, &mut actions)?;
195 }
196 Ok(Plan { actions })
197}
198
199fn plan_link(
200 link: &Link,
201 repo_root: &Path,
202 resolver: &Resolver,
203 out: &mut Vec<Action>,
204) -> Result<(), PlanError> {
205 let dst_str = resolver
206 .resolve(&link.dst)
207 .map_err(|e| PlanError::Resolve {
208 dst: link.dst.clone(),
209 source: e,
210 })?;
211 let dst_base = PathBuf::from(dst_str);
212
213 if let Some(src) = &link.src {
214 let src_path = repo_root.join(src);
215 let action = build_action(&src_path, &dst_base, EntryKind::Link);
216 out.push(action);
217 return Ok(());
218 }
219
220 if let Some(src_glob) = &link.src_glob {
221 let full_pattern = repo_root.join(src_glob).to_string_lossy().into_owned();
222 let matches = glob::glob(&full_pattern).map_err(|e| PlanError::Glob {
223 pattern: full_pattern.clone(),
224 reason: e.to_string(),
225 })?;
226 let glob_prefix = glob_prefix_of(src_glob);
227 let strip_root = repo_root.join(&glob_prefix);
228 let mut paths: Vec<PathBuf> = matches.filter_map(|r| r.ok()).collect();
229 paths.sort();
230 for src_path in paths {
231 if !src_path.is_file() {
233 continue;
234 }
235 let rel = src_path
236 .strip_prefix(&strip_root)
237 .unwrap_or(&src_path)
238 .to_path_buf();
239 let dst_path = dst_base.join(rel);
240 out.push(build_action(&src_path, &dst_path, EntryKind::Link));
241 }
242 return Ok(());
243 }
244
245 Ok(())
249}
250
251fn plan_template(
252 tmpl: &Template,
253 repo_root: &Path,
254 resolver: &Resolver,
255 out: &mut Vec<Action>,
256) -> Result<(), PlanError> {
257 let dst_str = resolver
258 .resolve(&tmpl.dst)
259 .map_err(|e| PlanError::Resolve {
260 dst: tmpl.dst.clone(),
261 source: e,
262 })?;
263 let dst_path = PathBuf::from(dst_str);
264 let src_path = repo_root.join(&tmpl.src);
265 out.push(build_action(&src_path, &dst_path, EntryKind::Template));
266 Ok(())
267}
268
269fn build_action(src: &Path, dst: &Path, kind: EntryKind) -> Action {
270 if dst.exists() {
271 Action::Conflict {
272 src: src.to_path_buf(),
273 dst: dst.to_path_buf(),
274 kind,
275 }
276 } else {
277 Action::Copy {
278 src: src.to_path_buf(),
279 dst: dst.to_path_buf(),
280 kind,
281 }
282 }
283}
284
285fn glob_prefix_of(pattern: &str) -> PathBuf {
289 let mut prefix = PathBuf::new();
290 for part in Path::new(pattern).components() {
291 let s = part.as_os_str().to_string_lossy();
292 if s.contains(['*', '?', '[']) {
293 break;
294 }
295 prefix.push(part.as_os_str());
296 }
297 prefix
298}
299
300fn current_platform_str() -> &'static str {
301 if cfg!(target_os = "windows") {
302 "windows"
303 } else if cfg!(target_os = "macos") {
304 "macos"
305 } else {
306 "linux"
307 }
308}
309
310fn platform_matches(entry_platform: &Option<String>, current: &str) -> Result<bool, PlanError> {
311 let Some(p) = entry_platform else {
312 return Ok(true);
313 };
314 match p.as_str() {
315 "linux" | "macos" | "windows" => Ok(p == current),
316 other => Err(PlanError::UnknownPlatform {
317 value: other.to_string(),
318 }),
319 }
320}
321
322#[derive(Debug, Default, Clone, Copy)]
326pub struct ExecOpts {
327 pub dry_run: bool,
329 pub overwrite_conflicts: bool,
332}
333
334#[derive(Debug, Clone)]
337pub struct Written {
338 pub src: PathBuf,
340 pub dst: PathBuf,
342 pub kind: EntryKind,
344 pub hash_src: Option<String>,
347 pub hash_dst: Option<String>,
350}
351
352#[derive(Debug, Default, Clone)]
354pub struct Report {
355 pub written: Vec<Written>,
358 pub skipped_conflicts: usize,
360}
361
362impl Report {
363 pub fn written_count(&self) -> usize {
366 self.written.len()
367 }
368}
369
370pub fn execute(plan: &Plan, opts: ExecOpts) -> Result<Report, ExecError> {
372 let mut report = Report::default();
373 for action in &plan.actions {
374 match action {
375 Action::Copy { src, dst, kind } => {
376 let written = do_copy(src, dst, *kind, opts)?;
377 report.written.push(written);
378 }
379 Action::Conflict { src, dst, kind } => {
380 if opts.overwrite_conflicts {
381 let written = do_copy(src, dst, *kind, opts)?;
382 report.written.push(written);
383 } else {
384 report.skipped_conflicts += 1;
385 }
386 }
387 }
388 }
389 Ok(report)
390}
391
392fn do_copy(src: &Path, dst: &Path, kind: EntryKind, opts: ExecOpts) -> Result<Written, ExecError> {
393 if opts.dry_run {
394 return Ok(Written {
395 src: src.to_path_buf(),
396 dst: dst.to_path_buf(),
397 kind,
398 hash_src: None,
399 hash_dst: None,
400 });
401 }
402 copy_atomic(src, dst)?;
403 let hash_src = crate::manifest::hash_file(src).ok();
404 let hash_dst = crate::manifest::hash_file(dst).ok();
405 Ok(Written {
406 src: src.to_path_buf(),
407 dst: dst.to_path_buf(),
408 kind,
409 hash_src,
410 hash_dst,
411 })
412}
413
414fn copy_atomic(src: &Path, dst: &Path) -> Result<(), ExecError> {
417 let mk_err = |e: std::io::Error| ExecError::Io {
418 src: src.to_path_buf(),
419 dst: dst.to_path_buf(),
420 source: e,
421 };
422
423 if !src.exists() {
424 return Err(ExecError::SourceMissing(src.to_path_buf()));
425 }
426 if let Some(parent) = dst.parent() {
427 fs::create_dir_all(parent).map_err(mk_err)?;
428 }
429 let tmp = tmp_sibling(dst);
430 let _ = fs::remove_file(&tmp);
433
434 fs::copy(src, &tmp).map_err(mk_err)?;
437
438 if let Ok(meta) = fs::metadata(src) {
441 if let Ok(modified) = meta.modified()
442 && let Ok(f) = File::options().write(true).open(&tmp)
443 {
444 let _ = f.set_modified(modified);
445 }
446 } else {
447 let _: SystemTime = SystemTime::now(); }
450
451 fs::rename(&tmp, dst).map_err(mk_err)?;
452 Ok(())
453}
454
455fn tmp_sibling(dst: &Path) -> PathBuf {
456 let mut name = dst.file_name().unwrap_or_default().to_os_string();
457 name.push(format!(".krypt-tmp-{}", std::process::id()));
458 dst.with_file_name(name)
459}
460
461#[cfg(test)]
464mod tests {
465 use super::*;
466
467 #[test]
468 fn glob_prefix_strips_at_first_wildcard() {
469 assert_eq!(
470 glob_prefix_of(".config/nvim/**/*"),
471 PathBuf::from(".config/nvim")
472 );
473 assert_eq!(glob_prefix_of("**/*"), PathBuf::new());
474 assert_eq!(glob_prefix_of("a/b/c"), PathBuf::from("a/b/c"));
475 assert_eq!(glob_prefix_of("foo/*.toml"), PathBuf::from("foo"));
476 }
477
478 #[test]
479 fn platform_match_accepts_omitted() {
480 assert!(platform_matches(&None, "linux").unwrap());
481 }
482
483 #[test]
484 fn platform_match_filters_other_os() {
485 assert!(platform_matches(&Some("linux".into()), "linux").unwrap());
486 assert!(!platform_matches(&Some("macos".into()), "linux").unwrap());
487 assert!(!platform_matches(&Some("windows".into()), "linux").unwrap());
488 }
489
490 #[test]
491 fn platform_match_rejects_unknown() {
492 assert!(matches!(
493 platform_matches(&Some("freebsd".into()), "linux"),
494 Err(PlanError::UnknownPlatform { .. })
495 ));
496 }
497
498 #[test]
499 fn tmp_sibling_lives_next_to_dst() {
500 let dst = PathBuf::from("/some/where/file.conf");
501 let tmp = tmp_sibling(&dst);
502 assert_eq!(tmp.parent(), dst.parent());
503 let name = tmp.file_name().unwrap().to_string_lossy().to_string();
504 assert!(name.starts_with("file.conf.krypt-tmp-"));
505 }
506
507 #[test]
508 fn plan_counts_match_actions() {
509 let actions = vec![
510 Action::Copy {
511 src: "/a".into(),
512 dst: "/b".into(),
513 kind: EntryKind::Link,
514 },
515 Action::Conflict {
516 src: "/c".into(),
517 dst: "/d".into(),
518 kind: EntryKind::Template,
519 },
520 Action::Copy {
521 src: "/e".into(),
522 dst: "/f".into(),
523 kind: EntryKind::Link,
524 },
525 ];
526 let p = Plan { actions };
527 assert_eq!(p.copy_count(), 2);
528 assert_eq!(p.conflict_count(), 1);
529 }
530}