aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--Cargo.lock191
-rw-r--r--Cargo.toml12
-rw-r--r--LICENSE.md51
-rw-r--r--README.md47
-rw-r--r--src/lib.rs200
-rw-r--r--src/main.rs47
-rw-r--r--testdata/a24
-rw-r--r--testdata/ancestor10
-rw-r--r--testdata/b26
-rw-r--r--testdata/expected243
-rw-r--r--testdata/expected341
-rw-r--r--testdata/tricky13
-rw-r--r--tests/test_cmd.rs62
14 files changed, 768 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ea8c4bf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..c93b059
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,191 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "anstyle"
+version = "1.0.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9"
+
+[[package]]
+name = "assert_cmd"
+version = "2.0.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1835b7f27878de8525dc71410b5a31cdcc5f230aed5ba5df968e09c201b23d"
+dependencies = [
+ "anstyle",
+ "bstr",
+ "doc-comment",
+ "libc",
+ "predicates",
+ "predicates-core",
+ "predicates-tree",
+ "wait-timeout",
+]
+
+[[package]]
+name = "bstr"
+version = "1.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "531a9155a481e2ee699d4f98f43c0ca4ff8ee1bfd55c31e9e98fb29d2b176fe0"
+dependencies = [
+ "memchr",
+ "regex-automata",
+ "serde",
+]
+
+[[package]]
+name = "difflib"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
+
+[[package]]
+name = "doc-comment"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10"
+
+[[package]]
+name = "libc"
+version = "0.2.171"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6"
+
+[[package]]
+name = "memchr"
+version = "2.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
+
+[[package]]
+name = "predicates"
+version = "3.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573"
+dependencies = [
+ "anstyle",
+ "difflib",
+ "predicates-core",
+]
+
+[[package]]
+name = "predicates-core"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa"
+
+[[package]]
+name = "predicates-tree"
+version = "1.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c"
+dependencies = [
+ "predicates-core",
+ "termtree",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.94"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
+
+[[package]]
+name = "serde"
+version = "1.0.219"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.219"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "termtree"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
+
+[[package]]
+name = "thiserror"
+version = "2.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
+
+[[package]]
+name = "wait-timeout"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "zsh_history"
+version = "0.1.0"
+dependencies = [
+ "assert_cmd",
+ "thiserror",
+]
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..2ec537a
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "zsh_history"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+assert_cmd = "2.0.16"
+thiserror = "2.0.12"
+
+[[bin]]
+name = "zsh_history"
+path = "src/main.rs"
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 0000000..d76dbaf
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,51 @@
+# License
+
+The crate zsh_history is dual licensed under the terms of the Apache License,
+Version 2.0, and the MIT License.
+
+You may obtain copies of the two licenses at
+
+* https://www.apache.org/licenses/LICENSE-2.0 and
+* https://opensource.org/licenses/MIT, respectively.
+
+The following two notices apply to every file of the project.
+
+## The Apache License
+
+```
+Copyright 2025 Christoph Groth
+
+Licensed under the Apache License, Version 2.0 (the “License”); you may not use
+this file except in compliance with the License. You may obtain a copy of the
+License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software distributed
+under the License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR
+CONDITIONS OF ANY KIND, either express or implied. See the License for the
+specific language governing permissions and limitations under the License.
+```
+
+## The MIT License
+
+```
+Copyright 2025 Christoph Groth
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the “Software”), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+```
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b462db3
--- /dev/null
+++ b/README.md
@@ -0,0 +1,47 @@
+zsh_history
+===========
+
+A tool (and Rust library) for merging zsh history files in zsh’s extended format.
+
+One possible application is synchronization of shell histories across multiple machines in a decentralized way.
+
+The extended format ([see `EXTENDED_HISTORY`](https://zsh.sourceforge.io/Doc/Release/Options.html#History)) is required so that zsh records timestamps together with the commands.
+These timestamps are used for automatic robust conflict resolution.
+
+Installation
+------------
+
+```shell
+cargo install --git https://git.grothesque.org/zsh_history
+```
+
+([Cargo](https://www.rust-lang.org/tools/install) is Rust’s package manager.)
+
+Usage
+-----
+
+```shell
+zsh_history merge A B >MERGED
+zsh_history merge A B ANCESTOR >MERGED
+```
+If the input files are valid zsh history files, the merged output will be also valid.
+
+Conflicts are resolved automatically such that all history variants are preserved
+(A’s variant is output before B’s variant).
+The output contains all commands from A and B, with the exception of those commands that are clearly identified as deleted (by comparing with ANCESTOR) by one or both sides.
+If a command has been deleted by one side but modified by the other, the conflict is resolved by preserving the modified version.
+
+The tool performs a single timestamp-synchronized pass through all inputs.
+At any timestamp value it considers at most one command from each input.
+Thus, if multiple consecutive commands have the same timestamp, and A and B differ,
+the merging will likely get out of sync, and detect spurious conflicts that will lead to commands being output twice (once for A and for B inputs).
+This is a theoretical problem that should almost never occur in practice.
+
+The duration field is preserved but otherwise ignored.
+
+Use with the Unison file synchronizer
+-------------------------------------
+
+The [Unison file synchronizer](https://github.com/bcpierce00/unison) can be configured to merge specific files using external tools when a conflict is detected.
+It can even keep ancestors around automatically.
+See the section “Merging Conflicting Versions” in the Unison manual.
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..7977d17
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,200 @@
+use std::io::{self, BufRead as _};
+use std::str;
+
+/// A single zsh extended history item.
+#[derive(Debug)]
+pub struct Item<'a> {
+ pub time: u64,
+ pub duration: u64,
+ pub cmd: &'a [u8],
+}
+
+impl Item<'_> {
+ /// Writes one item in the format b": <timestamp>:<duration>;<command>\n".
+ pub fn write<W: io::Write>(&self, out: &mut W) -> Result<(), Error> {
+ write!(out, ": {}:{};", self.time, self.duration)?;
+ out.write_all(self.cmd)?;
+ Ok(())
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ #[error(transparent)]
+ Io(#[from] io::Error),
+ #[error(transparent)]
+ Utf8(#[from] str::Utf8Error),
+ #[error(transparent)]
+ Int(#[from] std::num::ParseIntError),
+ #[error("Format error: {0}")]
+ Format(String),
+}
+
+/// A reader that parses extended zsh history items from a stream.
+/// It reuses an internal buffer so that items are yielded without copying.
+pub struct Reader<R: io::Read> {
+ reader: io::BufReader<R>,
+ buf: Vec<u8>,
+}
+
+impl<R: io::Read> Reader<R> {
+ pub fn new(inner: R) -> Self {
+ Reader {
+ reader: io::BufReader::new(inner),
+ buf: Vec::with_capacity(1024),
+ }
+ }
+
+ /// Parses self.buf into an Item.
+ /// Expected format: b": <timestamp>:<duration>;<command>"
+ /// This method borrows from self.buf.
+ fn parse(&self) -> Result<Item<'_>, Error> {
+ let mut item = &self.buf[..];
+
+ if let Some(stripped) = item.strip_prefix(b":").or_else(|| item.strip_prefix(b"\\:")) {
+ item = stripped;
+ } else {
+ return Err(Error::Format(format!(
+ "Item does not start with ':' or '\\:' : {:?}",
+ str::from_utf8(&item)?
+ )));
+ }
+
+ item = item.strip_prefix(b" ").unwrap_or(item);
+
+ let colon_pos = item.iter().position(|&c| c == b':')
+ .ok_or_else(|| Error::Format("Missing colon between timestamp and duration.".into()))?;
+ let time: u64 = str::from_utf8(&item[..colon_pos])?.parse()?;
+
+ let remainder = &item[colon_pos + 1..];
+ let semi_pos = remainder.iter().position(|&c| c == b';')
+ .ok_or_else(|| Error::Format("Missing semicolon in item.".into()))?;
+ let duration: u64 = str::from_utf8(&remainder[..semi_pos])?.parse()?;
+ let cmd = &remainder[semi_pos + 1..];
+
+ Ok(Item { time, duration, cmd })
+ }
+
+ // Reads and returns the next item, or None on EOF.
+ pub fn read_item(&mut self) -> Result<Option<Item<'_>>, Error> {
+ self.buf.clear();
+
+ loop {
+ if self.reader.read_until(b'\n', &mut self.buf)? == 0 {
+ return if self.buf.is_empty() {
+ Ok(None)
+ } else {
+ Ok(Some(self.parse()?))
+ }
+ }
+
+ // If a command ends with a backslash, zsh makes sure to prepend a space, such that
+ // backslash-quoted newlines mean: command continues on next line.
+ if !self.buf.ends_with(b"\\\n") {
+ break;
+ }
+ }
+ Ok(Some(self.parse()?))
+ }
+}
+
+pub fn merge<R1: io::Read, R2: io::Read, R3: io::Read, W: io::Write>(
+ mut left: Reader<R1>,
+ mut right: Reader<R2>,
+ mut ancestor: Reader<R3>,
+ out: &mut W,
+) -> Result<(), Error> {
+ // Get the first item from each stream.
+ let mut l = left.read_item()?;
+ let mut r = right.read_item()?;
+ let mut a = ancestor.read_item()?;
+
+ let out = &mut io::BufWriter::new(out);
+
+ // While any stream still has an item:
+ while l.is_some() || r.is_some() || a.is_some() {
+ // Determine the current earliest time over all non-None items.
+ let current_time = [l.as_ref(), r.as_ref(), a.as_ref()]
+ .iter()
+ .filter_map(|opt| opt.map(|i| i.time))
+ .min()
+ .unwrap();
+
+ // Grab the "chunk": all items whose time equals the current time.
+ // (After this step, each of left, right, and ancestor is either Some(item)
+ // with the current time or None.)
+ let l_chunk = l.as_ref().filter(|i| i.time == current_time);
+ let r_chunk = r.as_ref().filter(|i| i.time == current_time);
+ let a_chunk = a.as_ref().filter(|i| i.time == current_time);
+
+ match (l_chunk, r_chunk, a_chunk) {
+ // Case 1: Both left and right are present.
+ (Some(l), Some(r), a_opt) => {
+ if l.cmd == r.cmd {
+ // They agree: output it.
+ l.write(out)?;
+ } else {
+ // They differ. Check against ancestor.
+ if let Some(a) = a_opt {
+ if l.cmd == a.cmd && r.cmd != a.cmd {
+ r.write(out)?;
+ } else if r.cmd == a.cmd && l.cmd != a.cmd {
+ l.write(out)?;
+ } else {
+ // Otherwise both differ, so output both.
+ l.write(out)?;
+ r.write(out)?;
+ }
+ } else {
+ // No ancestor: output both.
+ l.write(out)?;
+ r.write(out)?;
+ }
+ }
+ }
+
+ // Case 2a: Left is present; right is missing.
+ (Some(l), None, a_opt) => {
+ if let Some(a) = a_opt {
+ if l.cmd == a.cmd {
+ // Left == ancestor: deletion.
+ } else {
+ l.write(out)?;
+ }
+ } else {
+ l.write(out)?;
+ }
+ }
+
+ // Case 2b: Right is present; left is missing.
+ (None, Some(r), a_opt) => {
+ if let Some(a) = a_opt {
+ if r.cmd == a.cmd {
+ } else {
+ r.write(out)?;
+ }
+ } else {
+ r.write(out)?;
+ }
+ }
+
+ // Case 3: Neither left nor right have a item.
+ (None, None, Some(_)) => {
+ // Interpret as a deletion.
+ }
+ (None, None, None) => unreachable!(),
+ }
+
+ // Advance the readers.
+ if l_chunk.is_some() {
+ l = left.read_item()?;
+ }
+ if r_chunk.is_some() {
+ r = right.read_item()?;
+ }
+ if a_chunk.is_some() {
+ a = ancestor.read_item()?;
+ }
+ }
+ Ok(())
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..044852b
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,47 @@
+use std::io;
+use zsh_history as zh;
+
+fn print_usage(prog: &str) {
+ eprintln!("Usage:");
+ eprintln!(" {} merge <left> <right> # two–way merge", prog);
+ eprintln!(" {} merge <left> <right> <ancestor> # three–way merge", prog);
+}
+
+fn main() -> Result<(), Box<dyn std::error::Error>> {
+ let args: Vec<String> = std::env::args().collect();
+ let prog = &args[0];
+
+ if args.len() < 4 || args.len() > 5 || args[1] != "merge" {
+ print_usage(prog);
+ return Ok(());
+ }
+
+ // Open files and create readers in one go.
+ let mut readers: Vec<_> = args[2..]
+ .iter()
+ .map(|name| {
+ std::fs::File::open(name)
+ .map_err(zh::Error::Io)
+ .map(zh::Reader::new)
+ })
+ .collect::<Result<_, _>>()?;
+
+ let mut out = io::stdout().lock();
+
+ let result = match readers.len() {
+ 2 => {
+ let ancestor = zh::Reader::new(io::empty());
+ zh::merge(readers.remove(0), readers.remove(0), ancestor, &mut out)
+ }
+ 3 => zh::merge(
+ readers.remove(0),
+ readers.remove(0),
+ readers.remove(0),
+ &mut out,
+ ),
+ _ => unreachable!(),
+ };
+
+ result?;
+ Ok(())
+}
diff --git a/testdata/a b/testdata/a
new file mode 100644
index 0000000..42d5016
--- /dev/null
+++ b/testdata/a
@@ -0,0 +1,24 @@
+: 1:0;b
+: 1:0;a
+: 1:0;a
+: 1:0;a
+: 2:0;cc
+: 3:0;dd
+: 4:0;f
+: 5:0;e
+: 6:3;g
+: 7:3;hh
+: 9:0;l
+: 9:0;m
+: 9:0;nn
+: 9:0;o
+: 9:0;p
+: 9:0;q
+: 9:0;t
+: 11:0;L
+: 12:0;M
+: 13:0;NN
+: 14:0;O
+: 15:0;P
+: 16:0;Q
+: 19:0;T
diff --git a/testdata/ancestor b/testdata/ancestor
new file mode 100644
index 0000000..6d6afe2
--- /dev/null
+++ b/testdata/ancestor
@@ -0,0 +1,10 @@
+: 0:0;a
+: 1:0;b
+: 2:0;c
+: 3:0;d
+: 4:0;e
+: 5:0;f
+: 6:3;g
+: 7:3;h
+: 8:3;i
+: 9:3;j
diff --git a/testdata/b b/testdata/b
new file mode 100644
index 0000000..61ed43e
--- /dev/null
+++ b/testdata/b
@@ -0,0 +1,26 @@
+: 1:0;b
+: 2:0;c
+: 3:0;ddd
+: 4:0;f
+: 5:0;e
+: 6:3;g
+: 8:3;ii
+: 9:3;j
+: 9:0;k
+: 9:0;m
+: 9:0;n
+: 9:0;o
+: 9:0;q
+: 9:0;p
+: 9:0;r
+: 9:0;r
+: 9:0;s
+: 10:0;K
+: 12:0;M
+: 13:0;N
+: 14:0;O
+: 15:0;Q
+: 16:0;P
+: 17:0;R
+: 17:0;R
+: 18:0;S
diff --git a/testdata/expected2 b/testdata/expected2
new file mode 100644
index 0000000..60a4031
--- /dev/null
+++ b/testdata/expected2
@@ -0,0 +1,43 @@
+: 1:0;b
+: 1:0;a
+: 1:0;a
+: 1:0;a
+: 2:0;cc
+: 2:0;c
+: 3:0;dd
+: 3:0;ddd
+: 4:0;f
+: 5:0;e
+: 6:3;g
+: 7:3;hh
+: 8:3;ii
+: 9:0;l
+: 9:3;j
+: 9:0;m
+: 9:0;k
+: 9:0;nn
+: 9:0;m
+: 9:0;o
+: 9:0;n
+: 9:0;p
+: 9:0;o
+: 9:0;q
+: 9:0;t
+: 9:0;p
+: 9:0;r
+: 9:0;r
+: 9:0;s
+: 10:0;K
+: 11:0;L
+: 12:0;M
+: 13:0;NN
+: 13:0;N
+: 14:0;O
+: 15:0;P
+: 15:0;Q
+: 16:0;Q
+: 16:0;P
+: 17:0;R
+: 17:0;R
+: 18:0;S
+: 19:0;T
diff --git a/testdata/expected3 b/testdata/expected3
new file mode 100644
index 0000000..43513b7
--- /dev/null
+++ b/testdata/expected3
@@ -0,0 +1,41 @@
+: 1:0;b
+: 1:0;a
+: 1:0;a
+: 1:0;a
+: 2:0;cc
+: 3:0;dd
+: 3:0;ddd
+: 4:0;f
+: 5:0;e
+: 6:3;g
+: 7:3;hh
+: 8:3;ii
+: 9:0;l
+: 9:0;m
+: 9:0;k
+: 9:0;nn
+: 9:0;m
+: 9:0;o
+: 9:0;n
+: 9:0;p
+: 9:0;o
+: 9:0;q
+: 9:0;t
+: 9:0;p
+: 9:0;r
+: 9:0;r
+: 9:0;s
+: 10:0;K
+: 11:0;L
+: 12:0;M
+: 13:0;NN
+: 13:0;N
+: 14:0;O
+: 15:0;P
+: 15:0;Q
+: 16:0;Q
+: 16:0;P
+: 17:0;R
+: 17:0;R
+: 18:0;S
+: 19:0;T
diff --git a/testdata/tricky b/testdata/tricky
new file mode 100644
index 0000000..dd0b842
--- /dev/null
+++ b/testdata/tricky
@@ -0,0 +1,13 @@
+: 1742639203:0;echo some tricky corner cases:
+: 1742639224:0;echo two-line command \\
+line 2
+: 1742639255:0;echo three-line-command \\
+line 2\\
+line 3 ends with a backslash\\
+: 1742639288:0;echo \ *
+: 1742639331:0;echo this line ends with three backslashes \\\\
+line 2
+: 1742639341:0;echo backslash followed by a _single_ space \
+: 1742639405:0;echo \\
+: 1111111111:0;echo this looks like a command, but it is a second line
+: 1742639433:0;echo
diff --git a/tests/test_cmd.rs b/tests/test_cmd.rs
new file mode 100644
index 0000000..16f639f
--- /dev/null
+++ b/tests/test_cmd.rs
@@ -0,0 +1,62 @@
+use assert_cmd::cargo::CommandCargoExt as _;
+use std::process::{Command, Stdio};
+
+const NULL: &str = "/dev/null";
+const TRICKY: &str = "testdata/tricky";
+const A: &str = "testdata/a";
+const B: &str = "testdata/b";
+const ANCESTOR: &str = "testdata/ancestor";
+const EXPECTED2: &str = "testdata/expected2";
+const EXPECTED3: &str = "testdata/expected3";
+
+fn merge_then_diff(args: &[&str], expected: &str) -> Result<(), Box<dyn std::error::Error>> {
+ let mut merge = Command::cargo_bin("zsh_history")?
+ .args(args)
+ .stdout(Stdio::piped())
+ .spawn()?;
+
+ let binary_stdout = merge.stdout.take().expect("Failed to capture stdout");
+
+ let mut diff = Command::new("diff")
+ .args(&["-u", expected, "-"])
+ .stdin(Stdio::from(binary_stdout))
+ .stdout(Stdio::inherit())
+ .stderr(Stdio::inherit())
+ .spawn()?;
+
+ let binary_status = merge.wait()?;
+ if !binary_status.success() {
+ return Err(format!("Merge failed with status {:?}", binary_status).into());
+ }
+
+ let diff_status = diff.wait()?;
+ if !diff_status.success() {
+ return Err(format!(
+ "Merging {} produced unexpected result (see diff above).",
+ args.join(" ")
+ )
+ .into());
+ }
+
+ Ok(())
+}
+
+#[test]
+fn test_pass_through() -> Result<(), Box<dyn std::error::Error>> {
+ for file in [NULL, TRICKY] {
+ merge_then_diff(&["merge", file, NULL], file)?;
+ merge_then_diff(&["merge", NULL, file], file)?;
+ merge_then_diff(&["merge", file, file], file)?;
+ }
+ Ok(())
+}
+
+#[test]
+fn test_merge() -> Result<(), Box<dyn std::error::Error>> {
+ merge_then_diff(&["merge", A, B], EXPECTED2)
+}
+
+#[test]
+fn test_merge_with_ancestor() -> Result<(), Box<dyn std::error::Error>> {
+ merge_then_diff(&["merge", A, B, ANCESTOR], EXPECTED3)
+}