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:
| M | dam/README.md | | | 87 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------ |
| M | dam/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(¤t_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);
+ }
}