use std::fs;
use std::path::Path;
use std::process::Output;
fn mkit_bin() -> &'static str {
env!("CARGO_BIN_EXE_mkit")
}
fn run_in(cwd: &Path, xdg: &Path, args: &[&str]) -> Output {
std::process::Command::new(mkit_bin())
.args(args)
.current_dir(cwd)
.env("XDG_CONFIG_HOME", xdg)
.output()
.expect("spawn mkit")
}
fn repo(files: &[(&str, &[u8])]) -> (tempfile::TempDir, tempfile::TempDir) {
let td = tempfile::tempdir().unwrap();
let xdg = tempfile::tempdir().unwrap();
let (root, x) = (td.path(), xdg.path());
assert!(run_in(root, x, &["init"]).status.success());
assert!(run_in(root, x, &["keygen"]).status.success());
for (name, content) in files {
let p = root.join(name);
if let Some(parent) = p.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&p, content).unwrap();
}
assert!(run_in(root, x, &["add", "."]).status.success());
assert!(run_in(root, x, &["commit", "-m", "init"]).status.success());
(td, xdg)
}
#[test]
fn mv_renames_file_and_stages_the_move() {
let (td, xdg) = repo(&[("a.txt", b"hello\n")]);
let (root, x) = (td.path(), xdg.path());
let out = run_in(root, x, &["mv", "a.txt", "b.txt"]);
assert!(out.status.success(), "mv failed: {out:?}");
assert!(!root.join("a.txt").exists(), "source must be moved away");
assert_eq!(fs::read(root.join("b.txt")).unwrap(), b"hello\n");
assert!(run_in(root, x, &["commit", "-m", "move"]).status.success());
let st = run_in(root, x, &["status", "--porcelain"]);
assert!(
String::from_utf8_lossy(&st.stdout).trim().is_empty(),
"tree should be clean after committing the move: {st:?}"
);
assert!(root.join("b.txt").exists());
}
#[test]
fn mv_refuses_to_clobber_existing_destination() {
let (td, xdg) = repo(&[("a.txt", b"aaa\n"), ("b.txt", b"bbb\n")]);
let (root, x) = (td.path(), xdg.path());
let out = run_in(root, x, &["mv", "a.txt", "b.txt"]);
assert!(
!out.status.success(),
"mv onto an existing path must be refused without -f: {out:?}"
);
assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"aaa\n");
assert_eq!(fs::read(root.join("b.txt")).unwrap(), b"bbb\n");
}
#[test]
fn mv_force_overwrites_existing_destination() {
let (td, xdg) = repo(&[("a.txt", b"aaa\n"), ("b.txt", b"bbb\n")]);
let (root, x) = (td.path(), xdg.path());
let out = run_in(root, x, &["mv", "-f", "a.txt", "b.txt"]);
assert!(out.status.success(), "mv -f failed: {out:?}");
assert!(!root.join("a.txt").exists());
assert_eq!(fs::read(root.join("b.txt")).unwrap(), b"aaa\n");
}
#[test]
fn mv_into_existing_directory_keeps_basename() {
let (td, xdg) = repo(&[("a.txt", b"hi\n"), ("sub/keep.txt", b"k\n")]);
let (root, x) = (td.path(), xdg.path());
let out = run_in(root, x, &["mv", "a.txt", "sub"]);
assert!(out.status.success(), "mv into dir failed: {out:?}");
assert!(!root.join("a.txt").exists());
assert_eq!(fs::read(root.join("sub/a.txt")).unwrap(), b"hi\n");
}
#[test]
fn mv_untracked_source_is_refused() {
let (td, xdg) = repo(&[("tracked.txt", b"t\n")]);
let (root, x) = (td.path(), xdg.path());
fs::write(root.join("untracked.txt"), b"u\n").unwrap();
let out = run_in(root, x, &["mv", "untracked.txt", "dest.txt"]);
assert!(
!out.status.success(),
"mv of an untracked source must be refused: {out:?}"
);
assert!(root.join("untracked.txt").exists(), "source untouched");
assert!(!root.join("dest.txt").exists());
}
#[test]
fn mv_missing_source_is_refused() {
let (td, xdg) = repo(&[("a.txt", b"a\n")]);
let (root, x) = (td.path(), xdg.path());
let out = run_in(root, x, &["mv", "ghost.txt", "dest.txt"]);
assert!(
!out.status.success(),
"mv of a missing source must fail: {out:?}"
);
}
#[test]
fn mv_directory_source_is_refused_clearly() {
let (td, xdg) = repo(&[("dir/file.txt", b"x\n")]);
let (root, x) = (td.path(), xdg.path());
let out = run_in(root, x, &["mv", "dir", "newdir"]);
assert!(
!out.status.success(),
"mv of a directory must be refused: {out:?}"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("directories"),
"expected a directory-not-supported message, got: {stderr:?}"
);
assert!(root.join("dir/file.txt").exists());
assert!(!root.join("newdir").exists());
}
#[test]
fn mv_multi_source_is_atomic_on_a_bad_source() {
let (td, xdg) = repo(&[("a.txt", b"a\n"), ("dst/keep.txt", b"k\n")]);
let (root, x) = (td.path(), xdg.path());
fs::write(root.join("untracked.txt"), b"u\n").unwrap();
let out = run_in(root, x, &["mv", "a.txt", "untracked.txt", "dst"]);
assert!(
!out.status.success(),
"batch with a bad source must fail: {out:?}"
);
assert!(
root.join("a.txt").exists(),
"valid source moved despite batch failure"
);
assert!(
!root.join("dst/a.txt").exists(),
"a.txt was partially moved"
);
}
#[cfg(unix)]
#[test]
fn mv_refuses_dangling_symlink_destination_without_force() {
use std::os::unix::fs::symlink;
let (td, xdg) = repo(&[("a.txt", b"a\n")]);
let (root, x) = (td.path(), xdg.path());
symlink("/nonexistent/target", root.join("danglink")).unwrap();
let out = run_in(root, x, &["mv", "a.txt", "danglink"]);
assert!(
!out.status.success(),
"mv onto a dangling symlink must be refused without -f: {out:?}"
);
assert!(root.join("a.txt").exists(), "source must be untouched");
}
#[cfg(unix)]
#[test]
fn mv_refuses_destination_escaping_repo_via_symlinked_dir() {
use std::os::unix::fs::symlink;
let (td, xdg) = repo(&[("a.txt", b"a\n")]);
let (root, x) = (td.path(), xdg.path());
let outside = tempfile::tempdir().unwrap();
symlink(outside.path(), root.join("link_out")).unwrap();
let out = run_in(root, x, &["mv", "a.txt", "link_out/moved.txt"]);
assert!(
!out.status.success(),
"mv to a path escaping the repo must be refused: {out:?}"
);
assert!(root.join("a.txt").exists(), "source must be untouched");
assert!(
!outside.path().join("moved.txt").exists(),
"nothing should be written outside the repository"
);
}