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 {
Self::with_db_md("---\ntype: db-md\nscope: company\nowner: T\n---\n\n# Store\n")
}
fn with_db_md(db_md: &str) -> Self {
let dir = TempDir::new().expect("tempdir");
std::fs::write(dir.path().join("DB.md"), db_md).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 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_from_store_dir(&self, args: &[&str]) -> Output {
let mut cmd = Command::new(DBMD);
cmd.args(args).current_dir(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(),
}
}
}
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))
}
fn error_json(&self) -> serde_json::Value {
serde_json::from_str(self.stderr.trim())
.unwrap_or_else(|e| panic!("stderr is not JSON ({e}): {:?}", self.stderr))
}
}
fn fm_value(file_text: &str, key: &str) -> String {
let prefix = format!("{key}:");
file_text
.lines()
.find_map(|line| line.trim().strip_prefix(&prefix))
.map(|v| v.trim().trim_matches(['"', '\'']).to_string())
.unwrap_or_else(|| panic!("frontmatter key `{key}` not found in:\n{file_text}"))
}
#[test]
fn write_flat_contact_composes_summary_and_prints_resolved_path() {
let store = Store::new();
let out = store.run(&[
"write",
"records/contacts/sarah.md",
"--type",
"contact",
"--fm",
"role=VP Sales",
]);
assert_eq!(out.code, Some(0), "stderr: {}", out.stderr);
assert_eq!(out.stdout.trim(), "records/contacts/sarah.md");
let written = std::fs::read_to_string(store.abs("records/contacts/sarah.md")).unwrap();
assert!(written.contains("type: contact"), "{written}");
assert!(written.contains("summary: VP Sales"), "{written}");
assert!(written.contains("created:"), "{written}");
assert!(written.contains("updated:"), "{written}");
}
#[test]
fn write_seeds_created_and_updated_as_valid_rfc3339() {
let store = Store::new();
let out = store.run(&[
"write",
"records/contacts/sarah.md",
"--type",
"contact",
"--summary",
"VP Sales",
]);
assert_eq!(out.code, Some(0), "stderr: {}", out.stderr);
let written = std::fs::read_to_string(store.abs("records/contacts/sarah.md")).unwrap();
let created = fm_value(&written, "created");
let updated = fm_value(&written, "updated");
assert!(
chrono::DateTime::parse_from_rfc3339(&created).is_ok(),
"seeded `created` must be valid RFC3339, got {created:?}"
);
assert!(
chrono::DateTime::parse_from_rfc3339(&updated).is_ok(),
"seeded `updated` must be valid RFC3339, got {updated:?}"
);
assert_eq!(
created, updated,
"`created` and `updated` must seed from the same instant"
);
}
#[test]
fn write_source_email_auto_shards_by_date_and_prints_sharded_path() {
let store = Store::new();
let out = store.run(&[
"write",
"anything/e1.md", "--type",
"email",
"--fm",
"date=2026-05-22",
"--fm",
"from=a@x.com",
"--fm",
"to=b@y.com",
"--fm",
"subject=Renewal",
]);
assert_eq!(out.code, Some(0), "stderr: {}", out.stderr);
assert_eq!(out.stdout.trim(), "sources/emails/2026/05/e1.md");
assert!(store.abs("sources/emails/2026/05/e1.md").exists());
}
#[test]
fn write_wiki_page_honours_explicit_subfolder() {
for sub in ["people", "projects", "synthesis"] {
let store = Store::new();
let path = format!("wiki/{sub}/page.md");
let out = store.run(&[
"write",
&path,
"--type",
"wiki-page",
"--summary",
"s",
"--fm",
"topic=T",
]);
assert_eq!(out.code, Some(0), "stderr: {}", out.stderr);
assert_eq!(
out.stdout.trim(),
path,
"explicit wiki/{sub}/ must be honoured"
);
assert!(store.abs(&path).exists(), "file must land in wiki/{sub}/");
}
}
#[test]
fn write_wiki_page_bare_filename_falls_back_to_topics_default() {
let store = Store::new();
let out = store.run(&[
"write",
"just-a-name.md",
"--type",
"wiki-page",
"--summary",
"s",
"--fm",
"topic=T",
]);
assert_eq!(out.code, Some(0), "stderr: {}", out.stderr);
assert_eq!(out.stdout.trim(), "wiki/topics/just-a-name.md");
assert!(store.abs("wiki/topics/just-a-name.md").exists());
}
#[test]
fn write_wiki_page_wrong_layer_path_falls_back_to_default_folder() {
let store = Store::new();
let out = store.run(&[
"write",
"sources/emails/weird.md",
"--type",
"wiki-page",
"--summary",
"s",
"--fm",
"topic=T",
]);
assert_eq!(out.code, Some(0), "stderr: {}", out.stderr);
assert_eq!(out.stdout.trim(), "wiki/topics/weird.md");
assert!(store.abs("wiki/topics/weird.md").exists());
}
#[test]
fn write_event_record_honours_explicit_subfolder_and_still_shards() {
let store = Store::new();
let out = store.run(&[
"write",
"records/meetings/m1.md",
"--type",
"meeting",
"--summary",
"s",
"--fm",
"date=2026-04-14",
]);
assert_eq!(out.code, Some(0), "stderr: {}", out.stderr);
assert_eq!(out.stdout.trim(), "records/meetings/2026/04/m1.md");
assert!(store.abs("records/meetings/2026/04/m1.md").exists());
}
#[test]
fn write_json_emits_written_path_and_type() {
let store = Store::new();
let out = store.run(&[
"--json",
"write",
"records/contacts/sarah.md",
"--type",
"contact",
"--summary",
"Director of Ops",
]);
assert_eq!(out.code, Some(0), "stderr: {}", out.stderr);
let v = out.stdout_json();
assert_eq!(v["written"], "records/contacts/sarah.md");
assert_eq!(v["type"], "contact");
}
#[test]
fn write_maintains_index_write_through() {
let store = Store::new();
let out = store.run(&[
"write",
"records/contacts/sarah.md",
"--type",
"contact",
"--summary",
"VP Sales",
]);
assert_eq!(out.code, Some(0), "stderr: {}", out.stderr);
assert!(
store.abs("records/contacts/index.md").exists(),
"index.md missing"
);
assert!(
store.abs("records/contacts/index.jsonl").exists(),
"index.jsonl missing"
);
assert!(store.abs("index.md").exists(), "root index.md missing");
let jsonl = std::fs::read_to_string(store.abs("records/contacts/index.jsonl")).unwrap();
assert!(
jsonl.contains("\"records/contacts/sarah.md\""),
"jsonl: {jsonl}"
);
}
#[test]
fn write_refuses_when_not_a_store() {
let dir = TempDir::new().unwrap();
let out = Command::new(DBMD)
.args([
"--json",
"write",
"records/contacts/x.md",
"--type",
"contact",
"--summary",
"s",
])
.arg("--dir")
.arg(dir.path())
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3), "NOT_A_STORE is exit 3");
let v: serde_json::Value = serde_json::from_slice(&out.stderr).unwrap();
assert_eq!(v["error"]["code"], "NOT_A_STORE");
}
#[test]
fn write_collision_refuses_with_structured_error_and_does_not_overwrite() {
let store = Store::new();
let original = "---\ntype: contact\nsummary: ORIGINAL SUMMARY\nname: Sarah\n---\n\n# Sarah\n\nOriginal body.\n";
store.seed("records/contacts/sarah.md", original);
let out = store.run(&[
"--json",
"write",
"records/contacts/sarah.md",
"--type",
"contact",
"--summary",
"A DIFFERENT SUMMARY",
]);
assert_eq!(
out.code,
Some(5),
"collision is exit 5; stderr: {}",
out.stderr
);
let err = out.error_json();
assert_eq!(err["error"]["code"], "PATH_COLLISION");
let msg = err["error"]["message"].as_str().unwrap();
assert!(msg.contains("already exists"), "msg: {msg}");
assert!(
msg.contains("contact"),
"message must carry existing type: {msg}"
);
assert!(
msg.contains("ORIGINAL SUMMARY"),
"message must carry existing summary: {msg}"
);
let after = std::fs::read_to_string(store.abs("records/contacts/sarah.md")).unwrap();
assert_eq!(
after, original,
"a refused write must NOT modify the existing file"
);
}
#[test]
fn write_frozen_page_refuses_and_creates_no_file() {
let store = Store::with_db_md(
"---\ntype: db-md\n---\n\n# Store\n\n## Policies\n\n### Frozen pages\n- records/decisions/frozen.md\n",
);
let out = store.run(&[
"--json",
"write",
"records/decisions/frozen.md",
"--type",
"decision",
"--summary",
"should never be written",
]);
assert_eq!(
out.code,
Some(4),
"frozen-page refusal is exit 4; stderr: {}",
out.stderr
);
let err = out.error_json();
assert_eq!(err["error"]["code"], "POLICY_FROZEN_PAGE");
assert!(
err["error"]["message"]
.as_str()
.unwrap()
.contains("records/decisions/frozen.md"),
"error names the frozen path"
);
assert!(
!store.abs("records/decisions/frozen.md").exists(),
"a frozen-page refusal must NOT create the file"
);
assert!(
!store.abs("records/decisions/index.md").exists(),
"a frozen-page refusal must NOT touch the index"
);
}
#[test]
fn write_frozen_page_refuses_dot_slash_spelling_too() {
let store = Store::with_db_md(
"---\ntype: db-md\n---\n\n# S\n\n## Policies\n\n### Frozen pages\n- records/decisions/frozen.md\n",
);
let out = store.run(&[
"write",
"./records/decisions/frozen.md",
"--type",
"decision",
"--summary",
"x",
]);
assert_eq!(out.code, Some(4), "stderr: {}", out.stderr);
assert!(!store.abs("records/decisions/frozen.md").exists());
}
#[test]
fn write_and_rename_refuse_an_extensionless_frozen_entry_identically() {
let db_md =
"---\ntype: db-md\n---\n\n# S\n\n## Policies\n\n### Frozen pages\n- records/decisions/q1\n";
let store = Store::with_db_md(db_md);
let out = store.run(&[
"--json",
"write",
"records/decisions/q1.md",
"--type",
"decision",
"--summary",
"should never be written",
]);
assert_eq!(
out.code,
Some(4),
"write must refuse an extensionless frozen entry (exit 4); stderr: {}",
out.stderr
);
assert_eq!(out.error_json()["error"]["code"], "POLICY_FROZEN_PAGE");
assert!(
!store.abs("records/decisions/q1.md").exists(),
"a refused write must NOT create the frozen file"
);
let store = Store::with_db_md(db_md);
store.seed(
"records/decisions/draft.md",
"---\ntype: decision\nsummary: a draft\n---\n# Draft\n",
);
let out = store.run(&[
"rename",
"records/decisions/draft.md",
"records/decisions/q1.md",
]);
assert_eq!(
out.code,
Some(4),
"rename must refuse landing on an extensionless frozen entry (exit 4); stderr: {}",
out.stderr
);
assert!(
store.abs("records/decisions/draft.md").exists(),
"a refused rename must leave the source in place"
);
assert!(
!store.abs("records/decisions/q1.md").exists(),
"a refused rename must NOT create the frozen destination"
);
}
#[test]
fn write_wiki_page_deriving_from_ignored_type_warns_but_writes() {
let store = Store::with_db_md(
"---\ntype: db-md\n---\n\n# S\n\n## Policies\n\n### Ignored types\n- secret\n",
);
store.seed(
"records/secrets/s.md",
"---\ntype: secret\nsummary: hush\n---\n\n# secret\n",
);
let out = store.run(&[
"write",
"topics/derived.md",
"--type",
"wiki-page",
"--summary",
"A synthesis",
"--fm",
"derived_from=[[records/secrets/s]]",
]);
assert_eq!(out.code, Some(0), "stderr: {}", out.stderr);
assert!(store.abs("wiki/topics/derived.md").exists());
assert!(
out.stderr.contains("ignored-type") && out.stderr.contains("records/secrets/s"),
"expected an ignored-type-derivation warning on stderr, got: {:?}",
out.stderr
);
}
#[test]
fn link_appends_full_path_wiki_link_to_body() {
let store = Store::new();
store.seed(
"records/contacts/sarah.md",
"---\ntype: contact\nsummary: x\n---\n# Sarah\n\nNotes.\n",
);
store.seed(
"records/companies/acme.md",
"---\ntype: company\nsummary: y\n---\n# Acme\n",
);
let out = store.run(&[
"link",
"records/contacts/sarah.md",
"records/companies/acme",
]);
assert_eq!(out.code, Some(0), "stderr: {}", out.stderr);
let body = std::fs::read_to_string(store.abs("records/contacts/sarah.md")).unwrap();
assert!(body.contains("[[records/companies/acme]]"), "{body}");
assert!(body.contains("type: contact"));
assert!(body.contains("Notes."));
}
#[test]
fn link_rejects_short_form_target() {
let store = Store::new();
store.seed(
"records/contacts/sarah.md",
"---\ntype: contact\nsummary: x\n---\n# Sarah\n",
);
let out = store.run(&["--json", "link", "records/contacts/sarah.md", "sarah-chen"]);
assert_eq!(out.code, Some(1), "stderr: {}", out.stderr);
assert_eq!(out.error_json()["error"]["code"], "WIKI_LINK_SHORT_FORM");
let body = std::fs::read_to_string(store.abs("records/contacts/sarah.md")).unwrap();
assert!(!body.contains("[[sarah-chen]]"), "{body}");
}
#[test]
fn link_refuses_frozen_from_file() {
let store = Store::with_db_md(
"---\ntype: db-md\n---\n\n# S\n\n## Policies\n\n### Frozen pages\n- records/decisions/d.md\n",
);
let frozen = "---\ntype: decision\nsummary: x\n---\n# D\n";
store.seed("records/decisions/d.md", frozen);
store.seed(
"records/companies/acme.md",
"---\ntype: company\nsummary: y\n---\n# A\n",
);
let out = store.run(&["link", "records/decisions/d.md", "records/companies/acme"]);
assert_eq!(out.code, Some(4), "stderr: {}", out.stderr);
assert_eq!(
std::fs::read_to_string(store.abs("records/decisions/d.md")).unwrap(),
frozen
);
}
#[test]
fn link_refuses_frozen_from_file_passed_as_absolute_path() {
let store = Store::with_db_md(
"---\ntype: db-md\n---\n\n# S\n\n## Policies\n\n### Frozen pages\n- records/decisions/d.md\n",
);
let frozen = "---\ntype: decision\nsummary: x\n---\n# D\n";
store.seed("records/decisions/d.md", frozen);
store.seed(
"records/companies/acme.md",
"---\ntype: company\nsummary: y\n---\n# A\n",
);
let abs_from = store.abs("records/decisions/d.md");
let out =
store.run_from_store_dir(&["link", abs_from.to_str().unwrap(), "records/companies/acme"]);
assert_eq!(
out.code,
Some(4),
"absolute frozen <from> must be refused; stderr: {}",
out.stderr
);
assert_eq!(std::fs::read_to_string(&abs_from).unwrap(), frozen);
}
#[test]
fn link_refuses_an_extensionless_frozen_from_entry() {
let store = Store::with_db_md(
"---\ntype: db-md\n---\n\n# S\n\n## Policies\n\n### Frozen pages\n- records/decisions/q1\n",
);
let frozen = "---\ntype: decision\nsummary: finalized\n---\n# Q1\n";
store.seed("records/decisions/q1.md", frozen);
store.seed(
"records/companies/acme.md",
"---\ntype: company\nsummary: y\n---\n# A\n",
);
let out = store.run(&["link", "records/decisions/q1.md", "records/companies/acme"]);
assert_eq!(
out.code,
Some(4),
"link from a file frozen by an extensionless entry must be refused; stderr: {}",
out.stderr
);
assert_eq!(
std::fs::read_to_string(store.abs("records/decisions/q1.md")).unwrap(),
frozen,
"a refused link must NOT append to the frozen file"
);
}
#[test]
fn rename_moves_file_and_rewrites_incoming_links() {
let store = Store::new();
store.seed(
"records/contacts/sarah.md",
"---\ntype: contact\nsummary: x\n---\n# Sarah\n",
);
store.seed(
"wiki/topics/a.md",
"---\ntype: wiki-page\nsummary: s\n---\nSee [[records/contacts/sarah]].\n",
);
store.seed(
"wiki/topics/b.md",
"---\ntype: wiki-page\nsummary: s\n---\nWith [[records/contacts/sarah|Sarah]].\n",
);
let out = store.run(&[
"--json",
"rename",
"records/contacts/sarah.md",
"records/contacts/sarah-chen.md",
]);
assert_eq!(out.code, Some(0), "stderr: {}", out.stderr);
assert!(!store.abs("records/contacts/sarah.md").exists());
assert!(store.abs("records/contacts/sarah-chen.md").exists());
let a = std::fs::read_to_string(store.abs("wiki/topics/a.md")).unwrap();
let b = std::fs::read_to_string(store.abs("wiki/topics/b.md")).unwrap();
assert!(a.contains("[[records/contacts/sarah-chen]]"), "{a}");
assert!(b.contains("[[records/contacts/sarah-chen|Sarah]]"), "{b}");
let v = out.stdout_json();
assert_eq!(v["links_rewritten"], 2);
assert_eq!(v["renamed"]["from"], "records/contacts/sarah.md");
assert_eq!(v["renamed"]["to"], "records/contacts/sarah-chen.md");
}
#[test]
fn rename_refuses_when_destination_exists() {
let store = Store::new();
store.seed(
"records/contacts/a.md",
"---\ntype: contact\nsummary: x\n---\n# A\n",
);
let dest = "---\ntype: contact\nsummary: y\n---\n# B\n";
store.seed("records/contacts/b.md", dest);
let out = store.run(&["rename", "records/contacts/a.md", "records/contacts/b.md"]);
assert_eq!(
out.code,
Some(5),
"destination collision is exit 5; stderr: {}",
out.stderr
);
assert!(
store.abs("records/contacts/a.md").exists(),
"source must survive a refused rename"
);
assert_eq!(
std::fs::read_to_string(store.abs("records/contacts/b.md")).unwrap(),
dest
);
}
#[test]
fn rename_refuses_frozen_source() {
let store = Store::with_db_md(
"---\ntype: db-md\n---\n\n# S\n\n## Policies\n\n### Frozen pages\n- records/decisions/d.md\n",
);
let frozen = "---\ntype: decision\nsummary: x\n---\n# D\n";
store.seed("records/decisions/d.md", frozen);
let out = store.run(&["rename", "records/decisions/d.md", "records/decisions/e.md"]);
assert_eq!(out.code, Some(4), "stderr: {}", out.stderr);
assert!(store.abs("records/decisions/d.md").exists());
assert!(!store.abs("records/decisions/e.md").exists());
}
#[test]
fn rename_refuses_frozen_source_passed_as_absolute_path() {
let store = Store::with_db_md(
"---\ntype: db-md\n---\n\n# S\n\n## Policies\n\n### Frozen pages\n- records/decisions/d.md\n",
);
let frozen = "---\ntype: decision\nsummary: x\n---\n# D\n";
store.seed("records/decisions/d.md", frozen);
let abs_old = store.abs("records/decisions/d.md");
let out = store.run_from_store_dir(&[
"rename",
abs_old.to_str().unwrap(),
"records/decisions/e.md",
]);
assert_eq!(
out.code,
Some(4),
"absolute frozen <old> must be refused; stderr: {}",
out.stderr
);
assert!(
abs_old.exists(),
"frozen source must survive a refused absolute-path rename"
);
assert_eq!(std::fs::read_to_string(&abs_old).unwrap(), frozen);
assert!(!store.abs("records/decisions/e.md").exists());
}
#[test]
fn spec_prints_bundled_standard() {
let out = Command::new(DBMD).arg("spec").output().unwrap();
assert_eq!(out.status.code(), Some(0));
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains("db.md"), "bundled SPEC should mention db.md");
assert!(!text.trim().is_empty(), "SPEC output must not be empty");
}
#[test]
fn spec_json_wraps_text_in_object() {
let out = Command::new(DBMD)
.args(["--json", "spec"])
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert!(v["spec"].is_string(), "json spec must be a string field");
assert!(v["spec"].as_str().unwrap().contains("db.md"));
}
#[test]
fn spec_honors_dbmd_spec_env_override() {
let tmp = TempDir::new().unwrap();
let custom = tmp.path().join("custom-spec.md");
std::fs::write(&custom, "CUSTOM SPEC OVERRIDE\n").unwrap();
let out = Command::new(DBMD)
.arg("spec")
.env("DBMD_SPEC", &custom)
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&out.stdout),
"CUSTOM SPEC OVERRIDE\n"
);
}
#[test]
fn spec_flag_overrides_env() {
let tmp = TempDir::new().unwrap();
let env_spec = tmp.path().join("env.md");
let flag_spec = tmp.path().join("flag.md");
std::fs::write(&env_spec, "ENV\n").unwrap();
std::fs::write(&flag_spec, "FLAG\n").unwrap();
let out = Command::new(DBMD)
.arg("spec")
.arg("--spec")
.arg(&flag_spec)
.env("DBMD_SPEC", &env_spec)
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&out.stdout), "FLAG\n");
}
#[test]
fn spec_missing_override_path_is_a_runtime_error() {
let out = Command::new(DBMD)
.args(["--json", "spec", "--spec", "/no/such/spec.md"])
.output()
.unwrap();
assert_eq!(
out.status.code(),
Some(1),
"a missing --spec path is exit 1"
);
let v: serde_json::Value = serde_json::from_slice(&out.stderr).unwrap();
assert_eq!(v["error"]["code"], "SPEC_READ_FAILED");
}