Files
Marcos Paulo 224ae2718a
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: index keeps note properties, size and times; base.* engine commands
Index: frontmatter properties (200 per note, 4 KiB per value, 500 list items, 3 levels), size and times; persistent cache format version 2 (old caches are replaced). Engine: base.list, base.read, base.properties, base.query over the index (limit 200 default, over 2000 is an error), base.write (validated; never replaces an unreadable file), base.create, base.view.add|remove|rename|duplicate|reorder, base.row.set (types.json, one undo step in an open buffer, file.name renames with links; other file.* and formula.* are read-only). Bases tasks 9, 10, 11 (Claude Sonnet). Verified with the full cargo xtask ci on a copy of the tree (4526 tests, clippy -D warnings, layers, tree, i18n). Gaps: no result cache keyed by index generation, no 15k-note perf test, base.row.set writes numbers as 5.0.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 03:23:00 -03:00

218 lines
9.9 KiB
Rust

//! The on-disk index cache: a second open gives the index a cold open gives, reads no unchanged
//! note, notices every edit, and ignores a cache it cannot trust.
mod common;
use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use common::{seed, temp_vault};
use notescraft_engine::{Registry, Session, register_core};
use serde_json::{Value, json};
const CACHE: &str = ".notescraft/cache/index-v1.bin";
const OLD: Duration = Duration::from_secs(1_000_000);
const NOTES: [(&str, &str); 6] = [
(
"Home.md",
"---\naliases: [Start]\ntags: [hub]\nyear: 1999\ndone: true\nrel: [a, b]\nwhen: 2026-01-02\n---\n# Home\n\nSee [[Trip]] and [[Soup#Steps]] and [[Missing]] #root\n\n- [ ] book the flight\n",
),
("Trips/Trip.md", "# Trip\n\nBook the flight to Lisbon and reserve the hotel near the river. [[Home]] #travel/pt\n\n## Plan\n\nzeppelin hangar ^block1\n"),
("Other/Soup.md", "# Soup\n\nBoil the onions and the carrots slowly.\n\n## Steps\n\n1. chop\n2. boil\n"),
("Other/Stew.md", "# Stew\n\nBraise the beef with the carrots. ![[pic.png]] [see](Soup.md)\n"),
("Private/Diary.md", "# Diary\n\nThe lisbon flight password is hunter2.\n"),
("Plain.md", "no heading at all, only words like quokka and wombat\n"),
];
fn old_vault(name: &str) -> common::TempVault {
let vault = temp_vault(name);
seed(vault.path(), &NOTES);
seed(vault.path(), &[("pic.png", "x")]);
for (path, _) in NOTES {
set_mtime(&vault.path().join(path), UNIX_EPOCH + OLD);
}
vault
}
fn set_mtime(path: &Path, time: SystemTime) {
std::fs::File::options().write(true).open(path).expect("open").set_modified(time).expect("set the time");
}
/// Writes `text` into `path` and puts the modified time back, so only the content changed.
fn rewrite_keeping_time(vault: &Path, path: &str, text: &str) {
let target = vault.join(path);
let time = std::fs::metadata(&target).and_then(|meta| meta.modified()).expect("mtime");
std::fs::write(&target, text).expect("write");
set_mtime(&target, time);
}
fn run(session: &mut Session, id: &str, params: &Value) -> Value {
let mut registry = Registry::new();
register_core(&mut registry).expect("register");
registry.run(id, session, params).unwrap_or_else(|error| panic!("{id}: {error:?}"))
}
/// Everything the indexes answer, as one comparable string.
fn snapshot(session: &mut Session) -> String {
let mut out: Vec<String> = Vec::new();
let paths: Vec<String> = session.index().notes().into_iter().map(str::to_owned).collect();
for path in &paths {
out.push(format!("{:?}\n{:?}\n", session.index().note(path), session.index().backlinks(path)));
}
out.push(format!("{:?}\n{:?}\n{:?}\n", session.index().files(), session.index().unresolved(), session.index().orphans()));
for query in ["flight", "carrots", "zeppelin", "tag:#travel", "quokka wombat", "\"lisbon flight\"", "task:book", "section:plan"] {
out.push(format!("{query}: {:?}\n", session.search(query, 20).expect("search")));
}
for query in ["flight lisbon", "carrots onions", "quokka"] {
out.push(format!("{query}: {}\n", run(session, "knowledge.search", &json!({ "query": query }))));
}
let status = run(session, "knowledge.status", &Value::Null);
out.push(format!("{} {} {}\n", status["notes"], status["passages"], status["vocab_terms"]));
out.push(format!("{}\n{}\n", run(session, "tags.list", &Value::Null), run(session, "lint.style", &Value::Null)));
out.join("\n")
}
/// What a scan of the vault as it is gives: the session is closed and the cache removed first.
fn cold_snapshot(vault: &Path, session: Session) -> String {
drop(session);
std::fs::remove_dir_all(vault.join(".notescraft")).expect("remove the private folder");
snapshot(&mut Session::open_read_only(vault).expect("read only"))
}
fn open(vault: &Path) -> Session {
Session::open(vault).expect("open")
}
#[test]
fn a_cached_open_gives_the_index_a_cold_open_gives() {
let vault = old_vault("ic_same");
let mut cold = open(vault.path());
let want = snapshot(&mut cold);
drop(cold);
assert!(vault.path().join(CACHE).is_file(), "the first open writes the cache");
let mut cached = open(vault.path());
assert_eq!(snapshot(&mut cached), want);
drop(cached);
let mut lazy = Session::open_lazy(vault.path()).expect("lazy");
while !lazy.index_step(Duration::from_secs(30)).finished {}
assert_eq!(snapshot(&mut lazy), want);
assert_eq!(lazy.index_progress().done, lazy.index_progress().total);
}
#[test]
fn unchanged_notes_are_not_read_again() {
let vault = old_vault("ic_noread");
drop(open(vault.path()));
// Same length, same time: only the cache can still know the old words.
rewrite_keeping_time(vault.path(), "Plain.md", "no heading at all, only words like wombat and quokka\n");
let session = open(vault.path());
assert_eq!(session.search("\"like quokka\"", 5).expect("search").len(), 1, "the cached text is still what the index knows");
let new = session.search("\"like wombat\"", 5).expect("search");
assert!(new.is_empty(), "the phrase exists only in the new file text, which was not read: {new:?}");
}
#[test]
fn an_edited_note_is_read_again() {
let vault = old_vault("ic_edit");
drop(open(vault.path()));
std::fs::write(vault.path().join("Plain.md"), "now about zeppelins and [[Soup]] #fresh\n").expect("edit");
rewrite_keeping_time(vault.path(), "Other/Stew.md", "# Stew\n\nBraise the beef with the carrots. ![[pic.png]] [see](Soup.md)\n");
let mut session = open(vault.path());
assert_eq!(session.search("quokka", 5).expect("search").len(), 0);
assert_eq!(session.search("zeppelins", 5).expect("search").len(), 1);
assert!(session.index().backlinks("Other/Soup.md").iter().any(|link| link.source == "Plain.md"));
assert_eq!(snapshot(&mut session), cold_snapshot(vault.path(), session), "same as a scan of the vault as it is");
}
#[test]
fn a_note_written_moments_before_the_cache_is_checked_by_content() {
let vault = temp_vault("ic_recent");
seed(vault.path(), &NOTES);
drop(open(vault.path()));
rewrite_keeping_time(vault.path(), "Plain.md", "no heading at all, only words like wombat and quokka\n");
let session = open(vault.path());
assert_eq!(session.search("\"like wombat\"", 5).expect("search").len(), 1, "equal stamps right after a write are not trusted");
}
#[test]
fn deleted_and_new_notes_follow_the_vault() {
let vault = old_vault("ic_delete");
drop(open(vault.path()));
std::fs::remove_file(vault.path().join("Trips/Trip.md")).expect("delete");
seed(vault.path(), &[("Extra.md", "# Extra\n\nlinks to [[Home]] and mentions zeppelin\n")]);
let mut session = open(vault.path());
assert!(!session.index().notes().contains(&"Trips/Trip.md"));
assert!(session.index().notes().contains(&"Extra.md"));
assert_eq!(session.search("zeppelin", 5).expect("search").iter().map(|hit| hit.path.as_str()).collect::<Vec<_>>(), ["Extra.md"]);
assert!(session.index().unresolved().iter().any(|link| link.target == "Trip"), "Home.md now points at nothing");
assert_eq!(snapshot(&mut session), cold_snapshot(vault.path(), session));
}
#[test]
fn a_cache_that_cannot_be_trusted_is_replaced_by_a_full_scan() {
let vault = old_vault("ic_corrupt");
let mut cold = open(vault.path());
let want = snapshot(&mut cold);
drop(cold);
let path = vault.path().join(CACHE);
let good = std::fs::read(&path).expect("cache");
let mut flipped = good.clone();
let middle = flipped.len() / 2;
flipped[middle] ^= 0xff;
let mut other_version = good.clone();
other_version[4] = other_version[4].wrapping_add(1);
let bad: [Vec<u8>; 6] = [Vec::new(), b"garbage".to_vec(), good[..good.len() / 2].to_vec(), flipped, other_version, vec![0xff; good.len()]];
for bytes in bad {
std::fs::write(&path, &bytes).expect("break the cache");
let mut session = open(vault.path());
assert_eq!(snapshot(&mut session), want);
drop(session);
let replaced = std::fs::read(&path).expect("cache");
assert!(replaced.starts_with(b"NCIX") && replaced != bytes, "the broken cache was replaced");
}
}
#[test]
fn a_cache_whose_vocabulary_changed_is_ignored() {
let vault = old_vault("ic_vocab");
drop(open(vault.path()));
std::fs::write(vault.path().join(".notescraft/knowledge-vocab.json"), "{}").expect("break the vocabulary");
rewrite_keeping_time(vault.path(), "Plain.md", "no heading at all, only words like wombat and quokka\n");
let session = open(vault.path());
assert_eq!(session.search("\"like wombat\"", 5).expect("search").len(), 1, "everything was read again");
}
#[test]
fn properties_size_and_times_survive_the_cache() {
let vault = old_vault("ic_props");
drop(open(vault.path()));
let session = open(vault.path());
let home = session.index().note("Home.md").expect("home").clone();
assert_eq!(home.props.len(), 6);
assert_eq!(home.props[2], ("year".to_string(), notescraft_markdown::Value::Number(1999.0)));
assert_eq!(home.size, NOTES[0].1.len() as u64);
assert_eq!(home.mtime_ms, i64::try_from(OLD.as_millis()).expect("ms"));
assert!(home.ctime_ms > 0);
}
#[test]
fn a_cache_of_the_previous_version_is_replaced() {
use std::hash::Hasher;
let vault = old_vault("ic_oldver");
drop(open(vault.path()));
let path = vault.path().join(CACHE);
let mut old = std::fs::read(&path).expect("cache");
old[4..8].copy_from_slice(&1_u32.to_le_bytes());
let body = old.len() - 8;
let mut hasher = std::collections::hash_map::DefaultHasher::new();
hasher.write(&old[..body]);
old[body..].copy_from_slice(&hasher.finish().to_le_bytes());
std::fs::write(&path, &old).expect("write the old cache");
let session = open(vault.path());
assert_eq!(session.index().note("Home.md").expect("home").props.len(), 6);
drop(session);
let replaced = std::fs::read(&path).expect("cache");
assert_eq!(replaced[4..8], 2_u32.to_le_bytes(), "rewritten with the current version");
}