use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
const DBMD: &str = env!("CARGO_BIN_EXE_dbmd");
struct Store {
dir: TempDir,
}
impl Store {
fn new() -> Self {
let dir = TempDir::new().expect("tempdir");
std::fs::write(
dir.path().join("DB.md"),
"---\ntype: db-md\nscope: company\nowner: T\n---\n\n# Store\n",
)
.expect("write DB.md");
Store { dir }
}
fn root(&self) -> &Path {
self.dir.path()
}
fn abs(&self, rel: &str) -> std::path::PathBuf {
self.dir.path().join(rel)
}
fn seed(&self, rel: &str, contents: &str) {
let abs = self.abs(rel);
std::fs::create_dir_all(abs.parent().unwrap()).unwrap();
std::fs::write(abs, contents).unwrap();
}
fn seed_bytes(&self, rel: &str, bytes: &[u8]) {
let abs = self.abs(rel);
std::fs::create_dir_all(abs.parent().unwrap()).unwrap();
std::fs::write(abs, bytes).unwrap();
}
fn run(&self, args: &[&str]) -> Output {
let mut cmd = Command::new(DBMD);
cmd.args(args).arg("--dir").arg(self.root());
let out = cmd.output().expect("spawn dbmd");
Output {
code: out.status.code(),
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
}
}
fn run_now(&self, now: &str, args: &[&str]) -> Output {
let mut cmd = Command::new(DBMD);
cmd.args(args)
.arg("--dir")
.arg(self.root())
.env("DBMD_NOW", now);
let out = cmd.output().expect("spawn dbmd");
Output {
code: out.status.code(),
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
}
}
}
struct Output {
code: Option<i32>,
stdout: String,
stderr: String,
}
impl Output {
fn stdout_json(&self) -> serde_json::Value {
serde_json::from_str(self.stdout.trim())
.unwrap_or_else(|e| panic!("stdout is not JSON ({e}): {:?}", self.stdout))
}
}
#[test]
fn regression_rename_skips_non_utf8_linker_and_completes_consistently() {
let store = Store::new();
store.seed(
"records/contacts/sarah.md",
"---\ntype: contact\nsummary: x\n---\n# Sarah\n",
);
store.seed(
"records/concepts/clean.md",
"---\ntype: concept\nmeta-type: conclusion\nsummary: s\n---\nSee [[records/contacts/sarah]].\n",
);
let mut bad: Vec<u8> = Vec::new();
bad.extend_from_slice(b"---\ntype: source\nsummary: s\n---\n");
bad.extend_from_slice(b"Ref [[records/contacts/sarah]] here. caf");
bad.push(0xE9); bad.extend_from_slice(b"\n");
store.seed_bytes("sources/import/dropped.md", &bad);
let out = store.run(&[
"--json",
"rename",
"records/contacts/sarah.md",
"records/contacts/sarah-chen.md",
]);
assert_eq!(
out.code,
Some(0),
"rename must complete despite a non-UTF8 linker; stderr: {}",
out.stderr
);
assert!(
!store.abs("records/contacts/sarah.md").exists(),
"source must be moved away from <old>"
);
assert!(
store.abs("records/contacts/sarah-chen.md").exists(),
"destination must exist at <new>"
);
let clean = std::fs::read_to_string(store.abs("records/concepts/clean.md")).unwrap();
assert!(
clean.contains("[[records/contacts/sarah-chen]]"),
"clean linker must be retargeted; got: {clean}"
);
assert!(
!clean.contains("[[records/contacts/sarah]]"),
"clean linker must no longer reference the old path; got: {clean}"
);
let bad_after = std::fs::read(store.abs("sources/import/dropped.md")).unwrap();
assert_eq!(
bad_after, bad,
"the skipped non-UTF8 linker must be left byte-for-byte unchanged"
);
assert!(
out.stderr.contains("non-UTF8") && out.stderr.contains("sources/import/dropped.md"),
"a skipped non-UTF8 linker must surface a warning naming it; stderr: {}",
out.stderr
);
let v = out.stdout_json();
assert_eq!(
v["links_rewritten"], 1,
"only the clean linker counts as rewritten"
);
}
#[test]
fn regression_rename_non_utf8_linker_does_not_strand_later_linkers() {
let store = Store::new();
store.seed(
"records/contacts/sarah.md",
"---\ntype: contact\nsummary: x\n---\n# Sarah\n",
);
let mut bad: Vec<u8> = Vec::new();
bad.extend_from_slice(b"---\ntype: source\nsummary: s\n---\n");
bad.extend_from_slice(b"[[records/contacts/sarah]] ");
bad.push(0xFF); bad.extend_from_slice(b"\n");
store.seed_bytes("sources/a-import.md", &bad);
store.seed(
"sources/z-late.md",
"---\ntype: note\nsummary: s\n---\nMentions [[records/contacts/sarah|Sarah]].\n",
);
let out = store.run(&[
"rename",
"records/contacts/sarah.md",
"records/contacts/sarah-chen.md",
]);
assert_eq!(
out.code,
Some(0),
"rename must complete; stderr: {}",
out.stderr
);
let late = std::fs::read_to_string(store.abs("sources/z-late.md")).unwrap();
assert!(
late.contains("[[records/contacts/sarah-chen|Sarah]]"),
"a clean linker sorting after a non-UTF8 linker must still be rewritten; got: {late}"
);
assert!(!store.abs("records/contacts/sarah.md").exists());
assert!(store.abs("records/contacts/sarah-chen.md").exists());
}
#[test]
fn regression_rename_rewrites_self_link_through_the_deferred_move() {
let store = Store::new();
store.seed(
"records/contacts/sarah.md",
"---\ntype: contact\nsummary: x\nlinks:\n - [[records/contacts/sarah]]\n---\nI am [[records/contacts/sarah]].\n",
);
let out = store.run(&[
"--json",
"rename",
"records/contacts/sarah.md",
"records/contacts/sarah-chen.md",
]);
assert_eq!(
out.code,
Some(0),
"self-link rename must succeed; stderr: {}",
out.stderr
);
assert!(!store.abs("records/contacts/sarah.md").exists());
let moved = std::fs::read_to_string(store.abs("records/contacts/sarah-chen.md")).unwrap();
assert!(
moved.contains("[[records/contacts/sarah-chen]]"),
"the self-link must be retargeted to the new path; got: {moved}"
);
assert!(
!moved.contains("[[records/contacts/sarah]]"),
"no stale self-link to the old path may remain; got: {moved}"
);
}
#[test]
fn regression_rename_restamps_moved_file_updated_but_not_linkers() {
let store = Store::new();
store.seed(
"records/contacts/sarah.md",
"---\ntype: contact\ncreated: 2026-01-01T00:00:00+00:00\nupdated: 2026-01-01T00:00:00+00:00\nsummary: x\n---\n# Sarah\n",
);
store.seed(
"records/concepts/clean.md",
"---\ntype: concept\nmeta-type: conclusion\ncreated: 2026-01-01T00:00:00+00:00\nupdated: 2026-01-01T00:00:00+00:00\nsummary: s\n---\nSee [[records/contacts/sarah]].\n",
);
let now = "2026-05-29T18:00:00Z";
let out = store.run_now(
now,
&[
"rename",
"records/contacts/sarah.md",
"records/contacts/sarah-chen.md",
],
);
assert_eq!(
out.code,
Some(0),
"rename must succeed; stderr: {}",
out.stderr
);
let moved = std::fs::read_to_string(store.abs("records/contacts/sarah-chen.md")).unwrap();
assert!(
moved.contains("created: 2026-01-01T00:00:00+00:00"),
"the moved file's `created` must be preserved; got: {moved}"
);
assert!(
moved.contains("updated: 2026-05-29T18:00:00+00:00"),
"the moved file's `updated` must be re-stamped to now; got: {moved}"
);
assert!(
!moved.contains("updated: 2026-01-01T00:00:00+00:00"),
"the stale `updated` must be gone from the moved file; got: {moved}"
);
let clean = std::fs::read_to_string(store.abs("records/concepts/clean.md")).unwrap();
assert!(
clean.contains("[[records/contacts/sarah-chen]]"),
"the linker's link text must be retargeted; got: {clean}"
);
assert!(
clean.contains("updated: 2026-01-01T00:00:00+00:00"),
"the linker's `updated` must NOT be cascaded by the rename; got: {clean}"
);
}
#[test]
fn regression_rename_moved_file_without_frontmatter_is_not_restamped() {
let store = Store::new();
let raw = "no frontmatter here, just text\n";
store.seed("records/notes/plain.md", raw);
let out = store.run_now(
"2026-05-29T18:00:00Z",
&[
"rename",
"records/notes/plain.md",
"records/notes/plain2.md",
],
);
assert_eq!(
out.code,
Some(0),
"rename of a frontmatter-less file must still succeed; stderr: {}",
out.stderr
);
assert!(!store.abs("records/notes/plain.md").exists());
let moved = std::fs::read_to_string(store.abs("records/notes/plain2.md")).unwrap();
assert_eq!(
moved, raw,
"a frontmatter-less moved file must survive byte-for-byte (no re-stamp)"
);
}
#[cfg(unix)]
#[test]
fn regression_rename_refuses_destination_through_in_store_symlink() {
use std::os::unix::fs::symlink;
let store = Store::new();
store.seed(
"records/contacts/sarah.md",
"---\ntype: contact\nsummary: x\n---\n# Sarah\n",
);
let outside = TempDir::new().expect("outside tempdir");
std::fs::create_dir_all(store.abs("records/links")).unwrap();
symlink(outside.path(), store.abs("records/links/escape")).unwrap();
let out = store.run(&[
"rename",
"records/contacts/sarah.md",
"records/links/escape/pwned.md",
]);
assert_ne!(
out.code,
Some(0),
"rename through a symlinked-out dir must be refused; code={:?} stderr={}",
out.code,
out.stderr
);
assert!(
out.stderr.contains("PATH_OUTSIDE_STORE") || out.stderr.to_lowercase().contains("outside"),
"the refusal should name the containment failure; stderr: {}",
out.stderr
);
assert!(
!outside.path().join("pwned.md").exists(),
"the moved file escaped the store root"
);
assert!(
store.abs("records/contacts/sarah.md").exists(),
"the source must survive a refused rename"
);
}
#[cfg(unix)]
#[test]
fn regression_rename_refuses_source_through_in_store_symlink() {
use std::os::unix::fs::symlink;
let store = Store::new();
let outside = TempDir::new().expect("outside tempdir");
let precious = outside.path().join("precious.md");
std::fs::write(
&precious,
"---\ntype: contact\nsummary: secret\n---\n# Precious\n",
)
.unwrap();
std::fs::create_dir_all(store.abs("records")).unwrap();
symlink(outside.path(), store.abs("records/linkdir")).unwrap();
let out = store.run(&[
"rename",
"records/linkdir/precious.md",
"records/contacts/moved.md",
]);
assert_ne!(
out.code,
Some(0),
"rename of a symlinked-out <old> must be refused; code={:?} stderr={}",
out.code,
out.stderr
);
assert!(
out.stderr.contains("PATH_OUTSIDE_STORE") || out.stderr.to_lowercase().contains("outside"),
"the refusal should name the containment failure; stderr: {}",
out.stderr
);
assert!(
precious.exists(),
"the out-of-store source must NOT be moved/destroyed by a refused rename"
);
assert_eq!(
std::fs::read_to_string(&precious).unwrap(),
"---\ntype: contact\nsummary: secret\n---\n# Precious\n",
"the out-of-store source bytes must survive verbatim"
);
assert!(
!store.abs("records/contacts/moved.md").exists(),
"nothing must land at the destination"
);
}
#[cfg(unix)]
#[test]
fn regression_rename_does_not_rewrite_out_of_store_linker() {
use std::os::unix::fs::symlink;
let store = Store::new();
store.seed(
"records/contacts/old-name.md",
"---\ntype: contact\nsummary: x\n---\n# Old\n",
);
let outside = TempDir::new().expect("outside tempdir");
let outside_linker = outside.path().join("linker.md");
std::fs::write(
&outside_linker,
"---\ntype: note\nsummary: s\n---\nSee [[records/contacts/old-name]].\n",
)
.unwrap();
std::fs::create_dir_all(store.abs("sources")).unwrap();
symlink(outside.path(), store.abs("sources/extlink")).unwrap();
let out = store.run(&[
"rename",
"records/contacts/old-name.md",
"records/contacts/new-name.md",
]);
assert_eq!(
out.code,
Some(0),
"the in-store rename must complete; stderr: {}",
out.stderr
);
assert!(store.abs("records/contacts/new-name.md").exists());
assert!(!store.abs("records/contacts/old-name.md").exists());
assert_eq!(
std::fs::read_to_string(&outside_linker).unwrap(),
"---\ntype: note\nsummary: s\n---\nSee [[records/contacts/old-name]].\n",
"the out-of-store linker must NOT be rewritten"
);
assert!(
out.stderr.to_lowercase().contains("out-of-store")
|| out.stderr.to_lowercase().contains("symlink"),
"a skipped out-of-store linker should surface a warning; stderr: {}",
out.stderr
);
}
#[test]
fn regression_rename_non_creatable_destination_leaves_linkers_untouched() {
let store = Store::new();
store.seed(
"records/contacts/sarah.md",
"---\ntype: contact\nsummary: x\n---\n# Sarah\n",
);
let linker_before = "---\ntype: note\nsummary: s\n---\nMet [[records/contacts/sarah]] today.\n";
store.seed("records/meetings/2026/06/m.md", linker_before);
store.seed(
"records/contacts/blocker.md",
"---\ntype: contact\nsummary: b\n---\n# Blocker\n",
);
let out = store.run(&[
"rename",
"records/contacts/sarah.md",
"records/contacts/blocker.md/inner.md",
]);
assert_ne!(
out.code,
Some(0),
"a rename onto a file-as-parent destination must fail; stderr: {}",
out.stderr
);
assert_eq!(
std::fs::read_to_string(store.abs("records/meetings/2026/06/m.md")).unwrap(),
linker_before,
"a failed rename must not rewrite any authored linker"
);
assert!(
store.abs("records/contacts/sarah.md").exists(),
"the source must survive a failed rename"
);
}