tools

various tools I have been using throughout the years
Log | Files | Refs | README | LICENSE

lib.rs (23627B)


      1 use anyhow::{Context, Result};
      2 use clap::{Args, Parser};
      3 use console::style;
      4 use similar::{ChangeTag, TextDiff};
      5 use std::collections::{BTreeSet, HashSet};
      6 use std::fs;
      7 use std::io::Write;
      8 use std::path::{Path, PathBuf};
      9 use std::process::{Command, ExitCode};
     10 
     11 /// dam (diff-and-merge) — sync line-oriented text files between a local
     12 /// directory and a remote host over rsync.
     13 ///
     14 /// dam merges files as the de-duplicated union of their local and remote
     15 /// lines: local lines keep their original order and remote-only lines are
     16 /// appended. It is intended for list-like data (allow-lists, hosts files,
     17 /// playlists, word lists), not for arbitrary documents — blank lines and
     18 /// duplicate lines are not preserved by the merge.
     19 ///
     20 /// --local and --remote may be directories or single files. When --local is
     21 /// an existing directory dam syncs the whole tree (recursing into
     22 /// subdirectories); otherwise it treats both paths as a single file. To mirror
     23 /// a directory into a new local location, create that directory first.
     24 ///
     25 /// Because the merge is a union, dam never removes a line: a line deleted on
     26 /// one side reappears from the other. Removing data is a manual edit on both
     27 /// sides, or a one-shot rsync.
     28 ///
     29 /// dam has a plan/apply workflow, with three mutually exclusive modes:
     30 ///
     31 ///   --plan  (default)  Show the changes each direction would make. Writes
     32 ///                      nothing.
     33 ///   --pull             Apply the merge to the local directory (remote → local).
     34 ///   --push             Apply the merge to the remote host (local → remote).
     35 ///
     36 /// Progress and the summary are written to stderr and the plan diff to stdout,
     37 /// so `dam --plan > changes.diff` captures just the differences.
     38 ///
     39 /// Examples:
     40 ///   # See what would change on either side
     41 ///   dam --host myserver --local ~/lists --remote /srv/lists
     42 ///
     43 ///   # Bring the local directory up to date
     44 ///   dam --host myserver --local ~/lists --remote /srv/lists --pull
     45 ///
     46 ///   # Bring the remote up to date
     47 ///   dam --host myserver --local ~/lists --remote /srv/lists --push
     48 ///
     49 ///   # Fail (exit 1) if the two sides have drifted — handy in CI
     50 ///   dam --host myserver --local ~/lists --remote /srv/lists --plan --exit-code
     51 #[derive(Parser, Debug)]
     52 #[command(
     53     author,
     54     version,
     55     about = "Diff-and-merge line-oriented text files between a local directory and a remote host over rsync",
     56     arg_required_else_help = true,
     57     verbatim_doc_comment
     58 )]
     59 pub struct Cli {
     60     /// SSH host alias (as configured in ~/.ssh/config) to sync with.
     61     #[arg(long, required = true)]
     62     host: String,
     63 
     64     /// Local directory (or single file) containing the line-oriented text.
     65     #[arg(long, required = true)]
     66     local: PathBuf,
     67 
     68     /// Remote directory (or single file) to sync from/to.
     69     #[arg(long, required = true)]
     70     remote: PathBuf,
     71 
     72     #[command(flatten)]
     73     mode: ModeFlags,
     74 
     75     #[command(flatten)]
     76     elevate: ElevateFlags,
     77 
     78     /// Exit with status 1 if --plan finds pending changes. Processing errors
     79     /// always exit with status 2.
     80     #[arg(long)]
     81     exit_code: bool,
     82 
     83     /// I/O timeout in seconds handed to rsync (0 disables the timeout).
     84     #[arg(long, default_value_t = 30)]
     85     timeout: u64,
     86 }
     87 
     88 /// The mutually exclusive plan/pull/push flags. `--plan` (preview) is the
     89 /// default when none is given.
     90 #[derive(Args, Debug)]
     91 #[group(required = false, multiple = false)]
     92 struct ModeFlags {
     93     /// Show the changes each direction would make, without writing anything
     94     /// (the default).
     95     #[arg(long)]
     96     plan: bool,
     97 
     98     /// Apply the merge to the local directory (remote → local).
     99     #[arg(long)]
    100     pull: bool,
    101 
    102     /// Apply the merge to the remote host (local → remote).
    103     #[arg(long)]
    104     push: bool,
    105 }
    106 
    107 impl ModeFlags {
    108     fn mode(&self) -> Mode {
    109         if self.push {
    110             Mode::Push
    111         } else if self.pull {
    112             Mode::Pull
    113         } else {
    114             Mode::Plan
    115         }
    116     }
    117 }
    118 
    119 /// The optional privilege-elevation flags. The remote rsync runs unprivileged
    120 /// unless one is given; the local rsync is never elevated.
    121 #[derive(Args, Debug)]
    122 #[group(required = false, multiple = false)]
    123 struct ElevateFlags {
    124     /// Elevate the remote rsync with `sudo` (via --rsync-path).
    125     #[arg(long)]
    126     sudo: bool,
    127 
    128     /// Elevate the remote rsync with `doas` (via --rsync-path).
    129     #[arg(long)]
    130     doas: bool,
    131 }
    132 
    133 impl ElevateFlags {
    134     /// The command to prefix rsync with, if any.
    135     fn command(&self) -> Option<&'static str> {
    136         if self.sudo {
    137             Some("sudo")
    138         } else if self.doas {
    139             Some("doas")
    140         } else {
    141             None
    142         }
    143     }
    144 }
    145 
    146 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
    147 enum Mode {
    148     Plan,
    149     Pull,
    150     Push,
    151 }
    152 
    153 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
    154 enum FileStatus {
    155     Created,
    156     Updated,
    157     Unchanged,
    158 }
    159 
    160 #[derive(Debug, Default)]
    161 struct Tally {
    162     created: u32,
    163     updated: u32,
    164     unchanged: u32,
    165     errors: u32,
    166 }
    167 
    168 impl Tally {
    169     fn record(&mut self, status: FileStatus) {
    170         match status {
    171             FileStatus::Created => self.created += 1,
    172             FileStatus::Updated => self.updated += 1,
    173             FileStatus::Unchanged => self.unchanged += 1,
    174         }
    175     }
    176 
    177     /// Number of files that would change (or did change).
    178     fn changed(&self) -> u32 {
    179         self.created + self.updated
    180     }
    181 
    182     fn print(&self, header: &str) {
    183         eprintln!("\n{header}:");
    184         eprintln!("  Created: {}", self.created);
    185         eprintln!("  Updated: {}", self.updated);
    186         eprintln!("  Unchanged: {}", self.unchanged);
    187         if self.errors > 0 {
    188             eprintln!("  Errors: {}", self.errors);
    189         }
    190     }
    191 }
    192 
    193 struct FileSyncWorker {
    194     host_alias: String,
    195     local_path: PathBuf,
    196     remote_path: PathBuf,
    197     mode: Mode,
    198     timeout: u64,
    199     elevate: Option<&'static str>,
    200 }
    201 
    202 /// One file to reconcile: where it lives locally, where its downloaded remote
    203 /// copy is, and where a push stages its merged result. `label` is used in
    204 /// progress and diff output.
    205 struct Pair {
    206     label: String,
    207     local: PathBuf,
    208     remote: PathBuf,
    209     staging: PathBuf,
    210 }
    211 
    212 impl FileSyncWorker {
    213     /// Single-file mode when --local is not an existing directory; otherwise
    214     /// the whole directory tree is synced.
    215     fn single_file(&self) -> bool {
    216         !self.local_path.is_dir()
    217     }
    218 
    219     fn run(&self) -> Result<Tally> {
    220         let single_file = self.single_file();
    221 
    222         // Always pull the remote into a temp dir so we have both sides to
    223         // compare; nothing is uploaded unless --push has changes to apply.
    224         let remote_tmp = tempfile::tempdir().context("Failed to create temporary directory")?;
    225         let remote_dir = remote_tmp.path();
    226         self.download(remote_dir, single_file)?;
    227 
    228         let action = match self.mode {
    229             Mode::Plan => "Planning",
    230             Mode::Pull => "Pulling",
    231             Mode::Push => "Pushing",
    232         };
    233         let unit = if single_file { "file" } else { "files" };
    234         eprintln!("{action} {unit} for {}", self.host_alias);
    235 
    236         // Staging dir holds the files we will upload when pushing.
    237         let staging_tmp = tempfile::tempdir().context("Failed to create staging directory")?;
    238         let staging = staging_tmp.path();
    239 
    240         let mut tally = Tally::default();
    241         for pair in self.build_pairs(remote_dir, staging, single_file)? {
    242             match self.process(&pair) {
    243                 Ok(status) => tally.record(status),
    244                 Err(e) => {
    245                     eprintln!("Error processing {}: {e:#}", pair.label);
    246                     tally.errors += 1;
    247                 }
    248             }
    249         }
    250 
    251         let header = match self.mode {
    252             Mode::Plan => "Plan summary",
    253             Mode::Pull => "Pull complete",
    254             Mode::Push => "Push complete",
    255         };
    256         tally.print(header);
    257 
    258         if self.mode == Mode::Push && tally.changed() > 0 {
    259             self.upload(staging, single_file)?;
    260         }
    261 
    262         Ok(tally)
    263     }
    264 
    265     /// Enumerate the file pair(s) to process: one per file in single-file
    266     /// mode, or one per file present in either directory tree otherwise.
    267     fn build_pairs(
    268         &self,
    269         remote_dir: &Path,
    270         staging: &Path,
    271         single_file: bool,
    272     ) -> Result<Vec<Pair>> {
    273         if single_file {
    274             let name = self.remote_file_name()?;
    275             return Ok(vec![Pair {
    276                 label: self.local_path.display().to_string(),
    277                 local: self.local_path.clone(),
    278                 remote: remote_dir.join(name),
    279                 staging: staging.join(name),
    280             }]);
    281         }
    282 
    283         let mut names = BTreeSet::new();
    284         collect_files(&self.local_path, &self.local_path, &mut names)?;
    285         collect_files(remote_dir, remote_dir, &mut names)?;
    286         Ok(names
    287             .into_iter()
    288             .map(|rel| Pair {
    289                 label: rel.display().to_string(),
    290                 local: self.local_path.join(&rel),
    291                 remote: remote_dir.join(&rel),
    292                 staging: staging.join(&rel),
    293             })
    294             .collect())
    295     }
    296 
    297     fn process(&self, pair: &Pair) -> Result<FileStatus> {
    298         let local = read_optional(&pair.local)?;
    299         let remote = read_optional(&pair.remote)?;
    300         let merged = merge(local.as_deref(), remote.as_deref());
    301 
    302         match self.mode {
    303             Mode::Plan => Ok(plan(
    304                 &pair.label,
    305                 local.as_deref(),
    306                 remote.as_deref(),
    307                 &merged,
    308             )),
    309             Mode::Pull => apply(&pair.label, local.as_deref(), &merged, &pair.local, "local"),
    310             Mode::Push => apply(
    311                 &pair.label,
    312                 remote.as_deref(),
    313                 &merged,
    314                 &pair.staging,
    315                 "remote",
    316             ),
    317         }
    318     }
    319 
    320     /// The file name component of --remote, required in single-file mode.
    321     fn remote_file_name(&self) -> Result<&std::ffi::OsStr> {
    322         self.remote_path
    323             .file_name()
    324             .context("--remote must name a file in single-file mode")
    325     }
    326 
    327     fn download(&self, dest: &Path, single_file: bool) -> Result<()> {
    328         // A trailing slash copies a directory's *contents*; without one rsync
    329         // copies the single file into the temp dir under its own name.
    330         let source = if single_file {
    331             format!("{}:{}", self.host_alias, self.remote_path.display())
    332         } else {
    333             format!("{}:{}/", self.host_alias, self.remote_path.display())
    334         };
    335         self.rsync(&source, &dest.to_string_lossy())
    336     }
    337 
    338     fn upload(&self, staging: &Path, single_file: bool) -> Result<()> {
    339         let (source, dest) = if single_file {
    340             let name = self.remote_file_name()?;
    341             (
    342                 staging.join(name).to_string_lossy().into_owned(),
    343                 format!("{}:{}", self.host_alias, self.remote_path.display()),
    344             )
    345         } else {
    346             (
    347                 format!("{}/", staging.display()),
    348                 format!("{}:{}/", self.host_alias, self.remote_path.display()),
    349             )
    350         };
    351         eprintln!("Uploading to {dest}");
    352         self.rsync(&source, &dest)
    353     }
    354 
    355     /// Run rsync with non-interactive SSH so it fails fast in automation
    356     /// instead of blocking on a password or host-key prompt. When elevation
    357     /// is requested it is applied to the *remote* rsync via --rsync-path; the
    358     /// local rsync always runs unprivileged.
    359     fn rsync(&self, source: &str, dest: &str) -> Result<()> {
    360         let mut cmd = Command::new("rsync");
    361         cmd.arg("-az").arg("-e").arg("ssh -o BatchMode=yes");
    362         if let Some(elevate) = self.elevate {
    363             cmd.arg(format!("--rsync-path={elevate} rsync"));
    364         }
    365         if self.timeout > 0 {
    366             cmd.arg(format!("--timeout={}", self.timeout));
    367         }
    368         cmd.arg(source).arg(dest);
    369 
    370         let status = cmd
    371             .status()
    372             .context("Failed to execute rsync (is it installed?)")?;
    373         if !status.success() {
    374             anyhow::bail!("rsync exited with status: {status}");
    375         }
    376         Ok(())
    377     }
    378 }
    379 
    380 /// Which end of the sync a planned change applies to.
    381 #[derive(Clone, Copy)]
    382 enum Side {
    383     Local,
    384     Remote,
    385 }
    386 
    387 impl Side {
    388     /// The tag printed in front of this side's diff, colour-coded so local and
    389     /// remote are easy to tell apart at a glance.
    390     fn tag(self) -> console::StyledObject<&'static str> {
    391         match self {
    392             Side::Local => style(" local ").black().on_cyan().bold(),
    393             Side::Remote => style(" remote ").black().on_magenta().bold(),
    394         }
    395     }
    396 }
    397 
    398 /// Preview the changes both directions would make to a single file, printing
    399 /// a per-side diff for each side that has pending changes. Writes nothing.
    400 fn plan(label: &str, local: Option<&str>, remote: Option<&str>, merged: &str) -> FileStatus {
    401     let local_current = local.unwrap_or("");
    402     let remote_current = remote.unwrap_or("");
    403     let local_changes = local_current != merged;
    404     let remote_changes = remote_current != merged;
    405 
    406     if !local_changes && !remote_changes {
    407         return FileStatus::Unchanged;
    408     }
    409 
    410     println!("{}", style(label).bold().underlined());
    411     if local_changes {
    412         print_side_diff(Side::Local, local_current, merged);
    413     }
    414     if remote_changes {
    415         print_side_diff(Side::Remote, remote_current, merged);
    416     }
    417     println!();
    418     FileStatus::Updated
    419 }
    420 
    421 /// Apply the merged content to one side, writing `dest` atomically. `current`
    422 /// is that side's existing content (`None` if the file is absent there).
    423 fn apply(
    424     label: &str,
    425     current: Option<&str>,
    426     merged: &str,
    427     dest: &Path,
    428     side: &str,
    429 ) -> Result<FileStatus> {
    430     let exists = current.is_some();
    431     if current.unwrap_or("") == merged {
    432         return Ok(FileStatus::Unchanged);
    433     }
    434 
    435     // Never create an empty new file.
    436     if !merged.is_empty() || exists {
    437         write_atomic(dest, merged)?;
    438     }
    439 
    440     if exists {
    441         eprintln!("Updating {label} on {side}");
    442         Ok(FileStatus::Updated)
    443     } else {
    444         eprintln!("Creating {label} on {side}");
    445         Ok(FileStatus::Created)
    446     }
    447 }
    448 
    449 /// Recursively collect the paths of regular files under `dir`, expressed
    450 /// relative to `root`. Symlinks are skipped to avoid traversal loops.
    451 fn collect_files(root: &Path, dir: &Path, out: &mut BTreeSet<PathBuf>) -> Result<()> {
    452     for entry in fs::read_dir(dir).with_context(|| format!("Failed to read {}", dir.display()))? {
    453         let entry = entry?;
    454         let path = entry.path();
    455         let file_type = entry.file_type()?;
    456         if file_type.is_dir() {
    457             collect_files(root, &path, out)?;
    458         } else if file_type.is_file() {
    459             let rel = path
    460                 .strip_prefix(root)
    461                 .with_context(|| format!("{} is not under {}", path.display(), root.display()))?;
    462             out.insert(rel.to_path_buf());
    463         }
    464     }
    465     Ok(())
    466 }
    467 
    468 /// Read a file, returning `None` if it does not exist.
    469 fn read_optional(path: &Path) -> Result<Option<String>> {
    470     match fs::read_to_string(path) {
    471         Ok(s) => Ok(Some(s)),
    472         Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
    473         Err(e) => Err(e).with_context(|| format!("Failed to read {}", path.display())),
    474     }
    475 }
    476 
    477 /// Write `contents` to `path` atomically: stage in a temp file in the same
    478 /// directory and rename into place, so a crash mid-write can't truncate or
    479 /// corrupt the target.
    480 fn write_atomic(path: &Path, contents: &str) -> Result<()> {
    481     let dir = path
    482         .parent()
    483         .with_context(|| format!("{} has no parent directory", path.display()))?;
    484     fs::create_dir_all(dir).with_context(|| format!("Failed to create {}", dir.display()))?;
    485 
    486     let mut tmp = tempfile::NamedTempFile::new_in(dir)
    487         .with_context(|| format!("Failed to create temp file in {}", dir.display()))?;
    488     tmp.write_all(contents.as_bytes())
    489         .with_context(|| format!("Failed to write {}", path.display()))?;
    490     tmp.persist(path)
    491         .map_err(|e| e.error)
    492         .with_context(|| format!("Failed to persist {}", path.display()))?;
    493     Ok(())
    494 }
    495 
    496 /// Merge two sides into the file content dam would write.
    497 ///
    498 /// When the file exists on both sides the result is the de-duplicated union
    499 /// of their non-empty lines: the local lines in their original order, then
    500 /// the lines only the remote has. Order is never changed, so pushing a
    501 /// reordered file propagates that order instead of fighting it, and merging
    502 /// an already-merged file is a no-op. When the file exists on only one side
    503 /// that side's content is propagated verbatim.
    504 fn merge(local: Option<&str>, remote: Option<&str>) -> String {
    505     match (local, remote) {
    506         (Some(l), Some(r)) => {
    507             let merged = union_lines(l, r);
    508             if merged.is_empty() {
    509                 String::new()
    510             } else {
    511                 format!("{}\n", merged.join("\n"))
    512             }
    513         }
    514         (Some(only), None) | (None, Some(only)) => only.to_string(),
    515         (None, None) => String::new(),
    516     }
    517 }
    518 
    519 /// De-duplicated union of the non-blank lines of `a` and `b`, in first-seen
    520 /// order: all of `a`'s lines, then the lines only `b` has. A trailing
    521 /// carriage return is stripped so CRLF and LF inputs compare equal.
    522 fn union_lines(a: &str, b: &str) -> Vec<String> {
    523     let mut seen = HashSet::new();
    524     a.lines()
    525         .chain(b.lines())
    526         .map(|line| line.trim_end_matches('\r'))
    527         .filter(|line| !line.trim().is_empty())
    528         .filter(|line| seen.insert(*line))
    529         .map(str::to_string)
    530         .collect()
    531 }
    532 
    533 /// Print one side's planned change to stdout: a tagged header naming the side
    534 /// with its added/removed counts, followed by only the changed lines.
    535 ///
    536 /// `current` is that side's existing content and `merged` the proposed result,
    537 /// so `+` lines are gained and `-` lines lost. Unchanged lines are omitted to
    538 /// keep the plan focused on the delta.
    539 fn print_side_diff(side: Side, current: &str, merged: &str) {
    540     // Diff the lines themselves rather than the raw text: `lines()` drops the
    541     // terminators, so a missing trailing newline never shows up as a change.
    542     let current: Vec<&str> = current.lines().collect();
    543     let merged: Vec<&str> = merged.lines().collect();
    544     let diff = TextDiff::from_slices(&current, &merged);
    545 
    546     // `true` marks an inserted line, `false` a deleted one; equal lines are
    547     // dropped here so the body below is purely the delta.
    548     let mut changes: Vec<(bool, &str)> = Vec::new();
    549     let (mut added, mut removed) = (0u32, 0u32);
    550     for change in diff.iter_all_changes() {
    551         match change.tag() {
    552             ChangeTag::Insert => added += 1,
    553             ChangeTag::Delete => removed += 1,
    554             ChangeTag::Equal => continue,
    555         }
    556         changes.push((change.tag() == ChangeTag::Insert, change.value()));
    557     }
    558 
    559     println!(
    560         "  {}  {} {}",
    561         side.tag(),
    562         style(format!("+{added}")).green(),
    563         style(format!("-{removed}")).red(),
    564     );
    565     for (inserted, line) in changes {
    566         if inserted {
    567             println!("    {}", style(format!("+{line}")).green());
    568         } else {
    569             println!("    {}", style(format!("-{line}")).red());
    570         }
    571     }
    572 }
    573 
    574 /// Parse the command line and run the configured sync, returning the process
    575 /// exit code.
    576 ///
    577 /// # Errors
    578 ///
    579 /// Returns an error when the sync cannot run at all: rsync fails or is
    580 /// missing, or a temporary directory cannot be created. Per-file processing
    581 /// failures do not abort the run; they are reported on stderr and mapped to
    582 /// exit code 2.
    583 pub fn run() -> Result<ExitCode> {
    584     let cli = Cli::parse();
    585 
    586     let mode = cli.mode.mode();
    587 
    588     let worker = FileSyncWorker {
    589         host_alias: cli.host,
    590         local_path: cli.local,
    591         remote_path: cli.remote,
    592         mode,
    593         timeout: cli.timeout,
    594         elevate: cli.elevate.command(),
    595     };
    596     let tally = worker.run()?;
    597 
    598     let code = if tally.errors > 0 {
    599         ExitCode::from(2)
    600     } else if cli.exit_code && mode == Mode::Plan && tally.changed() > 0 {
    601         ExitCode::from(1)
    602     } else {
    603         ExitCode::SUCCESS
    604     };
    605     Ok(code)
    606 }
    607 
    608 #[cfg(test)]
    609 mod tests {
    610     use super::*;
    611 
    612     #[test]
    613     fn union_dedups_and_drops_blank_lines() {
    614         let got = union_lines("banana\napple\n\napple", "cherry\napple");
    615         assert_eq!(got, vec!["banana", "apple", "cherry"]);
    616     }
    617 
    618     #[test]
    619     fn union_keeps_local_order_and_appends_remote_extras() {
    620         let got = union_lines("file10\nfile2\nfile1", "file0\nfile2");
    621         assert_eq!(got, vec!["file10", "file2", "file1", "file0"]);
    622     }
    623 
    624     #[test]
    625     fn union_normalises_crlf() {
    626         let got = union_lines("a\r\nb\r\n", "b\r\nc\r");
    627         assert_eq!(got, vec!["a", "b", "c"]);
    628     }
    629 
    630     #[test]
    631     fn merge_unions_when_both_present() {
    632         assert_eq!(merge(Some("b\na"), Some("c\na")), "b\na\nc\n");
    633     }
    634 
    635     #[test]
    636     fn merge_keeps_local_order_when_sides_hold_the_same_lines() {
    637         // A reordered playlist: pushing must propagate the local order, so a
    638         // follow-up plan sees the local side as unchanged (no push/pull loop).
    639         let merged = merge(Some("t3\nt1\nt2\n"), Some("t1\nt2\nt3\n"));
    640         assert_eq!(merged, "t3\nt1\nt2\n");
    641         assert_eq!(
    642             plan("f", Some("t3\nt1\nt2\n"), Some(&merged), &merged),
    643             FileStatus::Unchanged
    644         );
    645     }
    646 
    647     #[test]
    648     fn merge_is_idempotent() {
    649         // Once both sides hold the merged content, further runs change nothing.
    650         let merged = merge(Some("z\nm\na\n"), Some("m\nq\n"));
    651         assert_eq!(merge(Some(&merged), Some(&merged)), merged);
    652     }
    653 
    654     #[test]
    655     fn merge_propagates_single_side_verbatim() {
    656         // Order is preserved and nothing is sorted when only one side has it.
    657         assert_eq!(merge(Some("z\na\n"), None), "z\na\n");
    658         assert_eq!(merge(None, Some("z\na\n")), "z\na\n");
    659     }
    660 
    661     #[test]
    662     fn merge_is_empty_when_neither_present() {
    663         assert_eq!(merge(None, None), "");
    664     }
    665 
    666     #[test]
    667     fn merge_of_blank_only_content_is_empty() {
    668         assert_eq!(merge(Some("\n\n"), Some("   ")), "");
    669     }
    670 
    671     #[test]
    672     fn plan_reports_unchanged_when_sides_match() {
    673         let merged = merge(Some("a\nb\n"), Some("a\nb\n"));
    674         assert_eq!(
    675             plan("f", Some("a\nb\n"), Some("a\nb\n"), &merged),
    676             FileStatus::Unchanged
    677         );
    678     }
    679 
    680     #[test]
    681     fn plan_reports_change_when_sides_differ() {
    682         let merged = merge(Some("a"), Some("b"));
    683         assert_eq!(
    684             plan("f", Some("a"), Some("b"), &merged),
    685             FileStatus::Updated
    686         );
    687     }
    688 
    689     #[test]
    690     fn apply_creates_updates_and_skips() {
    691         let dir = tempfile::tempdir().unwrap();
    692         let dest = dir.path().join("f");
    693 
    694         // Absent target with content -> Created and written.
    695         let created = apply("f", None, "a\nb\n", &dest, "local").unwrap();
    696         assert_eq!(created, FileStatus::Created);
    697         assert_eq!(fs::read_to_string(&dest).unwrap(), "a\nb\n");
    698 
    699         // Existing target, new content -> Updated.
    700         let updated = apply("f", Some("a\nb\n"), "a\nb\nc\n", &dest, "local").unwrap();
    701         assert_eq!(updated, FileStatus::Updated);
    702         assert_eq!(fs::read_to_string(&dest).unwrap(), "a\nb\nc\n");
    703 
    704         // No difference -> Unchanged, no write needed.
    705         let same = apply("f", Some("x"), "x", &dest, "local").unwrap();
    706         assert_eq!(same, FileStatus::Unchanged);
    707     }
    708 }