tools

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

commit 0382c9d35940ccc167bcefa668bbaf0e390c98bb
parent f4edadf88d34df998672084826dcd01499bc85d0
Author: mtmn <miro@haravara.org>
Date:   Sun, 14 Jun 2026 14:29:01 +0200

dam: better diff, add dam.1 and makefile

Diffstat:
M.gitignore | 1+
Adam/Makefile | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adam/dam.1 | 244+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdam/src/main.rs | 78+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
4 files changed, 368 insertions(+), 17 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -8,3 +8,4 @@ zig-out _build # rust rust-project.json +target diff --git a/dam/Makefile b/dam/Makefile @@ -0,0 +1,62 @@ +CARGO ?= cargo +PREFIX ?= /usr/local +BINDIR ?= $(PREFIX)/bin +MANDIR ?= $(PREFIX)/share/man +DESTDIR ?= + +BIN := dam +MANPAGE := dam.1 + +TARGET := $(shell git rev-parse --show-toplevel)/target/release/$(BIN) + +.DEFAULT_GOAL := help + +.PHONY: build +build: ## Build a release binary + $(CARGO) build --release + +.PHONY: debug +debug: ## Build a debug binary + $(CARGO) build + +.PHONY: run +run: ## Run dam (pass args with ARGS=...) + $(CARGO) run -- $(ARGS) + +.PHONY: test +test: ## Run the test suite + $(CARGO) test + +.PHONY: check +check: ## Type-check without building artifacts + $(CARGO) check + +.PHONY: man +man: ## Open a preview of dam.1 + man ./$(MANPAGE) + +.PHONY: lint +lint: ## Run linters (fmt, clippy, man) + $(CARGO) fmt + $(CARGO) clippy --all-targets -- -D warnings + mandoc -T lint $(MANPAGE) + +.PHONY: install +install: build ## Install the binary and man page under PREFIX + install -d $(DESTDIR)$(BINDIR) $(DESTDIR)$(MANDIR)/man1 + install -m 0755 $(TARGET) $(DESTDIR)$(BINDIR)/$(BIN) + install -m 0644 $(MANPAGE) $(DESTDIR)$(MANDIR)/man1/$(MANPAGE) + +.PHONY: uninstall +uninstall: ## Remove the installed binary and man page + rm -f $(DESTDIR)$(BINDIR)/$(BIN) + rm -f $(DESTDIR)$(MANDIR)/man1/$(MANPAGE) + +.PHONY: clean +clean: ## Remove build artifacts + $(CARGO) clean + +.PHONY: help +help: ## List available targets + @grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "} {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' diff --git a/dam/dam.1 b/dam/dam.1 @@ -0,0 +1,244 @@ +.TH DAM 1 2026-06-14 +.SH NAME +dam \- diff-and-merge line-oriented text files between a local directory and a +remote host over rsync +.SH SYNOPSIS +.B dam +.B \-\-host +.I host +.B \-\-local +.I path +.B \-\-remote +.I path +.RB [ \-\-plan " | " \-\-pull " | " \-\-push ] +.RB [ \-\-sudo " | " \-\-doas ] +.RB [ \-\-exit-code ] +.RB [ \-\-timeout +.IR seconds ] +.SH DESCRIPTION +.B dam +synchronises line-oriented text files between a local directory and a remote +host over +.BR rsync (1), +using a plan/apply workflow. +By default it only previews the changes each direction would make; +.B \-\-pull +and +.B \-\-push +apply them. +.PP +.B dam +is intended for list-like data such as allow-lists, hosts files, and word +lists. +Blank lines, duplicates, and the original line ordering are +.I not +preserved by the merge, so it is unsuitable for arbitrary documents. +.PP +.B \-\-local +and +.B \-\-remote +may be directories or single files. +When +.B \-\-local +is an existing directory, +.B 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. +.SH MERGE STRATEGY +When a file exists on both sides, the merged result is the sorted, +de-duplicated +.I union +of its local and remote lines, using natural ordering (so +.I file2 +sorts before +.IR file10 ). +A trailing carriage return is stripped, so CRLF and LF inputs compare equal. +.PP +When a file exists on only one side, its contents are propagated verbatim, +without reordering. +.PP +The union is symmetric: the merged result is identical regardless of direction; +the mode only chooses which side gets written. +.PP +.B 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 +.BR rsync (1)), +not something +.B dam +does. +.SH MODES +.BR \-\-plan , +.BR \-\-pull , +and +.B \-\-push +are mutually exclusive. +.B \-\-plan +is the default when none is given. +.TP +.B \-\-plan +Show the changes each direction would make, without writing anything. +.TP +.B \-\-pull +Apply the merge to the local directory (remote -> local). +.TP +.B \-\-push +Apply the merge to the remote host (local -> remote). +.SH OPTIONS +.TP +.BI \-\-host " host" +SSH host alias, as configured in +.IR ~/.ssh/config , +to sync with. +Required. +.TP +.BI \-\-local " path" +Local directory, or single file, containing the line-oriented text. +Required. +.TP +.BI \-\-remote " path" +Remote directory, or single file, to sync from and to. +Required. +.TP +.B \-\-sudo +Elevate the +.I remote +rsync with +.BR sudo (8), +via rsync's +.BR \-\-rsync-path . +Mutually exclusive with +.BR \-\-doas . +.TP +.B \-\-doas +Elevate the +.I remote +rsync with +.BR doas (1), +via rsync's +.BR \-\-rsync-path . +Mutually exclusive with +.BR \-\-sudo . +.IP +.BR \-\-sudo / \-\-doas +elevate only the rsync process on the remote host (for example, when the remote +path is root-owned). +The local rsync always runs unprivileged, so your own SSH config and keys are +used. +.TP +.B \-\-exit-code +With +.BR \-\-plan , +exit 1 if pending changes are found. +Useful in CI. +Processing errors always exit 2. +.TP +.BI \-\-timeout " seconds" +I/O timeout in seconds handed to rsync. +The default is 30; 0 disables the timeout. +.SH OUTPUT +For each file with pending changes, +.B \-\-plan +prints the file's path followed by a per-side summary for each side that would +change: a +.I local +or +.I remote +tag, then the count of lines that side would gain +.RB ( +N ) +and lose +.RB ( \-N ), +and the changed lines themselves. +For example: +.PP +.RS 4 +.EX +hosts/blocklist + local +1 -0 + +badsite.example + remote +2 -0 + +ads.example + +tracker.example +.EE +.RE +.PP +Progress and the run summary are written to standard error; the plan diff is +written to standard output, so the diff can be captured on its own: +.PP +.RS 4 +.EX +dam --host myserver --local ~/lists --remote /srv/lists > changes.diff +.EE +.RE +.PP +Local files are written atomically: each is staged in a temporary file in the +same directory and renamed into place, so an interrupted run cannot leave a +half-written file. +rsync runs with +.I ssh -o BatchMode=yes +and the I/O +.B \-\-timeout +so it fails fast in automation rather than blocking on a password or host-key +prompt. +.SH EXIT STATUS +.TP +.B 0 +Success; nothing pending, or changes were applied with +.B \-\-pull +or +.BR \-\-push . +.TP +.B 1 +.B \-\-exit-code +was set with +.B \-\-plan +and pending changes were found. +.TP +.B 2 +One or more files failed to process. +.SH EXAMPLES +See what would change on either side: +.PP +.RS 4 +.EX +dam --host myserver --local ~/lists --remote /srv/lists +.EE +.RE +.PP +Bring the local directory up to date: +.PP +.RS 4 +.EX +dam --host myserver --local ~/lists --remote /srv/lists --pull +.EE +.RE +.PP +Bring the remote up to date: +.PP +.RS 4 +.EX +dam --host myserver --local ~/lists --remote /srv/lists --push +.EE +.RE +.PP +Sync a single file: +.PP +.RS 4 +.EX +dam --host myserver --local ~/hosts.allow --remote /etc/hosts.allow --pull +.EE +.RE +.PP +Fail (exit 1) if the two sides have drifted, for use in CI: +.PP +.RS 4 +.EX +dam --host myserver --local ~/lists --remote /srv/lists --plan --exit-code +.EE +.RE +.SH SEE ALSO +.BR rsync (1), +.BR ssh (1), +.BR ssh_config (5) diff --git a/dam/src/main.rs b/dam/src/main.rs @@ -370,8 +370,26 @@ impl FileSyncWorker { } } +/// Which end of the sync a planned change applies to. +#[derive(Clone, Copy)] +enum Side { + Local, + Remote, +} + +impl Side { + /// The tag printed in front of this side's diff, colour-coded so local and + /// remote are easy to tell apart at a glance. + fn tag(self) -> console::StyledObject<&'static str> { + match self { + Side::Local => style(" local ").black().on_cyan().bold(), + Side::Remote => style(" remote ").black().on_magenta().bold(), + } + } +} + /// Preview the changes both directions would make to a single file, printing -/// a diff per side that has pending changes. Writes nothing. +/// a per-side diff for each 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(""); @@ -382,13 +400,14 @@ fn plan(label: &str, local: Option<&str>, remote: Option<&str>, merged: &str) -> return FileStatus::Unchanged; } - println!("{label}"); + println!("{}", style(label).bold().underlined()); if local_changes { - print_diff(" --pull would update local:", local_current, merged); + print_side_diff(Side::Local, local_current, merged); } if remote_changes { - print_diff(" --push would update remote:", remote_current, merged); + print_side_diff(Side::Remote, remote_current, merged); } + println!(); FileStatus::Updated } @@ -489,7 +508,7 @@ fn merge(local: Option<&str>, remote: Option<&str>) -> String { /// 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> { +fn union_lines(a: &str, b: &str) -> Vec<String> { let mut seen = HashSet::new(); let mut result: Vec<String> = a .lines() @@ -503,20 +522,45 @@ fn union_lines<'a>(a: &'a str, b: &'a str) -> Vec<String> { 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); +/// Print one side's planned change to stdout: a tagged header naming the side +/// with its added/removed counts, followed by only the changed lines. +/// +/// `current` is that side's existing content and `merged` the proposed result, +/// so `+` lines are gained and `-` lines lost. Unchanged lines are omitted to +/// keep the plan focused on the delta. +fn print_side_diff(side: Side, current: &str, merged: &str) { + // Diff the lines themselves rather than the raw text: `lines()` drops the + // terminators, so a missing trailing newline never shows up as a change. + let current: Vec<&str> = current.lines().collect(); + let merged: Vec<&str> = merged.lines().collect(); + let diff = TextDiff::from_slices(&current, &merged); + + // `true` marks an inserted line, `false` a deleted one; equal lines are + // dropped here so the body below is purely the delta. + let mut changes: Vec<(bool, &str)> = Vec::new(); + let (mut added, mut removed) = (0u32, 0u32); 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}"); + match change.tag() { + ChangeTag::Insert => added += 1, + ChangeTag::Delete => removed += 1, + ChangeTag::Equal => continue, + } + changes.push((change.tag() == ChangeTag::Insert, change.value())); + } + + println!( + " {} {} {}", + side.tag(), + style(format!("+{added}")).green(), + style(format!("-{removed}")).red(), + ); + for (inserted, line) in changes { + if inserted { + println!(" {}", style(format!("+{line}")).green()); + } else { + println!(" {}", style(format!("-{line}")).red()); + } } - println!(); } fn main() -> Result<ExitCode> {