use assert_cmd::Command;
use predicates::prelude::*;
use std::fs;
#[test]
fn t5_move_happy_path_relative_paths_with_pairs() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src.txt");
fs::write(&src, b"hello").unwrap();
let log = dir.path().join("move.log");
Command::cargo_bin("logmv")
.unwrap()
.current_dir(dir.path())
.args([
log.to_str().unwrap(), "src.txt", "dst.txt", "by",
"cc", "ac",
"p", ])
.assert()
.success()
.stdout(predicate::str::is_empty());
assert!(!src.exists(), "src must be gone after move");
let dst = dir.path().join("dst.txt");
assert!(dst.exists(), "dst must exist after move");
assert_eq!(fs::read(&dst).unwrap(), b"hello");
let log_content = fs::read_to_string(&log).unwrap();
let lines: Vec<&str> = log_content.lines().collect();
assert_eq!(lines.len(), 1, "log must have exactly one line");
let entry: serde_json::Value =
serde_json::from_str(lines[0]).expect("log line must be valid JSON");
assert_eq!(entry["act"], "move");
let logged_src = entry["src"].as_str().unwrap();
let logged_dst = entry["dst"].as_str().unwrap();
assert!(
logged_src.starts_with('/'),
"logged src must be absolute, got: {logged_src}"
);
assert!(
logged_dst.starts_with('/'),
"logged dst must be absolute, got: {logged_dst}"
);
let ts_str = entry["ts"].as_str().expect("ts field must be present");
chrono::DateTime::parse_from_rfc3339(ts_str).expect("ts must be valid ISO-8601 with offset");
let obj = entry.as_object().unwrap();
let keys: Vec<&str> = obj.keys().map(|k| k.as_str()).collect();
assert_eq!(
&keys[..4],
&["ts", "act", "src", "dst"],
"first four keys must be canonical in order, got: {keys:?}"
);
assert_eq!(
&keys[4..],
&["by", "ac"],
"pairs must follow in given order, got: {keys:?}"
);
assert_eq!(entry["by"].as_str().unwrap(), "cc");
assert_eq!(entry["ac"].as_str().unwrap(), "p");
}
#[test]
fn t6_trash_happy_path_never_unlinks_no_pairs() {
let dir = tempfile::tempdir().unwrap();
let home_tmp = tempfile::tempdir().unwrap(); let trash_root = home_tmp.path().join(".Trash");
fs::create_dir(&trash_root).unwrap();
let src = dir.path().join("precious.txt");
fs::write(&src, b"irreplaceable").unwrap();
let log = dir.path().join("trash.log");
Command::cargo_bin("logmv")
.unwrap()
.env("HOME", home_tmp.path())
.args([
log.to_str().unwrap(), "--trash",
src.to_str().unwrap(),
])
.assert()
.success()
.stdout(predicate::str::is_empty());
assert!(!src.exists(), "source must be gone after trash");
let trashed = trash_root.join("precious.txt");
assert!(trashed.exists(), "content must be in trash dir");
assert_eq!(
fs::read(&trashed).unwrap(),
b"irreplaceable",
"trashed content must be intact"
);
let log_content = fs::read_to_string(&log).unwrap();
let entry: serde_json::Value =
serde_json::from_str(log_content.trim()).expect("log line must be valid JSON");
assert_eq!(entry["act"], "trash");
let expected_dst = std::fs::canonicalize(&trash_root)
.unwrap()
.join("precious.txt");
let expected_dst_str = expected_dst.to_str().unwrap();
let dst_val = entry["dst"].as_str().expect("dst must be a string");
assert!(
dst_val.starts_with('/'),
"dst must be absolute, got: {dst_val}"
);
assert_eq!(
dst_val, expected_dst_str,
"dst must be the canonical landing path"
);
let ts_str = entry["ts"].as_str().expect("ts must be present");
chrono::DateTime::parse_from_rfc3339(ts_str).expect("ts must be valid ISO-8601 with offset");
let obj = entry.as_object().unwrap();
assert_eq!(
obj.len(),
4,
"no-pairs line must have exactly 4 keys, got: {:?}",
obj.keys().collect::<Vec<_>>()
);
}
#[test]
fn t7_never_overwrite_move_refuses() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src.txt");
let dst = dir.path().join("dst.txt");
fs::write(&src, b"source content").unwrap();
fs::write(&dst, b"existing content").unwrap();
let log = dir.path().join("log.jsonl");
Command::cargo_bin("logmv")
.unwrap()
.args([
log.to_str().unwrap(),
src.to_str().unwrap(),
dst.to_str().unwrap(),
])
.assert()
.failure()
.stdout(predicate::str::is_empty())
.stderr(predicate::str::is_empty().not());
assert!(src.exists(), "src must be intact after refusal");
assert_eq!(
fs::read(&dst).unwrap(),
b"existing content",
"dst content must be unchanged"
);
assert!(!log.exists(), "no log file must be created on refusal");
}
#[test]
fn t8_trash_collision_disambiguates() {
let dir = tempfile::tempdir().unwrap();
let home_tmp = tempfile::tempdir().unwrap(); let trash_root = home_tmp.path().join(".Trash");
fs::create_dir(&trash_root).unwrap();
let src = dir.path().join("file.txt");
fs::write(&src, b"new content").unwrap();
let existing = trash_root.join("file.txt");
fs::write(&existing, b"old content").unwrap();
let log = dir.path().join("log.jsonl");
Command::cargo_bin("logmv")
.unwrap()
.env("HOME", home_tmp.path())
.args([log.to_str().unwrap(), "--trash", src.to_str().unwrap()])
.assert()
.success();
assert!(!src.exists(), "source must be gone after trash");
assert_eq!(
fs::read(&existing).unwrap(),
b"old content",
"pre-existing trash file must not be clobbered"
);
let entries: Vec<_> = fs::read_dir(&trash_root)
.unwrap()
.filter_map(|e| e.ok())
.collect();
assert_eq!(
entries.len(),
2,
"trash dir must have 2 files (original + disambiguated), got {}",
entries.len()
);
let log_content = fs::read_to_string(&log).unwrap();
let lines: Vec<&str> = log_content.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(lines.len(), 1, "log must have exactly one line");
let entry: serde_json::Value =
serde_json::from_str(lines[0]).expect("log line must be valid JSON");
assert_eq!(entry["act"], "trash");
let expected_dst = std::fs::canonicalize(&trash_root)
.unwrap()
.join("file-1.txt");
let expected_dst_str = expected_dst.to_str().unwrap();
let dst_val = entry["dst"].as_str().expect("dst must be a string");
assert!(
dst_val.starts_with('/'),
"dst must be absolute, got: {dst_val}"
);
assert!(
dst_val.ends_with("/file-1.txt"),
"dst must end with /file-1.txt (disambiguated suffix), got: {dst_val}"
);
assert_eq!(
dst_val, expected_dst_str,
"dst must be the canonical landing path for the disambiguated file"
);
}
#[test]
fn t9_drift_window_loud_error() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src.txt");
fs::write(&src, b"content").unwrap();
let dst = dir.path().join("dst.txt");
let log_as_dir = dir.path().join("logdir");
fs::create_dir(&log_as_dir).unwrap();
Command::cargo_bin("logmv")
.unwrap()
.args([
log_as_dir.to_str().unwrap(), src.to_str().unwrap(),
dst.to_str().unwrap(),
])
.assert()
.failure()
.stdout(predicate::str::is_empty())
.stderr(predicate::str::is_empty().not());
assert!(!src.exists(), "src must be gone (move was not rolled back)");
assert!(
dst.exists(),
"dst must exist (move happened before log failed)"
);
}
#[test]
fn t10_append_preserves_history() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src.txt");
fs::write(&src, b"content").unwrap();
let dst = dir.path().join("dst.txt");
let log = dir.path().join("log.jsonl");
let existing_line =
r#"{"ts":"2024-01-01T00:00:00+00:00","act":"move","src":"/old/src","dst":"/old/dst"}"#;
fs::write(&log, format!("{existing_line}\n")).unwrap();
Command::cargo_bin("logmv")
.unwrap()
.args([
log.to_str().unwrap(),
src.to_str().unwrap(),
dst.to_str().unwrap(),
])
.assert()
.success();
let log_content = fs::read_to_string(&log).unwrap();
let lines: Vec<&str> = log_content.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(lines.len(), 2, "log must have exactly 2 lines after append");
assert_eq!(
lines[0], existing_line,
"original log line must be byte-identical and first"
);
let _: serde_json::Value =
serde_json::from_str(lines[1]).expect("new log line must be valid JSON");
}
#[test]
fn t11_missing_log_is_usage_error() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src.txt");
fs::write(&src, b"content").unwrap();
Command::cargo_bin("logmv")
.unwrap()
.args(["--trash", src.to_str().unwrap()])
.assert()
.failure()
.stderr(predicate::str::is_empty().not());
assert!(src.exists(), "src must not be moved when LOG is missing");
}
#[test]
fn t_oddarg_dangling_key_is_usage_error() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src.txt");
fs::write(&src, b"content").unwrap();
let dst = dir.path().join("dst.txt");
let log = dir.path().join("log.jsonl");
Command::cargo_bin("logmv")
.unwrap()
.args([
log.to_str().unwrap(),
src.to_str().unwrap(),
dst.to_str().unwrap(),
"dangling_key", ])
.assert()
.failure()
.stdout(predicate::str::is_empty())
.stderr(predicate::str::is_empty().not());
assert!(src.exists(), "src must not be moved on odd-arg error");
assert!(!log.exists(), "no log must be written on odd-arg error");
}
#[test]
fn t_collide_i_canonical_key_collision_refuses() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src.txt");
fs::write(&src, b"content").unwrap();
let dst = dir.path().join("dst.txt");
let log = dir.path().join("log.jsonl");
Command::cargo_bin("logmv")
.unwrap()
.args([
log.to_str().unwrap(),
src.to_str().unwrap(),
dst.to_str().unwrap(),
"ts",
"spoofed", ])
.assert()
.failure()
.stderr(predicate::str::is_empty().not());
assert!(
src.exists(),
"src must not be moved on canonical-key collision"
);
assert!(
!log.exists(),
"no log must be written on canonical-key collision"
);
}
#[test]
fn t_into_dir_move() {
let tmp = tempfile::tempdir().unwrap();
let src_file = tmp.path().join("file.txt");
fs::write(&src_file, b"hello into-dir").unwrap();
let dest_dir = tmp.path().join("dest");
fs::create_dir(&dest_dir).unwrap();
let log = tmp.path().join("move.log");
Command::cargo_bin("logmv")
.unwrap()
.args([
log.to_str().unwrap(),
src_file.to_str().unwrap(),
dest_dir.to_str().unwrap(),
])
.assert()
.success()
.stdout(predicate::str::is_empty());
assert!(!src_file.exists(), "src must be gone after into-dir move");
let dst_file = dest_dir.join("file.txt");
assert!(
dst_file.exists(),
"dest/file.txt must exist after into-dir move"
);
assert_eq!(fs::read(&dst_file).unwrap(), b"hello into-dir");
let log_content = fs::read_to_string(&log).unwrap();
let lines: Vec<&str> = log_content.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(lines.len(), 1, "log must have exactly one line");
let entry: serde_json::Value =
serde_json::from_str(lines[0]).expect("log line must be valid JSON");
assert_eq!(entry["act"], "move");
let logged_dst = entry["dst"].as_str().unwrap();
assert!(
logged_dst.ends_with("/file.txt"),
"logged dst must be the resolved file path inside dest, got: {logged_dst}"
);
assert_ne!(
logged_dst,
dest_dir.to_str().unwrap(),
"logged dst must not be the dest dir itself"
);
let ts_str = entry["ts"].as_str().expect("ts must be present");
chrono::DateTime::parse_from_rfc3339(ts_str).expect("ts must be valid ISO-8601 with offset");
}
#[test]
fn t_into_dir_collision() {
let tmp = tempfile::tempdir().unwrap();
let src_file = tmp.path().join("file.txt");
fs::write(&src_file, b"source content").unwrap();
let dest_dir = tmp.path().join("dest");
fs::create_dir(&dest_dir).unwrap();
let existing = dest_dir.join("file.txt");
fs::write(&existing, b"existing content").unwrap();
let log = tmp.path().join("move.log");
Command::cargo_bin("logmv")
.unwrap()
.args([
log.to_str().unwrap(),
src_file.to_str().unwrap(),
dest_dir.to_str().unwrap(),
])
.assert()
.failure()
.stderr(predicate::str::contains("file.txt"));
assert!(
src_file.exists(),
"src must be intact after into-dir collision"
);
assert_eq!(
fs::read(&existing).unwrap(),
b"existing content",
"dest/file.txt must be unchanged"
);
assert!(
!log.exists(),
"no log must be written on into-dir collision"
);
}
#[test]
fn t_mkdir_creates_and_logs() {
let tmp = tempfile::tempdir().unwrap();
let src_file = tmp.path().join("file.txt");
fs::write(&src_file, b"mkdir-log content").unwrap();
let a_dir = tmp.path().join("a");
fs::create_dir(&a_dir).unwrap();
let target = tmp.path().join("a/b/c/file.txt");
let log = tmp.path().join("move.log");
Command::cargo_bin("logmv")
.unwrap()
.args([
log.to_str().unwrap(),
"--mkdir",
src_file.to_str().unwrap(),
target.to_str().unwrap(),
])
.assert()
.success()
.stdout(predicate::str::is_empty());
assert!(tmp.path().join("a/b").is_dir(), "a/b must be created");
assert!(tmp.path().join("a/b/c").is_dir(), "a/b/c must be created");
assert!(a_dir.is_dir(), "a must still exist");
assert!(!src_file.exists(), "src must be gone after --mkdir move");
assert!(target.exists(), "target file must exist after --mkdir move");
let log_content = fs::read_to_string(&log).unwrap();
let lines: Vec<&str> = log_content.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(lines.len(), 3, "log must have 3 lines: 2 mkdir + 1 move");
let e0: serde_json::Value = serde_json::from_str(lines[0]).expect("line 0 must be valid JSON");
let e1: serde_json::Value = serde_json::from_str(lines[1]).expect("line 1 must be valid JSON");
let e2: serde_json::Value = serde_json::from_str(lines[2]).expect("line 2 must be valid JSON");
assert_eq!(e0["act"], "mkdir", "line 0 must be mkdir");
assert_eq!(e0["src"], "-", "mkdir src must be '-'");
assert!(
e0["dst"].as_str().unwrap().ends_with("/a/b"),
"line 0 mkdir dst must be abs a/b, got: {}",
e0["dst"]
);
assert_eq!(e1["act"], "mkdir", "line 1 must be mkdir");
assert_eq!(e1["src"], "-", "mkdir src must be '-'");
assert!(
e1["dst"].as_str().unwrap().ends_with("/a/b/c"),
"line 1 mkdir dst must be abs a/b/c, got: {}",
e1["dst"]
);
assert_eq!(e2["act"], "move", "line 2 must be move");
for (i, e) in [&e0, &e1, &e2].iter().enumerate() {
let ts_str = e["ts"]
.as_str()
.unwrap_or_else(|| panic!("line {i} must have ts"));
chrono::DateTime::parse_from_rfc3339(ts_str)
.unwrap_or_else(|_| panic!("line {i} ts must be valid RFC3339"));
}
}
#[test]
fn t_mkdir_noop_when_chain_present() {
let tmp = tempfile::tempdir().unwrap();
let src_file = tmp.path().join("file.txt");
fs::write(&src_file, b"noop content").unwrap();
let ab_dir = tmp.path().join("a/b");
fs::create_dir_all(&ab_dir).unwrap();
let target = tmp.path().join("a/b/dst.txt");
let log = tmp.path().join("move.log");
Command::cargo_bin("logmv")
.unwrap()
.args([
log.to_str().unwrap(),
"--mkdir",
src_file.to_str().unwrap(),
target.to_str().unwrap(),
])
.assert()
.success()
.stdout(predicate::str::is_empty());
assert!(!src_file.exists(), "src must be gone");
assert!(target.exists(), "target must exist");
let log_content = fs::read_to_string(&log).unwrap();
let lines: Vec<&str> = log_content.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(
lines.len(),
1,
"log must have exactly one line (zero mkdir lines)"
);
let entry: serde_json::Value =
serde_json::from_str(lines[0]).expect("log line must be valid JSON");
assert_eq!(entry["act"], "move", "the sole log line must be act=move");
}
#[test]
fn t_mkdir_create_failure_reports_creation() {
let tmp = tempfile::tempdir().unwrap();
let src_file = tmp.path().join("src.txt");
fs::write(&src_file, b"create-failure content").unwrap();
let blocker = tmp.path().join("blocker");
fs::write(&blocker, b"i am a file, not a dir").unwrap();
let target = tmp.path().join("blocker/sub/dst.txt");
let log = tmp.path().join("move.log");
Command::cargo_bin("logmv")
.unwrap()
.args([
log.to_str().unwrap(),
"--mkdir",
src_file.to_str().unwrap(),
target.to_str().unwrap(),
])
.assert()
.failure()
.stderr(predicate::str::is_empty().not())
.stderr(predicate::str::contains("append").not());
assert!(
!tmp.path().join("blocker/sub").exists(),
"blocker/sub must not be created on create failure"
);
assert!(blocker.is_file(), "blocker must still be a regular file");
assert!(src_file.exists(), "src must be intact");
assert!(!target.exists(), "dst.txt must not exist");
assert!(!log.exists(), "no log must be written on create failure");
}
#[test]
fn t_mkdir_append_failure_reports_created() {
let tmp = tempfile::tempdir().unwrap();
let src_file = tmp.path().join("src.txt");
fs::write(&src_file, b"append-failure content").unwrap();
let new_dir = tmp.path().join("newdir");
let target = new_dir.join("dst.txt");
let log_dir = tmp.path().join("logdir");
fs::create_dir(&log_dir).unwrap();
Command::cargo_bin("logmv")
.unwrap()
.args([
log_dir.to_str().unwrap(),
"--mkdir",
src_file.to_str().unwrap(),
target.to_str().unwrap(),
])
.assert()
.failure()
.stderr(predicate::str::is_empty().not())
.stderr(predicate::str::contains("creation").not());
assert!(new_dir.is_dir(), "newdir must have been created");
assert!(src_file.exists(), "src must be intact");
assert!(!target.exists(), "newdir/dst.txt must not exist");
}
#[test]
fn t_no_mkdir_missing_parent_errors() {
let tmp = tempfile::tempdir().unwrap();
let src_file = tmp.path().join("file.txt");
fs::write(&src_file, b"content").unwrap();
let target = tmp.path().join("a/b/file.txt");
let log = tmp.path().join("move.log");
Command::cargo_bin("logmv")
.unwrap()
.args([
log.to_str().unwrap(),
src_file.to_str().unwrap(),
target.to_str().unwrap(),
])
.assert()
.failure()
.stderr(predicate::str::is_empty().not());
assert!(
!tmp.path().join("a/b").exists(),
"a/b must not be created without --mkdir"
);
assert!(src_file.exists(), "src must be intact");
assert!(!log.exists(), "no log must be written");
}
#[test]
fn t_trailing_slash_mkdir_creates_dir() {
let tmp = tempfile::tempdir().unwrap();
let src_file = tmp.path().join("file.txt");
fs::write(&src_file, b"trailing-slash content").unwrap();
let dst_arg = format!("{}/newdir/", tmp.path().to_str().unwrap());
let log = tmp.path().join("move.log");
Command::cargo_bin("logmv")
.unwrap()
.args([
log.to_str().unwrap(),
"--mkdir",
src_file.to_str().unwrap(),
&dst_arg,
])
.assert()
.success()
.stdout(predicate::str::is_empty());
let newdir = tmp.path().join("newdir");
assert!(newdir.is_dir(), "newdir must be created as a directory");
assert!(!src_file.exists(), "src must be gone");
let dst_file = newdir.join("file.txt");
assert!(dst_file.exists(), "newdir/file.txt must exist");
assert_eq!(fs::read(&dst_file).unwrap(), b"trailing-slash content");
let log_content = fs::read_to_string(&log).unwrap();
let lines: Vec<&str> = log_content.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(lines.len(), 2, "log must have 2 lines: 1 mkdir + 1 move");
let e0: serde_json::Value = serde_json::from_str(lines[0]).expect("line 0 must be valid JSON");
let e1: serde_json::Value = serde_json::from_str(lines[1]).expect("line 1 must be valid JSON");
assert_eq!(e0["act"], "mkdir", "line 0 must be mkdir");
assert_eq!(e0["src"], "-");
assert!(
e0["dst"].as_str().unwrap().ends_with("/newdir"),
"mkdir dst must be abs newdir, got: {}",
e0["dst"]
);
assert_eq!(e1["act"], "move", "line 1 must be move");
assert!(
e1["dst"].as_str().unwrap().ends_with("/newdir/file.txt"),
"move dst must be abs newdir/file.txt, got: {}",
e1["dst"]
);
}
#[test]
fn t_rmdir_cascade_and_logs() {
let tmp = tempfile::tempdir().unwrap();
let keep_dir = tmp.path().join("keep");
fs::create_dir_all(tmp.path().join("keep/a/b")).unwrap();
fs::write(tmp.path().join("keep/other.txt"), b"anchor").unwrap();
let src_file = tmp.path().join("keep/a/b/file.txt");
fs::write(&src_file, b"cascade content").unwrap();
let out_dir = tmp.path().join("out");
fs::create_dir(&out_dir).unwrap();
let log = tmp.path().join("move.log");
Command::cargo_bin("logmv")
.unwrap()
.args([
log.to_str().unwrap(),
"--rmdir",
src_file.to_str().unwrap(),
out_dir.to_str().unwrap(),
])
.assert()
.success()
.stdout(predicate::str::is_empty());
assert!(
!tmp.path().join("keep/a/b").exists(),
"keep/a/b must be removed"
);
assert!(
!tmp.path().join("keep/a").exists(),
"keep/a must be removed"
);
assert!(keep_dir.is_dir(), "keep must still exist (was non-empty)");
let dst_file = out_dir.join("file.txt");
assert!(dst_file.exists(), "out/file.txt must exist after move");
assert_eq!(fs::read(&dst_file).unwrap(), b"cascade content");
let log_content = fs::read_to_string(&log).unwrap();
let lines: Vec<&str> = log_content.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(lines.len(), 3, "log must have 3 lines: move + 2 rmdir");
let e0: serde_json::Value = serde_json::from_str(lines[0]).expect("line 0 must be valid JSON");
let e1: serde_json::Value = serde_json::from_str(lines[1]).expect("line 1 must be valid JSON");
let e2: serde_json::Value = serde_json::from_str(lines[2]).expect("line 2 must be valid JSON");
assert_eq!(e0["act"], "move", "line 0 must be move");
assert_eq!(e1["act"], "rmdir", "line 1 must be rmdir");
assert_eq!(e1["dst"], "-", "rmdir dst must be '-'");
assert!(
e1["src"].as_str().unwrap().ends_with("/keep/a/b"),
"line 1 rmdir src must be abs keep/a/b, got: {}",
e1["src"]
);
assert_eq!(e2["act"], "rmdir", "line 2 must be rmdir");
assert_eq!(e2["dst"], "-", "rmdir dst must be '-'");
assert!(
e2["src"].as_str().unwrap().ends_with("/keep/a"),
"line 2 rmdir src must be abs keep/a, got: {}",
e2["src"]
);
}
#[test]
fn t_rmdir_truly_empty_only_dsstore() {
let tmp = tempfile::tempdir().unwrap();
let src_dir = tmp.path().join("dir");
fs::create_dir(&src_dir).unwrap();
let src_file = src_dir.join("file.txt");
fs::write(&src_file, b"dsstore content").unwrap();
fs::write(src_dir.join(".DS_Store"), b"store").unwrap();
let out_dir = tmp.path().join("out");
fs::create_dir(&out_dir).unwrap();
let log = tmp.path().join("move.log");
Command::cargo_bin("logmv")
.unwrap()
.args([
log.to_str().unwrap(),
"--rmdir",
src_file.to_str().unwrap(),
out_dir.to_str().unwrap(),
])
.assert()
.success()
.stdout(predicate::str::is_empty());
assert!(src_dir.is_dir(), "dir must still exist (had .DS_Store)");
let dst_file = out_dir.join("file.txt");
assert!(dst_file.exists(), "out/file.txt must exist");
let log_content = fs::read_to_string(&log).unwrap();
let lines: Vec<&str> = log_content.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(
lines.len(),
1,
"log must have exactly 1 line (no rmdir for .DS_Store dir)"
);
let entry: serde_json::Value =
serde_json::from_str(lines[0]).expect("log line must be valid JSON");
assert_eq!(entry["act"], "move", "the sole log line must be act=move");
}
#[test]
fn t_rmdir_with_trash() {
let tmp = tempfile::tempdir().unwrap();
let src_dir = tmp.path().join("dir");
fs::create_dir(&src_dir).unwrap();
let src_file = src_dir.join("only.txt");
fs::write(&src_file, b"trash cascade content").unwrap();
let home_tmp = tempfile::tempdir().unwrap(); let trash_root = home_tmp.path().join(".Trash");
fs::create_dir(&trash_root).unwrap();
let log = tmp.path().join("move.log");
Command::cargo_bin("logmv")
.unwrap()
.env("HOME", home_tmp.path())
.args([
log.to_str().unwrap(),
"--rmdir",
"--trash",
src_file.to_str().unwrap(),
])
.assert()
.success()
.stdout(predicate::str::is_empty());
let trashed = trash_root.join("only.txt");
assert!(trashed.exists(), "content must be in trash dir");
assert_eq!(fs::read(&trashed).unwrap(), b"trash cascade content");
assert!(
!src_dir.exists(),
"dir must be removed after --rmdir with --trash"
);
let log_content = fs::read_to_string(&log).unwrap();
let lines: Vec<&str> = log_content.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(lines.len(), 2, "log must have 2 lines: trash + rmdir");
let e0: serde_json::Value = serde_json::from_str(lines[0]).expect("line 0 must be valid JSON");
let e1: serde_json::Value = serde_json::from_str(lines[1]).expect("line 1 must be valid JSON");
assert_eq!(e0["act"], "trash", "line 0 must be trash");
let expected_dst = std::fs::canonicalize(&trash_root).unwrap().join("only.txt");
let expected_dst_str = expected_dst.to_str().unwrap();
let dst_val = e0["dst"].as_str().expect("dst must be a string");
assert!(
dst_val.starts_with('/'),
"dst must be absolute, got: {dst_val}"
);
assert_eq!(
dst_val, expected_dst_str,
"dst must be the canonical landing path"
);
assert_eq!(e1["act"], "rmdir", "line 1 must be rmdir");
assert_eq!(e1["dst"], "-", "rmdir dst must be '-'");
assert!(
e1["src"].as_str().unwrap().ends_with("/dir"),
"rmdir src must be abs dir, got: {}",
e1["src"]
);
}
#[test]
fn t_mkdir_isolation_doomed_move() {
let tmp = tempfile::tempdir().unwrap();
let src_file = tmp.path().join("file.txt");
fs::write(&src_file, b"content").unwrap();
let target = tmp.path().join("a/b/file.txt");
let log = tmp.path().join("move.log");
Command::cargo_bin("logmv")
.unwrap()
.args([
log.to_str().unwrap(),
"--mkdir",
src_file.to_str().unwrap(),
target.to_str().unwrap(),
"ts",
"X", ])
.assert()
.failure()
.stderr(predicate::str::contains("collides"));
assert!(
!tmp.path().join("a/b").exists(),
"a/b must not be created for a doomed move"
);
assert!(src_file.exists(), "src must be intact");
assert!(!log.exists(), "no log must be written for a doomed move");
}
#[test]
fn t_rmdir_not_run_when_move_log_fails() {
let tmp = tempfile::tempdir().unwrap();
fs::create_dir_all(tmp.path().join("keep/a")).unwrap();
let src_file = tmp.path().join("keep/a/file.txt");
fs::write(&src_file, b"drift content").unwrap();
let dst = tmp.path().join("dst.txt");
let log_as_dir = tmp.path().join("logdir");
fs::create_dir(&log_as_dir).unwrap();
Command::cargo_bin("logmv")
.unwrap()
.args([
log_as_dir.to_str().unwrap(),
"--rmdir",
src_file.to_str().unwrap(),
dst.to_str().unwrap(),
])
.assert()
.failure()
.stderr(predicate::str::is_empty().not());
assert!(
dst.exists(),
"dst must exist: move happened before log failed"
);
assert_eq!(fs::read(&dst).unwrap(), b"drift content");
assert!(!src_file.exists(), "src must be gone after rename");
assert!(
tmp.path().join("keep/a").is_dir(),
"keep/a must still exist: --rmdir must not run when log append fails"
);
}
#[test]
fn t_full_sequence_order_schema() {
let tmp = tempfile::tempdir().unwrap();
let src_base = tmp.path().join("src");
let x_dir = src_base.join("x");
fs::create_dir_all(&x_dir).unwrap();
let src_file = x_dir.join("file.txt");
fs::write(&src_file, b"full sequence content").unwrap();
let target = tmp.path().join("dst/y/z/file.txt");
let log = tmp.path().join("move.log");
Command::cargo_bin("logmv")
.unwrap()
.args([
log.to_str().unwrap(),
"--mkdir",
"--rmdir",
src_file.to_str().unwrap(),
target.to_str().unwrap(),
])
.assert()
.success()
.stdout(predicate::str::is_empty());
assert!(!src_file.exists(), "src file must be gone");
assert!(target.exists(), "target file must exist");
assert_eq!(fs::read(&target).unwrap(), b"full sequence content");
assert!(tmp.path().join("dst").is_dir());
assert!(tmp.path().join("dst/y").is_dir());
assert!(tmp.path().join("dst/y/z").is_dir());
assert!(!x_dir.exists(), "src/x must be removed");
assert!(!src_base.exists(), "src must be removed");
let log_content = fs::read_to_string(&log).unwrap();
let lines: Vec<&str> = log_content.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(
lines.len(),
6,
"log must have 6 lines (3 mkdir + move + 2 rmdir)"
);
let entries: Vec<serde_json::Value> = lines
.iter()
.enumerate()
.map(|(i, l)| {
serde_json::from_str(l).unwrap_or_else(|e| panic!("line {i} must be valid JSON: {e}"))
})
.collect();
for (i, entry) in entries.iter().enumerate() {
let obj = entry.as_object().unwrap();
let keys: Vec<&str> = obj.keys().map(|k| k.as_str()).collect();
assert_eq!(
&keys[..4],
&["ts", "act", "src", "dst"],
"line {i} must have canonical keys first, got: {keys:?}"
);
chrono::DateTime::parse_from_rfc3339(entry["ts"].as_str().unwrap())
.unwrap_or_else(|_| panic!("line {i} ts must be valid RFC3339"));
}
assert_eq!(entries[0]["act"], "mkdir");
assert_eq!(entries[0]["src"], "-");
assert!(
entries[0]["dst"].as_str().unwrap().ends_with("/dst"),
"line 0 mkdir dst must be abs dst/, got: {}",
entries[0]["dst"]
);
assert_eq!(entries[1]["act"], "mkdir");
assert_eq!(entries[1]["src"], "-");
assert!(
entries[1]["dst"].as_str().unwrap().ends_with("/dst/y"),
"line 1 mkdir dst must be abs dst/y, got: {}",
entries[1]["dst"]
);
assert_eq!(entries[2]["act"], "mkdir");
assert_eq!(entries[2]["src"], "-");
assert!(
entries[2]["dst"].as_str().unwrap().ends_with("/dst/y/z"),
"line 2 mkdir dst must be abs dst/y/z, got: {}",
entries[2]["dst"]
);
assert_eq!(entries[3]["act"], "move");
assert!(
entries[3]["src"]
.as_str()
.unwrap()
.ends_with("/src/x/file.txt"),
"move src got: {}",
entries[3]["src"]
);
assert!(
entries[3]["dst"]
.as_str()
.unwrap()
.ends_with("/dst/y/z/file.txt"),
"move dst got: {}",
entries[3]["dst"]
);
assert_eq!(entries[4]["act"], "rmdir");
assert_eq!(entries[4]["dst"], "-");
assert!(
entries[4]["src"].as_str().unwrap().ends_with("/src/x"),
"line 4 rmdir src must be abs src/x, got: {}",
entries[4]["src"]
);
assert_eq!(entries[5]["act"], "rmdir");
assert_eq!(entries[5]["dst"], "-");
assert!(
entries[5]["src"].as_str().unwrap().ends_with("/src"),
"line 5 rmdir src must be abs src, got: {}",
entries[5]["src"]
);
}
#[test]
fn t_move_refuses_dangling_symlink_dest() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src.txt");
fs::write(&src, b"source content").unwrap();
let dst = tmp.path().join("dst.txt");
std::os::unix::fs::symlink(tmp.path().join("does-not-exist"), &dst).unwrap();
let log = tmp.path().join("log.jsonl");
Command::cargo_bin("logmv")
.unwrap()
.args([
log.to_str().unwrap(),
src.to_str().unwrap(),
dst.to_str().unwrap(),
])
.assert()
.failure()
.stdout(predicate::str::is_empty())
.stderr(predicate::str::is_empty().not());
assert!(src.exists(), "src must be intact after refusal");
assert_eq!(
fs::read(&src).unwrap(),
b"source content",
"src content must be unchanged"
);
assert!(
fs::symlink_metadata(&dst).unwrap().file_type().is_symlink(),
"dst symlink must not be clobbered"
);
assert!(!log.exists(), "no log file must be created on refusal");
}
#[test]
fn t_trash_disambiguates_around_dangling_symlink() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("file.txt");
fs::write(&src, b"new content").unwrap();
let home_tmp = tempfile::tempdir().unwrap();
let trash_root = home_tmp.path().join(".Trash");
fs::create_dir(&trash_root).unwrap();
std::os::unix::fs::symlink(home_tmp.path().join("nope"), trash_root.join("file.txt")).unwrap();
let log = dir.path().join("log.jsonl");
Command::cargo_bin("logmv")
.unwrap()
.env("HOME", home_tmp.path())
.args([log.to_str().unwrap(), "--trash", src.to_str().unwrap()])
.assert()
.success()
.stdout(predicate::str::is_empty());
assert!(!src.exists(), "source must be gone after trash");
assert!(
fs::symlink_metadata(trash_root.join("file.txt"))
.unwrap()
.file_type()
.is_symlink(),
"pre-existing dangling symlink must not be clobbered"
);
assert_eq!(
fs::read(trash_root.join("file-1.txt")).unwrap(),
b"new content",
"trashed content must land at disambiguated name"
);
let log_content = fs::read_to_string(&log).unwrap();
let lines: Vec<&str> = log_content.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(lines.len(), 1, "log must have exactly one line");
let entry: serde_json::Value =
serde_json::from_str(lines[0]).expect("log line must be valid JSON");
assert_eq!(entry["act"], "trash");
let expected_dst = std::fs::canonicalize(&trash_root)
.unwrap()
.join("file-1.txt");
let expected_dst_str = expected_dst.to_str().unwrap();
let dst_val = entry["dst"].as_str().expect("dst must be a string");
assert!(
dst_val.starts_with('/'),
"dst must be absolute, got: {dst_val}"
);
assert!(
dst_val.ends_with("/file-1.txt"),
"dst must end with /file-1.txt (disambiguated suffix), got: {dst_val}"
);
assert_eq!(
dst_val, expected_dst_str,
"dst must be the canonical landing path for the disambiguated file"
);
}
#[test]
fn t_trash_rejects_empty_home() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("precious.txt");
fs::write(&src, b"irreplaceable").unwrap();
fs::create_dir(dir.path().join(".Trash")).unwrap();
let log = dir.path().join("trash.log");
Command::cargo_bin("logmv")
.unwrap()
.current_dir(dir.path())
.env("HOME", "")
.args([log.to_str().unwrap(), "--trash", src.to_str().unwrap()])
.assert()
.failure()
.stderr(predicate::str::is_empty().not());
assert!(src.exists(), "source must be intact on rejection");
assert_eq!(fs::read(&src).unwrap(), b"irreplaceable");
assert!(
!dir.path().join(".Trash").join("precious.txt").exists(),
"file must NOT be trashed into cwd-relative .Trash"
);
assert!(!log.exists(), "no log written on pre-rename rejection");
}
#[test]
fn t_trash_rejects_relative_home() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("precious.txt");
fs::write(&src, b"irreplaceable").unwrap();
fs::create_dir_all(dir.path().join("relhome").join(".Trash")).unwrap();
let log = dir.path().join("trash.log");
Command::cargo_bin("logmv")
.unwrap()
.current_dir(dir.path())
.env("HOME", "relhome")
.args([log.to_str().unwrap(), "--trash", src.to_str().unwrap()])
.assert()
.failure()
.stderr(predicate::str::is_empty().not());
assert!(src.exists(), "source must be intact on rejection");
assert_eq!(fs::read(&src).unwrap(), b"irreplaceable");
assert!(
!dir.path()
.join("relhome")
.join(".Trash")
.join("precious.txt")
.exists(),
"file must NOT be trashed into cwd-relative relhome/.Trash"
);
assert!(!log.exists(), "no log written on pre-rename rejection");
}
#[test]
fn t_flag_after_positionals_rejected() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src.txt");
fs::write(&src, b"hello").unwrap();
let dst = dir.path().join("dst.txt");
let log = dir.path().join("move.log");
Command::cargo_bin("logmv")
.unwrap()
.current_dir(dir.path())
.args([
log.to_str().unwrap(),
"src.txt",
"dst.txt",
"--mkdir",
"somevalue",
])
.assert()
.failure()
.stderr(predicate::str::is_empty().not());
assert!(
!log.exists(),
"no log file must be created when a --flag token appears after positionals"
);
assert!(src.exists(), "src must be intact after rejection");
assert_eq!(fs::read(&src).unwrap(), b"hello");
assert!(!dst.exists(), "dst must not be created after rejection");
}
#[test]
fn t_trash_flag_after_positionals_rejected() {
let dir = tempfile::tempdir().unwrap();
let home_tmp = tempfile::tempdir().unwrap(); let trash_root = home_tmp.path().join(".Trash");
fs::create_dir(&trash_root).unwrap();
let p = dir.path().join("precious.txt");
fs::write(&p, b"irreplaceable").unwrap();
let log = dir.path().join("trash.log");
Command::cargo_bin("logmv")
.unwrap()
.env("HOME", home_tmp.path())
.args([
log.to_str().unwrap(),
"--trash",
p.to_str().unwrap(),
"a",
"b",
"--mkdir",
"c",
])
.assert()
.failure()
.stderr(predicate::str::is_empty().not());
assert!(
!log.exists(),
"no log file must be created when a --flag token appears after trash positionals"
);
assert!(p.exists(), "source must be intact after rejection");
assert_eq!(fs::read(&p).unwrap(), b"irreplaceable");
assert!(
!trash_root.join("precious.txt").exists(),
"file must NOT be trashed into $HOME/.Trash on rejection"
);
}
#[test]
fn t_hyphen_metadata_key_supported() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src.txt");
fs::write(&src, b"hello").unwrap();
let log = dir.path().join("move.log");
Command::cargo_bin("logmv")
.unwrap()
.current_dir(dir.path())
.args([
log.to_str().unwrap(),
"src.txt",
"dst.txt",
"created-by",
"alice",
])
.assert()
.success();
assert!(!src.exists(), "src must be gone after move");
let dst = dir.path().join("dst.txt");
assert!(dst.exists(), "dst must exist after move");
let log_content = fs::read_to_string(&log).unwrap();
let lines: Vec<&str> = log_content.lines().collect();
assert_eq!(lines.len(), 1, "log must have exactly one line");
let entry: serde_json::Value =
serde_json::from_str(lines[0]).expect("log line must be valid JSON");
let obj = entry.as_object().unwrap();
let keys: Vec<&str> = obj.keys().map(|k| k.as_str()).collect();
assert_eq!(
&keys[..4],
&["ts", "act", "src", "dst"],
"first four keys must be canonical in order, got: {keys:?}"
);
assert_eq!(
&keys[4..],
&["created-by"],
"mid-hyphen key must follow canonical keys, got: {keys:?}"
);
assert_eq!(entry["created-by"].as_str().unwrap(), "alice");
}