mod common;
use std::fs;
use std::path::Path;
use common::{dbmd, write_db_md};
const LOG_FRONTMATTER: &str = "---\ntype: log\n---\n\n# Curator log\n";
fn fresh_store() -> (tempfile::TempDir, std::path::PathBuf) {
let tmp = tempfile::TempDir::new().expect("tempdir");
let root = tmp.path().join("store");
fs::create_dir_all(&root).expect("create store root");
write_db_md(&root);
(tmp, root)
}
fn entry_block(ts: &str, kind: &str, object: &str, note: &str) -> String {
format!("## [{ts}] {kind} | {object}\n{note}\n\n")
}
fn store_wide_block(ts: &str, kind: &str, note: &str) -> String {
format!("## [{ts}] {kind}\n{note}\n\n")
}
fn write_active_log(root: &Path, blocks: &[String]) {
let mut content = String::from(LOG_FRONTMATTER);
content.push('\n');
for b in blocks {
content.push_str(b);
}
fs::write(root.join("log.md"), content).expect("write log.md");
}
fn write_archive(root: &Path, year_month: &str, blocks: &[String]) {
let dir = root.join("log");
fs::create_dir_all(&dir).expect("create log/ dir");
let mut content = String::from(LOG_FRONTMATTER);
content.push('\n');
for b in blocks {
content.push_str(b);
}
fs::write(dir.join(format!("{year_month}.md")), content).expect("write archive");
}
fn run_read(root: &Path, args: &[&str]) -> String {
let out = dbmd().args(args).arg("--dir").arg(root).assert().success();
String::from_utf8(out.get_output().stdout.clone()).expect("utf8 stdout")
}
fn header_tuples(stdout: &str) -> Vec<(String, String, Option<String>)> {
let mut out = Vec::new();
for line in stdout.lines() {
let Some(rest) = line.strip_prefix('[') else {
continue;
};
let Some(close) = rest.find(']') else {
continue;
};
let ts = rest[..close].to_string();
let after = rest[close + 1..].trim();
if after.is_empty() {
continue;
}
let (kind, object) = match after.split_once('|') {
Some((k, o)) => (k.trim().to_string(), Some(o.trim().to_string())),
None => (after.to_string(), None),
};
out.push((ts, kind, object));
}
out
}
#[test]
fn log_append_rotates_multiple_prior_months_into_archives_and_keeps_current_active() {
let (_tmp, root) = fresh_store();
let oct1 = entry_block("2023-10-05 09:00", "ingest", "sources/a", "october one");
let oct2 = entry_block("2023-10-20 14:30", "create", "records/b", "october two");
let nov1 = entry_block("2023-11-08 08:15", "update", "records/c", "november one");
let nov2 = entry_block("2023-11-25 16:45", "link", "wiki/d", "november two");
write_active_log(&root, &[oct1, oct2, nov1, nov2]);
assert!(
!root.join("log").exists(),
"precondition: no log/ archive dir before the boundary-crossing append"
);
let out = dbmd()
.current_dir(&root)
.args([
"--json",
"log",
"update",
"records/contacts/sarah-chen.md",
"-m",
"current-month entry",
])
.assert()
.success();
let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap();
let appended: serde_json::Value =
serde_json::from_str(&stdout).expect("append --json is valid JSON");
assert_eq!(appended["appended"], serde_json::json!(true));
let stamped_ts = appended["timestamp"]
.as_str()
.expect("append --json carries a timestamp string");
let current_ym = &stamped_ts[..7]; assert_ne!(
current_ym, "2023-10",
"sanity: the run clock is not the seeded far-past month"
);
assert_ne!(current_ym, "2023-11");
let oct_archive = root.join("log").join("2023-10.md");
let nov_archive = root.join("log").join("2023-11.md");
assert!(
oct_archive.exists(),
"2023-10 entries must roll into log/2023-10.md"
);
assert!(
nov_archive.exists(),
"2023-11 entries must roll into log/2023-11.md"
);
let oct_text = fs::read_to_string(&oct_archive).unwrap();
let nov_text = fs::read_to_string(&nov_archive).unwrap();
assert!(oct_text.starts_with("---\ntype: log\n---\n"));
assert!(nov_text.starts_with("---\ntype: log\n---\n"));
assert!(oct_text.contains("## [2023-10-05 09:00] ingest | sources/a"));
assert!(oct_text.contains("## [2023-10-20 14:30] create | records/b"));
assert!(
!oct_text.contains("november"),
"October archive must not hold November entries:\n{oct_text}"
);
assert!(nov_text.contains("## [2023-11-08 08:15] update | records/c"));
assert!(nov_text.contains("## [2023-11-25 16:45] link | wiki/d"));
assert!(
!nov_text.contains("october"),
"November archive must not hold October entries:\n{nov_text}"
);
let active = fs::read_to_string(root.join("log.md")).unwrap();
assert!(
!active.contains("2023-10") && !active.contains("2023-11"),
"no prior-month entries may remain in the active file:\n{active}"
);
assert!(
active.contains("records/contacts/sarah-chen.md"),
"the appended entry stays in the active file:\n{active}"
);
let active_headers = header_tuples_in_raw(&active);
assert_eq!(
active_headers.len(),
1,
"exactly one entry (the current-month append) stays active:\n{active}"
);
assert!(
active_headers[0].starts_with(current_ym),
"the active entry is in the current month {current_ym}; got {:?}",
active_headers[0]
);
let tail_out = run_read(&root, &["log", "tail", "10"]);
let tuples = header_tuples(&tail_out);
let kinds: Vec<&str> = tuples.iter().map(|(_, k, _)| k.as_str()).collect();
assert_eq!(
kinds,
vec!["ingest", "create", "update", "link", "update"],
"tail across archives+active is the whole timeline, oldest→newest:\n{tail_out}"
);
assert_eq!(tuples[0].0, "2023-10-05 09:00");
assert_eq!(tuples[1].0, "2023-10-20 14:30");
assert_eq!(tuples[2].0, "2023-11-08 08:15");
assert_eq!(tuples[3].0, "2023-11-25 16:45");
assert!(tuples[4].0.starts_with(current_ym));
}
fn header_tuples_in_raw(raw: &str) -> Vec<String> {
raw.lines()
.filter_map(|l| {
let rest = l.strip_prefix("## [")?;
let close = rest.find(']')?;
Some(rest[..close].to_string())
})
.collect()
}
#[test]
fn log_tail_reverse_reads_across_active_and_archive_boundary() {
let (_tmp, root) = fresh_store();
write_archive(
&root,
"2023-11",
&[
entry_block("2023-11-08 08:15", "ingest", "sources/n1", "nov one"),
entry_block("2023-11-25 16:45", "create", "records/n2", "nov two"),
],
);
write_archive(
&root,
"2023-12",
&[
entry_block("2023-12-03 10:00", "update", "records/d1", "dec one"),
entry_block("2023-12-30 23:10", "link", "wiki/d2", "dec two"),
],
);
write_active_log(
&root,
&[
entry_block("2024-01-04 09:30", "update", "records/j1", "jan one"),
store_wide_block("2024-01-19 12:00", "validate", "jan validate"),
],
);
let t2 = header_tuples(&run_read(&root, &["log", "tail", "2"]));
assert_eq!(
t2.iter().map(|(ts, _, _)| ts.as_str()).collect::<Vec<_>>(),
vec!["2024-01-04 09:30", "2024-01-19 12:00"],
"tail 2 = the two newest (active month), chronological"
);
let t3 = header_tuples(&run_read(&root, &["log", "tail", "3"]));
assert_eq!(
t3.iter().map(|(ts, _, _)| ts.as_str()).collect::<Vec<_>>(),
vec!["2023-12-30 23:10", "2024-01-04 09:30", "2024-01-19 12:00"],
"tail 3 crosses into the 2023-12 archive for the 3rd-newest"
);
let t6 = header_tuples(&run_read(&root, &["log", "tail", "6"]));
assert_eq!(
t6.iter().map(|(ts, _, _)| ts.as_str()).collect::<Vec<_>>(),
vec![
"2023-11-08 08:15",
"2023-11-25 16:45",
"2023-12-03 10:00",
"2023-12-30 23:10",
"2024-01-04 09:30",
"2024-01-19 12:00",
],
"tail 6 stitches both archives behind the active file, in order"
);
let t_all = header_tuples(&run_read(&root, &["log", "tail", "999"]));
assert_eq!(
t_all.len(),
6,
"tail 999 returns the 6 real entries, no more"
);
assert_eq!(
t_all, t6,
"over-large tail equals the full ordered timeline"
);
let last = t_all.last().unwrap();
assert_eq!(last.1, "validate");
assert_eq!(
last.2, None,
"store-wide validate header has no object slot"
);
}
#[test]
fn log_since_reverse_reads_across_boundary_and_excludes_cutoff() {
let (_tmp, root) = fresh_store();
write_archive(
&root,
"2023-11",
&[
entry_block("2023-11-08 08:15", "ingest", "sources/n1", "nov one"),
entry_block("2023-11-25 16:45", "create", "records/n2", "nov two"),
],
);
write_archive(
&root,
"2023-12",
&[
entry_block("2023-12-03 10:00", "update", "records/d1", "dec one"),
entry_block("2023-12-30 23:10", "link", "wiki/d2", "dec two"),
],
);
write_active_log(
&root,
&[entry_block(
"2024-01-04 09:30",
"update",
"records/j1",
"jan one",
)],
);
let s = header_tuples(&run_read(&root, &["log", "since", "2023-12-03T10:00:00Z"]));
assert_eq!(
s.iter().map(|(ts, _, _)| ts.as_str()).collect::<Vec<_>>(),
vec!["2023-12-30 23:10", "2024-01-04 09:30"],
"since is exclusive of the exact cutoff and crosses archive→active"
);
let s2 = header_tuples(&run_read(&root, &["log", "since", "2023-11-08T08:15:00Z"]));
assert_eq!(
s2.iter().map(|(ts, _, _)| ts.as_str()).collect::<Vec<_>>(),
vec![
"2023-11-25 16:45",
"2023-12-03 10:00",
"2023-12-30 23:10",
"2024-01-04 09:30",
],
"since deep in the oldest archive returns all strictly-newer, in order"
);
let s_none = run_read(&root, &["log", "since", "2024-02-01T00:00:00Z"]);
assert!(
header_tuples(&s_none).is_empty(),
"since after the newest entry returns no entries:\n{s_none}"
);
let s_all = header_tuples(&run_read(&root, &["log", "since", "2023-01-01T00:00:00Z"]));
assert_eq!(
s_all
.iter()
.map(|(ts, _, _)| ts.as_str())
.collect::<Vec<_>>(),
vec![
"2023-11-08 08:15",
"2023-11-25 16:45",
"2023-12-03 10:00",
"2023-12-30 23:10",
"2024-01-04 09:30",
],
"since before the oldest entry returns the full ordered timeline"
);
}
#[test]
fn log_since_handles_non_monotonic_active_log() {
let (_tmp, root) = fresh_store();
write_active_log(
&root,
&[
entry_block("2026-05-27 10:10", "update", "records/c", "newest"),
entry_block("2026-05-27 10:05", "create", "records/b", "middle"),
entry_block("2026-05-27 10:00", "update", "records/a", "backdated fix"),
],
);
let s = header_tuples(&run_read(&root, &["log", "since", "2026-05-27T10:02:00Z"]));
let mut stamps: Vec<&str> = s.iter().map(|(ts, _, _)| ts.as_str()).collect();
stamps.sort_unstable();
assert_eq!(
stamps,
vec!["2026-05-27 10:05", "2026-05-27 10:10"],
"since(10:02) over a non-monotonic log must return both newer entries, \
not stop at the physically-last backdated 10:00 entry:\n{s:?}"
);
let s_all = header_tuples(&run_read(&root, &["log", "since", "2026-05-27T09:00:00Z"]));
assert_eq!(
s_all.len(),
3,
"since before everything returns all 3 entries"
);
}