tools

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

commit a0e2fd26519053c5417e0e5506eb37d26065aea1
parent c87bff7a79050d5e10401495b2a1b7a54149b075
Author: mtmn <miro@haravara.org>
Date:   Sat, 13 Jun 2026 22:22:09 +0200

dam: refactor, tests, add plan/pull/push modes

Diffstat:
Mdam/README.md | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mdam/src/main.rs | 813++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
2 files changed, 606 insertions(+), 294 deletions(-)

diff --git a/dam/README.md b/dam/README.md @@ -1,8 +1,32 @@ # dam (diff-and-merge) -Syncs line-oriented text files between a local directory and a remote host over rsync. By default shows a coloured diff; pass `--sync` to apply changes. +Syncs line-oriented text files between a local directory and a remote host +over rsync, with a plan/apply workflow: `--plan` (the default) previews the +changes, `--pull` applies them locally, `--push` applies them to the remote. -Merge strategy: union of local and remote lines, deduplicated and sorted with natural ordering. +**Merge strategy:** when a file exists on both sides, the result is the +sorted, de-duplicated **union** of its local and remote lines (natural +ordering). When a file exists on only one side it is propagated verbatim. The +union is symmetric, so the merged result is identical regardless of direction; +the mode only chooses which side gets written. + +`dam` is intended for list-like data (allow-lists, hosts files, word lists). +Blank lines, duplicates, and original ordering are **not** preserved by the +merge. + +`--local` and `--remote` may be **directories or single files**. When +`--local` is an existing directory, `dam` syncs the whole tree (recursing into +subdirectories); otherwise it treats both paths as a single file. To mirror a +directory into a new local location, create that directory first. + +> **dam never deletes.** Because the merge is a union, a line removed on one +> side reappears from the other. Removing data is a manual edit on both sides +> (or a one-shot `rsync`), not something `dam` does. + +Local files are written atomically (staged in a temp file and renamed), so an +interrupted run can't leave a half-written file. rsync runs with +`ssh -o BatchMode=yes` and an I/O `--timeout` so it fails fast in automation +rather than blocking on a prompt. ## Building @@ -13,26 +37,63 @@ cargo build --release ## Usage ```sh -dam --host <host> --local <dir> (--remote <dir> | --same-as-local) [--sync] [--reverse] +dam --host <host> --local <dir> --remote <dir> [--plan | --pull | --push] [--sudo | --doas] [--exit-code] [--timeout <secs>] ``` +### Modes + +`--plan`, `--pull`, and `--push` are mutually exclusive; `--plan` is the default. + +| Flag | Description | +|------|-------------| +| `--plan` | Show the changes each direction would make. Writes nothing. (default) | +| `--pull` | Apply the merge to the local directory (remote → local) | +| `--push` | Apply the merge to the remote host (local → remote) | + ### Options | Flag | Description | |------|-------------| -| `--host` | SSH host alias to sync with | +| `--host` | SSH host alias (from `~/.ssh/config`) to sync with | | `--local` | Local directory containing the text files | -| `--remote` | Remote path to sync from/to | -| `--same-as-local` | Use the same path on the remote as `--local` | -| `--sync` | Apply changes (default: dry-run diff only) | -| `--reverse` | Merge local → remote instead of remote → local | +| `--remote` | Remote directory to sync from/to | +| `--sudo` | Elevate the **remote** rsync with `sudo` (via `--rsync-path`). Mutually exclusive with `--doas` | +| `--doas` | Elevate the **remote** rsync with `doas` (via `--rsync-path`). Mutually exclusive with `--sudo` | +| `--exit-code` | Exit 1 if `--plan` finds pending changes. Useful in CI | +| `--timeout` | rsync I/O timeout in seconds (default: 30; 0 disables) | -### Example +Run `dam --help` for full details. + +`--sudo`/`--doas` elevate only the rsync process on the remote host (e.g. when +the remote path is root-owned) by passing `--rsync-path="sudo rsync"`. The +local rsync always runs unprivileged, so your own SSH config and keys are used. + +### Exit status + +| Code | Meaning | +|------|---------| +| `0` | Success; nothing pending (or changes were applied with `--pull`/`--push`) | +| `1` | `--exit-code` was set with `--plan` and pending changes were found | +| `2` | One or more files failed to process | + +Progress and the summary go to **stderr**; the plan diff goes to **stdout**, +so `dam --plan … > changes.diff` captures only the differences. + +### Examples ```sh -# Preview changes from remote -dam --host myserver --local ~/lists --same-as-local +# See what would change on either side +dam --host myserver --local ~/lists --remote /srv/lists + +# Bring the local directory up to date +dam --host myserver --local ~/lists --remote /srv/lists --pull + +# Bring the remote up to date +dam --host myserver --local ~/lists --remote /srv/lists --push + +# Sync a single file +dam --host myserver --local ~/hosts.allow --remote /etc/hosts.allow --pull -# Apply changes -dam --host myserver --local ~/lists --same-as-local --sync +# Fail if the two sides have drifted +dam --host myserver --local ~/lists --remote /srv/lists --plan --exit-code ``` diff --git a/dam/src/main.rs b/dam/src/main.rs @@ -1,377 +1,628 @@ use anyhow::{Context, Result}; -use clap::Parser; +use clap::{Args, Parser}; use console::style; use similar::{ChangeTag, TextDiff}; -use std::collections::HashSet; +use std::collections::{BTreeSet, HashSet}; use std::fs; +use std::io::Write; use std::path::{Path, PathBuf}; -use std::process::Command; - +use std::process::{Command, ExitCode}; + +/// dam (diff-and-merge) — sync line-oriented text files between a local +/// directory and a remote host over rsync. +/// +/// dam merges files as the sorted, de-duplicated union of their local and +/// remote lines. It is intended for list-like data (allow-lists, hosts files, +/// word lists), not for arbitrary documents — blank lines, duplicates, and +/// original ordering are not preserved by the merge. +/// +/// --local and --remote may be directories or single files. When --local is +/// an existing directory dam syncs the whole tree (recursing into +/// subdirectories); otherwise it treats both paths as a single file. To mirror +/// a directory into a new local location, create that directory first. +/// +/// Because the merge is a union, dam never removes a line: a line deleted on +/// one side reappears from the other. Removing data is a manual edit on both +/// sides, or a one-shot rsync. +/// +/// dam has a plan/apply workflow, with three mutually exclusive modes: +/// +/// --plan (default) Show the changes each direction would make. Writes +/// nothing. +/// --pull Apply the merge to the local directory (remote → local). +/// --push Apply the merge to the remote host (local → remote). +/// +/// Progress and the summary are written to stderr and the plan diff to stdout, +/// so `dam --plan > changes.diff` captures just the differences. +/// +/// Examples: +/// # See what would change on either side +/// dam --host myserver --local ~/lists --remote /srv/lists +/// +/// # Bring the local directory up to date +/// dam --host myserver --local ~/lists --remote /srv/lists --pull +/// +/// # Bring the remote up to date +/// dam --host myserver --local ~/lists --remote /srv/lists --push +/// +/// # Fail (exit 1) if the two sides have drifted — handy in CI +/// dam --host myserver --local ~/lists --remote /srv/lists --plan --exit-code #[derive(Parser, Debug)] -#[command(author, version, about, long_about = None, arg_required_else_help = true)] +#[command(author, version, arg_required_else_help = true, verbatim_doc_comment)] struct Cli { + /// SSH host alias (as configured in ~/.ssh/config) to sync with. #[arg(long, required = true)] host: String, + /// Local directory (or single file) containing the line-oriented text. #[arg(long, required = true)] local: PathBuf, - #[arg(long)] - remote: Option<PathBuf>, + /// Remote directory (or single file) to sync from/to. + #[arg(long, required = true)] + remote: PathBuf, - #[arg(long, conflicts_with = "remote")] - same_as_local: bool, + #[command(flatten)] + mode: ModeFlags, - #[arg(long)] - reverse: bool, + #[command(flatten)] + elevate: ElevateFlags, + /// Exit with status 1 if --plan finds pending changes. Processing errors + /// always exit with status 2. #[arg(long)] - sync: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq)] -enum FileStatus { - Created, - Updated, - Unchanged, -} + exit_code: bool, -#[derive(Debug)] -struct FileSyncWorker { - host_alias: String, - local_path: PathBuf, - remote_path: PathBuf, - sync: bool, - reverse: bool, + /// I/O timeout in seconds handed to rsync (0 disables the timeout). + #[arg(long, default_value_t = 30)] + timeout: u64, } -impl FileSyncWorker { - fn new( - host_alias: String, - local_path: PathBuf, - remote_path: PathBuf, - sync: bool, - reverse: bool, - ) -> Self { - Self { - host_alias, - local_path, - remote_path, - sync, - reverse, - } - } +/// The mutually exclusive plan/pull/push flags. `--plan` (preview) is the +/// default when none is given. +#[derive(Args, Debug)] +#[group(required = false, multiple = false)] +struct ModeFlags { + /// Show the changes each direction would make, without writing anything + /// (the default). + #[arg(long)] + plan: bool, - fn sync(&self) -> Result<()> { - fs::create_dir_all(&self.local_path).context("Failed to create local files directory")?; + /// Apply the merge to the local directory (remote → local). + #[arg(long)] + pull: bool, - let temp_dir = tempfile::tempdir().context("Failed to create temporary directory")?; - let temp_path = temp_dir.path(); + /// Apply the merge to the remote host (local → remote). + #[arg(long)] + push: bool, +} - if self.reverse { - println!("Syncing files to {}", self.host_alias); +impl ModeFlags { + fn mode(&self) -> Mode { + if self.push { + Mode::Push + } else if self.pull { + Mode::Pull } else { - println!("Syncing files from {}", self.host_alias); + Mode::Plan } + } +} - let remote_src = format!("{}:{}/", self.host_alias, self.remote_path.display()); - - let status = Command::new("rsync") - .arg("-az") - .arg(&remote_src) - .arg(temp_path) - .status() - .context("Failed to execute rsync")?; +/// The optional privilege-elevation flags. The remote rsync runs unprivileged +/// unless one is given; the local rsync is never elevated. +#[derive(Args, Debug)] +#[group(required = false, multiple = false)] +struct ElevateFlags { + /// Elevate the remote rsync with `sudo` (via --rsync-path). + #[arg(long)] + sudo: bool, - if !status.success() { - anyhow::bail!("Rsync failed with status: {status}"); - } + /// Elevate the remote rsync with `doas` (via --rsync-path). + #[arg(long)] + doas: bool, +} - if self.reverse { - self.process_reverse_sync(temp_path) +impl ElevateFlags { + /// The command to prefix rsync with, if any. + fn command(&self) -> Option<&'static str> { + if self.sudo { + Some("sudo") + } else if self.doas { + Some("doas") } else { - self.process_normal_sync(temp_path) + None } } +} - fn process_normal_sync(&self, temp_path: &Path) -> Result<()> { - let mut created = 0; - let mut updated = 0; - let mut unchanged = 0; - let mut errors = 0; +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Mode { + Plan, + Pull, + Push, +} - let entries = fs::read_dir(temp_path).context("Failed to read temp directory")?; +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum FileStatus { + Created, + Updated, + Unchanged, +} - // Process files sequentially - for entry in entries { - let entry = entry?; - let path = entry.path(); +#[derive(Debug, Default)] +struct Tally { + created: u32, + updated: u32, + unchanged: u32, + errors: u32, +} - if path.is_file() { - match self.process_files(&path) { - Ok(FileStatus::Created) => created += 1, - Ok(FileStatus::Updated) => updated += 1, - Ok(FileStatus::Unchanged) => unchanged += 1, - Err(_) => errors += 1, - } - } +impl Tally { + fn record(&mut self, status: FileStatus) { + match status { + FileStatus::Created => self.created += 1, + FileStatus::Updated => self.updated += 1, + FileStatus::Unchanged => self.unchanged += 1, } + } - println!("\nSync completed:"); - println!(" Created: {created}"); - println!(" Updated: {updated}"); - println!(" Unchanged: {unchanged}"); - if errors > 0 { - println!(" Errors: {errors}"); - } + /// Number of files that would change (or did change). + fn changed(&self) -> u32 { + self.created + self.updated + } - Ok(()) + fn print(&self, header: &str) { + eprintln!("\n{header}:"); + eprintln!(" Created: {}", self.created); + eprintln!(" Updated: {}", self.updated); + eprintln!(" Unchanged: {}", self.unchanged); + if self.errors > 0 { + eprintln!(" Errors: {}", self.errors); + } } +} + +struct FileSyncWorker { + host_alias: String, + local_path: PathBuf, + remote_path: PathBuf, + mode: Mode, + timeout: u64, + elevate: Option<&'static str>, +} - fn process_reverse_sync(&self, remote_temp_path: &Path) -> Result<()> { - let staging_dir = tempfile::tempdir().context("Failed to create staging directory")?; - let staging_path = staging_dir.path(); +/// One file to reconcile: where it lives locally, where its downloaded remote +/// copy is, and where a push stages its merged result. `label` is used in +/// progress and diff output. +struct Pair { + label: String, + local: PathBuf, + remote: PathBuf, + staging: PathBuf, +} - let mut created = 0; - let mut updated = 0; - let mut unchanged = 0; - let mut errors = 0; +impl FileSyncWorker { + /// Single-file mode when --local is not an existing directory; otherwise + /// the whole directory tree is synced. + fn single_file(&self) -> bool { + !self.local_path.is_dir() + } - let entries = fs::read_dir(&self.local_path).context("Failed to read local directory")?; + fn run(&self) -> Result<Tally> { + let single_file = self.single_file(); - for entry in entries { - let entry = entry?; - let path = entry.path(); + // Always pull the remote into a temp dir so we have both sides to + // compare; nothing is uploaded unless --push has changes to apply. + let remote_tmp = tempfile::tempdir().context("Failed to create temporary directory")?; + let remote_dir = remote_tmp.path(); + self.download(remote_dir, single_file)?; - if path.is_file() { - match self.process_local_file(&path, remote_temp_path, staging_path) { - Ok(FileStatus::Created) => created += 1, - Ok(FileStatus::Updated) => updated += 1, - Ok(FileStatus::Unchanged) => unchanged += 1, - Err(_) => errors += 1, + let action = match self.mode { + Mode::Plan => "Planning", + Mode::Pull => "Pulling", + Mode::Push => "Pushing", + }; + let unit = if single_file { "file" } else { "files" }; + eprintln!("{action} {unit} for {}", self.host_alias); + + // Staging dir holds the files we will upload when pushing. + let staging_tmp = tempfile::tempdir().context("Failed to create staging directory")?; + let staging = staging_tmp.path(); + + let mut tally = Tally::default(); + for pair in self.build_pairs(remote_dir, staging, single_file)? { + match self.process(&pair) { + Ok(status) => tally.record(status), + Err(e) => { + eprintln!("Error processing {}: {e:#}", pair.label); + tally.errors += 1; } } } - println!("\nSync completed (Reverse):"); - println!(" Created: {created}"); - println!(" Updated: {updated}"); - println!(" Unchanged: {unchanged}"); - if errors > 0 { - println!(" Errors: {errors}"); - } + let header = match self.mode { + Mode::Plan => "Plan summary", + Mode::Pull => "Pull complete", + Mode::Push => "Push complete", + }; + tally.print(header); - if self.sync { - println!("Uploading to {}:{}/", self.host_alias, self.remote_path.display()); - let remote_dest = format!("{}:{}/", self.host_alias, self.remote_path.display()); - let status = Command::new("rsync") - .arg("-az") - .arg(format!("{}/", staging_path.display())) - .arg(&remote_dest) - .status() - .context("Failed to execute rsync upload")?; - - if !status.success() { - anyhow::bail!("Rsync upload failed with status: {status}"); - } + if self.mode == Mode::Push && tally.changed() > 0 { + self.upload(staging, single_file)?; } - Ok(()) + Ok(tally) } - fn process_files(&self, temp_file_path: &Path) -> Result<FileStatus> { - let filename = temp_file_path - .file_name() - .and_then(|n| n.to_str()) - .context("Invalid filename")?; - - let remote_content = fs::read_to_string(temp_file_path) - .with_context(|| format!("Error reading temp file {filename}"))?; + /// Enumerate the file pair(s) to process: one per file in single-file + /// mode, or one per file present in either directory tree otherwise. + fn build_pairs( + &self, + remote_dir: &Path, + staging: &Path, + single_file: bool, + ) -> Result<Vec<Pair>> { + if single_file { + let name = self.remote_file_name()?; + return Ok(vec![Pair { + label: self.local_path.display().to_string(), + local: self.local_path.clone(), + remote: remote_dir.join(name), + staging: staging.join(name), + }]); + } - let remote_entries: Vec<String> = remote_content.lines().map(ToString::to_string).collect(); + let mut names = BTreeSet::new(); + collect_files(&self.local_path, &self.local_path, &mut names)?; + collect_files(remote_dir, remote_dir, &mut names)?; + Ok(names + .into_iter() + .map(|rel| Pair { + label: rel.display().to_string(), + local: self.local_path.join(&rel), + remote: remote_dir.join(&rel), + staging: staging.join(&rel), + }) + .collect()) + } - self.merge_and_write(filename, remote_entries) + fn process(&self, pair: &Pair) -> Result<FileStatus> { + let local = read_optional(&pair.local)?; + let remote = read_optional(&pair.remote)?; + let merged = merge(local.as_deref(), remote.as_deref()); + + match self.mode { + Mode::Plan => Ok(plan( + &pair.label, + local.as_deref(), + remote.as_deref(), + &merged, + )), + Mode::Pull => apply(&pair.label, local.as_deref(), &merged, &pair.local, "local"), + Mode::Push => apply( + &pair.label, + remote.as_deref(), + &merged, + &pair.staging, + "remote", + ), + } } - fn process_local_file( - &self, - local_file_path: &Path, - remote_temp_path: &Path, - staging_path: &Path, - ) -> Result<FileStatus> { - let filename = local_file_path + /// The file name component of --remote, required in single-file mode. + fn remote_file_name(&self) -> Result<&std::ffi::OsStr> { + self.remote_path .file_name() - .and_then(|n| n.to_str()) - .context("Invalid filename")?; - - let local_content = fs::read_to_string(local_file_path)?; - let local_entries: Vec<String> = local_content.lines().map(ToString::to_string).collect(); - - let remote_file_path = remote_temp_path.join(filename); - let remote_exists = remote_file_path.exists(); - - let remote_entries = if remote_exists { - let c = fs::read_to_string(&remote_file_path)?; - c.lines().map(ToString::to_string).collect() - } else { - Vec::new() - }; - - let final_entries = if remote_exists { - Self::merge_entries(local_entries, remote_entries) - } else { - local_entries - }; + .context("--remote must name a file in single-file mode") + } - let new_content = if final_entries.is_empty() { - String::new() + fn download(&self, dest: &Path, single_file: bool) -> Result<()> { + // A trailing slash copies a directory's *contents*; without one rsync + // copies the single file into the temp dir under its own name. + let source = if single_file { + format!("{}:{}", self.host_alias, self.remote_path.display()) } else { - format!("{}\n", final_entries.join("\n")) + format!("{}:{}/", self.host_alias, self.remote_path.display()) }; + self.rsync(&source, &dest.to_string_lossy()) + } - let original_remote_content = if remote_exists { - fs::read_to_string(&remote_file_path)? + fn upload(&self, staging: &Path, single_file: bool) -> Result<()> { + let (source, dest) = if single_file { + let name = self.remote_file_name()?; + ( + staging.join(name).to_string_lossy().into_owned(), + format!("{}:{}", self.host_alias, self.remote_path.display()), + ) } else { - String::new() + ( + format!("{}/", staging.display()), + format!("{}:{}/", self.host_alias, self.remote_path.display()), + ) }; + eprintln!("Uploading to {dest}"); + self.rsync(&source, &dest) + } - if original_remote_content == new_content { - return Ok(FileStatus::Unchanged); + /// Run rsync with non-interactive SSH so it fails fast in automation + /// instead of blocking on a password or host-key prompt. When elevation + /// is requested it is applied to the *remote* rsync via --rsync-path; the + /// local rsync always runs unprivileged. + fn rsync(&self, source: &str, dest: &str) -> Result<()> { + let mut cmd = Command::new("rsync"); + cmd.arg("-az").arg("-e").arg("ssh -o BatchMode=yes"); + if let Some(elevate) = self.elevate { + cmd.arg(format!("--rsync-path={elevate} rsync")); } - - if !self.sync { - println!("Diff for {filename} (Reverse):"); - let diff = TextDiff::from_lines(&original_remote_content, &new_content); - for change in diff.iter_all_changes() { - let (sign, style) = match change.tag() { - ChangeTag::Delete => ("-", style(change).red()), - ChangeTag::Insert => ("+", style(change).green()), - ChangeTag::Equal => (" ", style(change)), - }; - print!("{sign}{style}"); - } - return Ok(if remote_exists { - FileStatus::Updated - } else { - FileStatus::Created - }); + if self.timeout > 0 { + cmd.arg(format!("--timeout={}", self.timeout)); } + cmd.arg(source).arg(dest); - // Write to staging - let staging_file = staging_path.join(filename); - fs::write(&staging_file, new_content)?; - - if remote_exists { - println!("Updating: {filename}"); - Ok(FileStatus::Updated) - } else { - println!("Creating: {filename}"); - Ok(FileStatus::Created) + let status = cmd + .status() + .context("Failed to execute rsync (is it installed?)")?; + if !status.success() { + anyhow::bail!("rsync exited with status: {status}"); } + Ok(()) } +} - fn merge_and_write(&self, filename: &str, remote_entries: Vec<String>) -> Result<FileStatus> { - let local_files = self.local_path.join(filename); - let exists = local_files.exists(); +/// Preview the changes both directions would make to a single file, printing +/// a diff per side that has pending changes. Writes nothing. +fn plan(label: &str, local: Option<&str>, remote: Option<&str>, merged: &str) -> FileStatus { + let local_current = local.unwrap_or(""); + let remote_current = remote.unwrap_or(""); + let local_changes = local_current != merged; + let remote_changes = remote_current != merged; - let final_entries = if exists { - let local_content = fs::read_to_string(&local_files)?; - let local_entries = local_content.lines().map(ToString::to_string).collect(); - Self::merge_entries(local_entries, remote_entries) - } else { - // For new files, copy remote content as-is without filtering or sorting - remote_entries - }; - - let new_content = if final_entries.is_empty() { - String::new() - } else { - format!("{}\n", final_entries.join("\n")) - }; + if !local_changes && !remote_changes { + return FileStatus::Unchanged; + } - if exists { - let current_content = fs::read_to_string(&local_files)?; - if current_content == new_content { - return Ok(FileStatus::Unchanged); - } - } + println!("{label}"); + if local_changes { + print_diff(" --pull would update local:", local_current, merged); + } + if remote_changes { + print_diff(" --push would update remote:", remote_current, merged); + } + FileStatus::Updated +} - if !self.sync { - let current_content = if exists { - fs::read_to_string(&local_files)? - } else { - String::new() - }; - - println!("Diff for {filename}:"); - let diff = TextDiff::from_lines(&current_content, &new_content); - for change in diff.iter_all_changes() { - let (sign, style) = match change.tag() { - ChangeTag::Delete => ("-", style(change).red()), - ChangeTag::Insert => ("+", style(change).green()), - ChangeTag::Equal => (" ", style(change)), - }; - print!("{sign}{style}"); - } +/// Apply the merged content to one side, writing `dest` atomically. `current` +/// is that side's existing content (`None` if the file is absent there). +fn apply( + label: &str, + current: Option<&str>, + merged: &str, + dest: &Path, + side: &str, +) -> Result<FileStatus> { + let exists = current.is_some(); + if current.unwrap_or("") == merged { + return Ok(FileStatus::Unchanged); + } - return Ok(if exists { - FileStatus::Updated - } else { - FileStatus::Created - }); - } + // Never create an empty new file. + if !merged.is_empty() || exists { + write_atomic(dest, merged)?; + } - let status = if exists { - println!("Updating: {filename}"); - FileStatus::Updated - } else { - println!("Creating: {filename}"); - FileStatus::Created - }; + if exists { + eprintln!("Updating {label} on {side}"); + Ok(FileStatus::Updated) + } else { + eprintln!("Creating {label} on {side}"); + Ok(FileStatus::Created) + } +} - if !new_content.is_empty() || exists { - fs::write(&local_files, new_content)?; +/// Recursively collect the paths of regular files under `dir`, expressed +/// relative to `root`. Symlinks are skipped to avoid traversal loops. +fn collect_files(root: &Path, dir: &Path, out: &mut BTreeSet<PathBuf>) -> Result<()> { + for entry in fs::read_dir(dir).with_context(|| format!("Failed to read {}", dir.display()))? { + let entry = entry?; + let path = entry.path(); + let file_type = entry.file_type()?; + if file_type.is_dir() { + collect_files(root, &path, out)?; + } else if file_type.is_file() { + let rel = path + .strip_prefix(root) + .with_context(|| format!("{} is not under {}", path.display(), root.display()))?; + out.insert(rel.to_path_buf()); } + } + Ok(()) +} - Ok(status) +/// Read a file, returning `None` if it does not exist. +fn read_optional(path: &Path) -> Result<Option<String>> { + match fs::read_to_string(path) { + Ok(s) => Ok(Some(s)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e).with_context(|| format!("Failed to read {}", path.display())), } +} - fn merge_entries(local: Vec<String>, remote: Vec<String>) -> Vec<String> { - // Union of local and remote, preserving unique entries - let mut seen = HashSet::new(); - let mut result = Vec::new(); +/// Write `contents` to `path` atomically: stage in a temp file in the same +/// directory and rename into place, so a crash mid-write can't truncate or +/// corrupt the target. +fn write_atomic(path: &Path, contents: &str) -> Result<()> { + let dir = path + .parent() + .with_context(|| format!("{} has no parent directory", path.display()))?; + fs::create_dir_all(dir).with_context(|| format!("Failed to create {}", dir.display()))?; + + let mut tmp = tempfile::NamedTempFile::new_in(dir) + .with_context(|| format!("Failed to create temp file in {}", dir.display()))?; + tmp.write_all(contents.as_bytes()) + .with_context(|| format!("Failed to write {}", path.display()))?; + tmp.persist(path) + .map_err(|e| e.error) + .with_context(|| format!("Failed to persist {}", path.display()))?; + Ok(()) +} - // Add all local entries first - for entry in local { - if !entry.trim().is_empty() && seen.insert(entry.clone()) { - result.push(entry); +/// Merge two sides into the file content dam would write. +/// +/// When the file exists on both sides the result is the sorted, de-duplicated +/// union of their non-empty lines. When it exists on only one side that +/// side's content is propagated verbatim (no reordering of brand-new files). +fn merge(local: Option<&str>, remote: Option<&str>) -> String { + match (local, remote) { + (Some(l), Some(r)) => { + let merged = union_lines(l, r); + if merged.is_empty() { + String::new() + } else { + format!("{}\n", merged.join("\n")) } } + (Some(only), None) | (None, Some(only)) => only.to_string(), + (None, None) => String::new(), + } +} - // Add remote entries that aren't already seen - for entry in remote { - if !entry.trim().is_empty() && seen.insert(entry.clone()) { - result.push(entry); - } - } +/// Sorted, de-duplicated union of the non-blank lines of `a` and `b`. A +/// trailing carriage return is stripped so CRLF and LF inputs compare equal. +fn union_lines<'a>(a: &'a str, b: &'a str) -> Vec<String> { + let mut seen = HashSet::new(); + let mut result: Vec<String> = a + .lines() + .chain(b.lines()) + .map(|line| line.trim_end_matches('\r')) + .filter(|line| !line.trim().is_empty()) + .filter(|line| seen.insert(*line)) + .map(str::to_string) + .collect(); + result.sort_by(|a, b| natord::compare(a, b)); + result +} - result.sort_by(|a, b| natord::compare(a, b)); - result +/// Print a coloured line diff to stdout. `old` is the base and `new` the +/// proposed content, so `+` lines are additions and `-` lines are removals. +fn print_diff(header: &str, old: &str, new: &str) { + println!("{header}"); + let diff = TextDiff::from_lines(old, new); + for change in diff.iter_all_changes() { + let (sign, styled) = match change.tag() { + ChangeTag::Delete => ("-", style(change).red()), + ChangeTag::Insert => ("+", style(change).green()), + ChangeTag::Equal => (" ", style(change).dim()), + }; + print!("{sign}{styled}"); } + println!(); } -fn main() -> Result<()> { +fn main() -> Result<ExitCode> { let cli = Cli::parse(); - let remote = if cli.same_as_local { - cli.local.clone() + let mode = cli.mode.mode(); + + let worker = FileSyncWorker { + host_alias: cli.host, + local_path: cli.local, + remote_path: cli.remote, + mode, + timeout: cli.timeout, + elevate: cli.elevate.command(), + }; + let tally = worker.run()?; + + let code = if tally.errors > 0 { + ExitCode::from(2) + } else if cli.exit_code && mode == Mode::Plan && tally.changed() > 0 { + ExitCode::from(1) } else { - cli.remote - .context("--remote or --same-as-local must be specified")? + ExitCode::SUCCESS }; + Ok(code) +} - let syncer = FileSyncWorker::new(cli.host, cli.local, remote, cli.sync, cli.reverse); - syncer.sync()?; +#[cfg(test)] +mod tests { + use super::*; - Ok(()) + #[test] + fn union_dedups_sorts_and_drops_blank_lines() { + let got = union_lines("banana\napple\n\napple", "cherry\napple"); + assert_eq!(got, vec!["apple", "banana", "cherry"]); + } + + #[test] + fn union_uses_natural_ordering() { + let got = union_lines("file10\nfile2\nfile1", ""); + assert_eq!(got, vec!["file1", "file2", "file10"]); + } + + #[test] + fn union_normalises_crlf() { + let got = union_lines("a\r\nb\r\n", "b\r\nc\r"); + assert_eq!(got, vec!["a", "b", "c"]); + } + + #[test] + fn merge_unions_when_both_present() { + assert_eq!(merge(Some("b\na"), Some("c\na")), "a\nb\nc\n"); + } + + #[test] + fn merge_propagates_single_side_verbatim() { + // Order is preserved and nothing is sorted when only one side has it. + assert_eq!(merge(Some("z\na\n"), None), "z\na\n"); + assert_eq!(merge(None, Some("z\na\n")), "z\na\n"); + } + + #[test] + fn merge_is_empty_when_neither_present() { + assert_eq!(merge(None, None), ""); + } + + #[test] + fn merge_of_blank_only_content_is_empty() { + assert_eq!(merge(Some("\n\n"), Some(" ")), ""); + } + + #[test] + fn plan_reports_unchanged_when_sides_match() { + let merged = merge(Some("a\nb\n"), Some("a\nb\n")); + assert_eq!( + plan("f", Some("a\nb\n"), Some("a\nb\n"), &merged), + FileStatus::Unchanged + ); + } + + #[test] + fn plan_reports_change_when_sides_differ() { + let merged = merge(Some("a"), Some("b")); + assert_eq!( + plan("f", Some("a"), Some("b"), &merged), + FileStatus::Updated + ); + } + + #[test] + fn apply_creates_updates_and_skips() { + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("f"); + + // Absent target with content -> Created and written. + let created = apply("f", None, "a\nb\n", &dest, "local").unwrap(); + assert_eq!(created, FileStatus::Created); + assert_eq!(fs::read_to_string(&dest).unwrap(), "a\nb\n"); + + // Existing target, new content -> Updated. + let updated = apply("f", Some("a\nb\n"), "a\nb\nc\n", &dest, "local").unwrap(); + assert_eq!(updated, FileStatus::Updated); + assert_eq!(fs::read_to_string(&dest).unwrap(), "a\nb\nc\n"); + + // No difference -> Unchanged, no write needed. + let same = apply("f", Some("x"), "x", &dest, "local").unwrap(); + assert_eq!(same, FileStatus::Unchanged); + } }