use std::fs::{self, File};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::config::{Config, Link, Template};
use crate::paths::{ResolveError, Resolver};
#[derive(Debug, Error)]
pub enum PlanError {
#[error("resolve dst {dst:?}: {source}")]
Resolve {
dst: String,
#[source]
source: ResolveError,
},
#[error("invalid glob pattern {pattern:?}: {reason}")]
Glob {
pattern: String,
reason: String,
},
#[error("unknown platform string {value:?}")]
UnknownPlatform {
value: String,
},
}
#[derive(Debug, Error)]
pub enum ExecError {
#[error("copy {src:?} -> {dst:?}: {source}")]
Io {
src: PathBuf,
dst: PathBuf,
#[source]
source: std::io::Error,
},
#[error("source missing: {0:?}")]
SourceMissing(PathBuf),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EntryKind {
Link,
Template,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
Copy {
src: PathBuf,
dst: PathBuf,
kind: EntryKind,
},
Conflict {
src: PathBuf,
dst: PathBuf,
kind: EntryKind,
},
}
impl Action {
pub fn src(&self) -> &Path {
match self {
Action::Copy { src, .. } | Action::Conflict { src, .. } => src,
}
}
pub fn dst(&self) -> &Path {
match self {
Action::Copy { dst, .. } | Action::Conflict { dst, .. } => dst,
}
}
pub fn kind(&self) -> EntryKind {
match self {
Action::Copy { kind, .. } | Action::Conflict { kind, .. } => *kind,
}
}
}
#[derive(Debug, Default, Clone)]
pub struct Plan {
pub actions: Vec<Action>,
}
impl Plan {
pub fn copy_count(&self) -> usize {
self.actions
.iter()
.filter(|a| matches!(a, Action::Copy { .. }))
.count()
}
pub fn conflict_count(&self) -> usize {
self.actions
.iter()
.filter(|a| matches!(a, Action::Conflict { .. }))
.count()
}
}
pub fn plan(cfg: &Config, repo_root: &Path, resolver: &Resolver) -> Result<Plan, PlanError> {
let mut actions = Vec::new();
let current_platform = current_platform_str();
for link in &cfg.links {
if !platform_matches(&link.platform, current_platform)? {
continue;
}
plan_link(link, repo_root, resolver, &mut actions)?;
}
for tmpl in &cfg.templates {
if !platform_matches(&tmpl.platform, current_platform)? {
continue;
}
plan_template(tmpl, repo_root, resolver, &mut actions)?;
}
Ok(Plan { actions })
}
fn plan_link(
link: &Link,
repo_root: &Path,
resolver: &Resolver,
out: &mut Vec<Action>,
) -> Result<(), PlanError> {
let dst_str = resolver
.resolve(&link.dst)
.map_err(|e| PlanError::Resolve {
dst: link.dst.clone(),
source: e,
})?;
let dst_base = PathBuf::from(dst_str);
if let Some(src) = &link.src {
let src_path = repo_root.join(src);
let action = build_action(&src_path, &dst_base, EntryKind::Link);
out.push(action);
return Ok(());
}
if let Some(src_glob) = &link.src_glob {
let full_pattern = repo_root.join(src_glob).to_string_lossy().into_owned();
let matches = glob::glob(&full_pattern).map_err(|e| PlanError::Glob {
pattern: full_pattern.clone(),
reason: e.to_string(),
})?;
let glob_prefix = glob_prefix_of(src_glob);
let strip_root = repo_root.join(&glob_prefix);
let mut paths: Vec<PathBuf> = matches.filter_map(|r| r.ok()).collect();
paths.sort();
for src_path in paths {
if !src_path.is_file() {
continue;
}
let rel = src_path
.strip_prefix(&strip_root)
.unwrap_or(&src_path)
.to_path_buf();
let dst_path = dst_base.join(rel);
out.push(build_action(&src_path, &dst_path, EntryKind::Link));
}
return Ok(());
}
Ok(())
}
fn plan_template(
tmpl: &Template,
repo_root: &Path,
resolver: &Resolver,
out: &mut Vec<Action>,
) -> Result<(), PlanError> {
let dst_str = resolver
.resolve(&tmpl.dst)
.map_err(|e| PlanError::Resolve {
dst: tmpl.dst.clone(),
source: e,
})?;
let dst_path = PathBuf::from(dst_str);
let src_path = repo_root.join(&tmpl.src);
out.push(build_action(&src_path, &dst_path, EntryKind::Template));
Ok(())
}
fn build_action(src: &Path, dst: &Path, kind: EntryKind) -> Action {
if dst.exists() {
Action::Conflict {
src: src.to_path_buf(),
dst: dst.to_path_buf(),
kind,
}
} else {
Action::Copy {
src: src.to_path_buf(),
dst: dst.to_path_buf(),
kind,
}
}
}
fn glob_prefix_of(pattern: &str) -> PathBuf {
let mut prefix = PathBuf::new();
for part in Path::new(pattern).components() {
let s = part.as_os_str().to_string_lossy();
if s.contains(['*', '?', '[']) {
break;
}
prefix.push(part.as_os_str());
}
prefix
}
fn current_platform_str() -> &'static str {
if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"linux"
}
}
fn platform_matches(entry_platform: &Option<String>, current: &str) -> Result<bool, PlanError> {
let Some(p) = entry_platform else {
return Ok(true);
};
match p.as_str() {
"linux" | "macos" | "windows" => Ok(p == current),
other => Err(PlanError::UnknownPlatform {
value: other.to_string(),
}),
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct ExecOpts {
pub dry_run: bool,
pub overwrite_conflicts: bool,
}
#[derive(Debug, Clone)]
pub struct Written {
pub src: PathBuf,
pub dst: PathBuf,
pub kind: EntryKind,
pub hash_src: Option<String>,
pub hash_dst: Option<String>,
}
#[derive(Debug, Default, Clone)]
pub struct Report {
pub written: Vec<Written>,
pub skipped_conflicts: usize,
}
impl Report {
pub fn written_count(&self) -> usize {
self.written.len()
}
}
pub fn execute(plan: &Plan, opts: ExecOpts) -> Result<Report, ExecError> {
let mut report = Report::default();
for action in &plan.actions {
match action {
Action::Copy { src, dst, kind } => {
let written = do_copy(src, dst, *kind, opts)?;
report.written.push(written);
}
Action::Conflict { src, dst, kind } => {
if opts.overwrite_conflicts {
let written = do_copy(src, dst, *kind, opts)?;
report.written.push(written);
} else {
report.skipped_conflicts += 1;
}
}
}
}
Ok(report)
}
fn do_copy(src: &Path, dst: &Path, kind: EntryKind, opts: ExecOpts) -> Result<Written, ExecError> {
if opts.dry_run {
return Ok(Written {
src: src.to_path_buf(),
dst: dst.to_path_buf(),
kind,
hash_src: None,
hash_dst: None,
});
}
copy_atomic(src, dst)?;
let hash_src = crate::manifest::hash_file(src).ok();
let hash_dst = crate::manifest::hash_file(dst).ok();
Ok(Written {
src: src.to_path_buf(),
dst: dst.to_path_buf(),
kind,
hash_src,
hash_dst,
})
}
fn copy_atomic(src: &Path, dst: &Path) -> Result<(), ExecError> {
let mk_err = |e: std::io::Error| ExecError::Io {
src: src.to_path_buf(),
dst: dst.to_path_buf(),
source: e,
};
if !src.exists() {
return Err(ExecError::SourceMissing(src.to_path_buf()));
}
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent).map_err(mk_err)?;
}
let tmp = tmp_sibling(dst);
let _ = fs::remove_file(&tmp);
fs::copy(src, &tmp).map_err(mk_err)?;
if let Ok(meta) = fs::metadata(src) {
if let Ok(modified) = meta.modified()
&& let Ok(f) = File::options().write(true).open(&tmp)
{
let _ = f.set_modified(modified);
}
} else {
let _: SystemTime = SystemTime::now(); }
fs::rename(&tmp, dst).map_err(mk_err)?;
Ok(())
}
fn tmp_sibling(dst: &Path) -> PathBuf {
let mut name = dst.file_name().unwrap_or_default().to_os_string();
name.push(format!(".krypt-tmp-{}", std::process::id()));
dst.with_file_name(name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn glob_prefix_strips_at_first_wildcard() {
assert_eq!(
glob_prefix_of(".config/nvim/**/*"),
PathBuf::from(".config/nvim")
);
assert_eq!(glob_prefix_of("**/*"), PathBuf::new());
assert_eq!(glob_prefix_of("a/b/c"), PathBuf::from("a/b/c"));
assert_eq!(glob_prefix_of("foo/*.toml"), PathBuf::from("foo"));
}
#[test]
fn platform_match_accepts_omitted() {
assert!(platform_matches(&None, "linux").unwrap());
}
#[test]
fn platform_match_filters_other_os() {
assert!(platform_matches(&Some("linux".into()), "linux").unwrap());
assert!(!platform_matches(&Some("macos".into()), "linux").unwrap());
assert!(!platform_matches(&Some("windows".into()), "linux").unwrap());
}
#[test]
fn platform_match_rejects_unknown() {
assert!(matches!(
platform_matches(&Some("freebsd".into()), "linux"),
Err(PlanError::UnknownPlatform { .. })
));
}
#[test]
fn tmp_sibling_lives_next_to_dst() {
let dst = PathBuf::from("/some/where/file.conf");
let tmp = tmp_sibling(&dst);
assert_eq!(tmp.parent(), dst.parent());
let name = tmp.file_name().unwrap().to_string_lossy().to_string();
assert!(name.starts_with("file.conf.krypt-tmp-"));
}
#[test]
fn plan_counts_match_actions() {
let actions = vec![
Action::Copy {
src: "/a".into(),
dst: "/b".into(),
kind: EntryKind::Link,
},
Action::Conflict {
src: "/c".into(),
dst: "/d".into(),
kind: EntryKind::Template,
},
Action::Copy {
src: "/e".into(),
dst: "/f".into(),
kind: EntryKind::Link,
},
];
let p = Plan { actions };
assert_eq!(p.copy_count(), 2);
assert_eq!(p.conflict_count(), 1);
}
}