use super::*;
use crate::okf::model::{BuildOptions, BuildReport, Dialect};
use std::collections::BTreeMap;
use std::sync::Arc;
fn golden_vault() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/okf/golden/vault")
}
fn vault_opts() -> BuildOptions {
BuildOptions::for_dialect(Dialect::Obsidian)
}
fn build_vault(dir: &Path) -> Arc<DirGraph> {
crate::okf::build(dir, &vault_opts()).unwrap().graph
}
fn write_vault(root: &Path, files: &[(&str, &str)]) {
for (rel, text) in files {
let path = root.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
}
fn vault_of(files: &[(&str, &str)]) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
write_vault(dir.path(), files);
dir
}
fn snapshot(dir: &Path) -> BTreeMap<String, Vec<u8>> {
let mut out = BTreeMap::new();
collect_files(dir, dir, &mut out);
out
}
fn collect_files(root: &Path, dir: &Path, out: &mut BTreeMap<String, Vec<u8>>) {
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
collect_files(root, &path, out);
} else {
let rel = path
.strip_prefix(root)
.unwrap()
.to_string_lossy()
.replace('\\', "/");
out.insert(rel, std::fs::read(&path).unwrap());
}
}
}
#[derive(Debug, Default, PartialEq, Eq)]
struct Shape {
labels: BTreeMap<String, usize>,
edges: BTreeMap<String, usize>,
edge_props: BTreeMap<String, usize>,
nodes: BTreeMap<(String, String), BTreeMap<String, String>>,
}
impl Shape {
fn of(graph: &DirGraph) -> Shape {
let _arena_guard = graph.graph.begin_query();
let mut shape = Shape::default();
for idx in graph.graph.node_indices() {
let Some(view) = graph.node_view(idx) else {
continue;
};
let label = view.node_type_str(&graph.interner).to_string();
*shape.labels.entry(label.clone()).or_default() += 1;
let id = crate::datatypes::values::raw_string(&view.id());
let props: BTreeMap<String, String> = view
.property_pairs_named(&graph.interner)
.into_iter()
.filter(|(_, v)| !matches!(v, Value::Null))
.map(|(k, v)| (k, crate::datatypes::values::raw_string(&v)))
.collect();
shape.nodes.insert((label, id), props);
}
for edge in graph.graph.edge_indices() {
if let Some(data) = graph.graph.edge_weight(edge) {
let conn = data.connection_type_str(&graph.interner).to_string();
*shape.edges.entry(conn.clone()).or_default() += 1;
*shape.edge_props.entry(conn).or_default() += data.properties.len();
}
}
shape
}
fn label(&self, label: &str) -> usize {
self.labels.get(label).copied().unwrap_or(0)
}
fn edge(&self, conn: &str) -> usize {
self.edges.get(conn).copied().unwrap_or(0)
}
fn props(&self, label: &str, id: &str) -> Option<&BTreeMap<String, String>> {
self.nodes.get(&(label.to_string(), id.to_string()))
}
}
struct Trip {
first: Shape,
second: Shape,
third: Shape,
exports: Vec<BTreeMap<String, Vec<u8>>>,
report: ExportReport,
second_report: BuildReport,
_dirs: Vec<tempfile::TempDir>,
}
impl Trip {
fn from_vault(source: &Path) -> Trip {
Trip::run(source, true)
}
fn rootless(source: &Path) -> Trip {
Trip::run(source, false)
}
fn run(source: &Path, with_root: bool) -> Trip {
let mut graph = (*build_vault(source)).clone();
if !with_root {
graph.source_root = None;
}
let first = Shape::of(&graph);
let root = with_root.then(|| source.to_path_buf());
Trip::from_graph(&graph, root, first)
}
fn from_graph(graph: &DirGraph, source_root: Option<PathBuf>, first: Shape) -> Trip {
let carry_root = source_root.is_some();
let dirs: Vec<tempfile::TempDir> = (0..3).map(|_| tempfile::tempdir().unwrap()).collect();
let mut root = source_root;
let mut report = None;
let mut shapes = Vec::new();
let mut second_report = None;
let mut exports = Vec::new();
let mut current = Arc::new(graph.clone());
for (round, dir) in dirs.iter().enumerate() {
let done = export(
¤t,
dir.path(),
&ExportOptions {
source_root: root.take(),
..ExportOptions::default()
},
)
.unwrap();
exports.push(snapshot(dir.path()));
let built = crate::okf::build(dir.path(), &vault_opts()).unwrap();
if round == 0 {
report = Some(done);
second_report = Some(built.report.clone());
}
shapes.push(Shape::of(&built.graph));
root = carry_root.then(|| dir.path().to_path_buf());
current = built.graph;
}
let mut shapes = shapes.into_iter();
Trip {
exports,
first,
second: shapes.next().unwrap(),
third: shapes.next().unwrap(),
report: report.unwrap(),
second_report: second_report.unwrap(),
_dirs: dirs,
}
}
fn assert_stable(&self, name: &str) {
self.assert_trees_match(name, 1, 2);
assert_eq!(
self.second, self.third,
"{name}: the graph drifted on the second round trip, so the losses are not one-shot"
);
}
fn assert_byte_identical_from_the_first_export(&self, name: &str) {
self.assert_trees_match(name, 0, 1);
self.assert_stable(name);
}
fn assert_trees_match(&self, name: &str, a: usize, b: usize) {
let (left, right) = (&self.exports[a], &self.exports[b]);
assert_eq!(
left.keys().collect::<Vec<_>>(),
right.keys().collect::<Vec<_>>(),
"{name}: export {a} and export {b} wrote different file lists"
);
let (manifest, content): (Vec<_>, Vec<_>) = left
.iter()
.partition(|(rel, _)| rel.ends_with(MANIFEST_FILE));
for (rel, bytes) in content.into_iter().chain(manifest) {
assert_eq!(
String::from_utf8_lossy(bytes),
String::from_utf8_lossy(&right[rel]),
"{name}: {rel} differs between export {a} and export {b}"
);
}
}
}
const TYPING: &[(&str, &str)] = &[
(
".kglite/vault.yaml",
"kglite_vault: 1\ndefault_label: Note\ntypes:\n Note: {stays_text: string}\n",
),
(
"notes/typed.md",
"---\n\
title: Typed values\n\
metadata:\n source: vendor\n nested:\n deep: 3\n\
count: 7\n\
ratio: 1.5\n\
whole: 2.0\n\
flag: true\n\
released: 2026-01-15\n\
stamp: 2026-01-15T08:30:00Z\n\
stays_text: \"2026-02-20\"\n\
looks_numeric: \"12\"\n\
looks_hex: \"0x1f\"\n\
looks_bool: \"yes\"\n\
looks_null: \"Null\"\n\
leading_dash: \"- not a sequence\"\n\
colon_pair: \"key: value\"\n\
trailing_colon: \"ends:\"\n\
comment_opener: \"text # not a comment\"\n\
padded: \" padded \"\n\
empty: \"\"\n\
brace: \"{not a map}\"\n\
at_sign: \"@handle\"\n\
tags: [alpha, 2026-01-15]\n\
---\n\
Prose about typed values.\n",
),
];
const LAYOUT: &[(&str, &str)] = &[
(
"Docs/Guides.md",
"---\ntitle: Guides\n---\nThe folder note for `Docs/Guides/`, the `X.md` spelling.\n",
),
(
"Docs/Survey/Survey.md",
"---\ntitle: Survey\n---\nThe folder note for `Docs/Survey/`, the `X/X.md` spelling.\n",
),
(
"Docs/Guides/Intro.md",
"---\nparent: \"[[Survey]]\"\ndepends_on: \"[[Docs/Guides/Faults]]\"\n---\n\
Cross-listed under Survey as well as its own folder.\n",
),
(
"Docs/Guides/Faults.md",
"Its stem collides with `Docs/Survey/Faults.md`, so both fall back to their paths.\n",
),
(
"Docs/Survey/Faults.md",
"The other Faults. A link to either has to name the folder.\n",
),
];
const MEDIA: &[(&str, &str)] = &[
(
"Media/figures.md",
"---\ntitle: Figures\n---\n\
## Plates\n\n\
 is note-relative, ![[diagram.png]] resolves\n\
on the bare filename, and ![[handbook.pdf]] is not an image at all.\n\n\
## Gaps\n\n\
 resolves to nothing.\n",
),
("img/faults.png", "stand-in bytes\n"),
("img/diagram.png", "other stand-in bytes\n"),
("img/handbook.pdf", "not a pdf either\n"),
];
fn backdate_attachments(root: &Path) -> std::time::SystemTime {
let when = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_577_836_800);
for rel in ["img/faults.png", "img/diagram.png", "img/handbook.pdf"] {
let path = root.join(rel);
if path.is_file() {
std::fs::File::options()
.write(true)
.open(&path)
.unwrap()
.set_modified(when)
.unwrap();
}
}
when
}
#[test]
fn a_copied_attachment_keeps_its_modification_time_across_the_rounds() {
let dir = vault_of(MEDIA);
backdate_attachments(dir.path());
let trip = Trip::from_vault(dir.path());
let source = trip
.first
.props(IMAGE_LABEL, "img/faults.png")
.and_then(|p| p.get("mtime"))
.cloned()
.expect("the source image has an mtime");
assert_eq!(source, "2020-01-01 00:00:00", "the backdating took");
for (round, shape) in [("second", &trip.second), ("third", &trip.third)] {
assert_eq!(
shape
.props(IMAGE_LABEL, "img/faults.png")
.and_then(|p| p.get("mtime")),
Some(&source),
"the {round} graph's image was stamped with the time of the copy"
);
}
}
#[test]
fn every_corpus_vault_reaches_a_fixed_point() {
Trip::from_vault(&golden_vault()).assert_byte_identical_from_the_first_export("golden");
for (name, files) in [("layout", LAYOUT), ("media", MEDIA)] {
let dir = vault_of(files);
Trip::from_vault(dir.path()).assert_byte_identical_from_the_first_export(name);
}
let dir = vault_of(TYPING);
Trip::from_vault(dir.path()).assert_stable("typing");
let dir = vault_of(MEDIA);
Trip::rootless(dir.path())
.assert_byte_identical_from_the_first_export("media (no source root)");
}
#[test]
fn a_graph_that_was_never_a_vault_reaches_a_fixed_point() {
let graph = cypher_graph();
Trip::from_graph(&graph, None, Shape::of(&graph))
.assert_byte_identical_from_the_first_export("cypher");
}
fn cypher_graph() -> DirGraph {
let mut graph = crate::graph::storage::mode::new_dir_graph_in_mode(
crate::graph::storage::mode::StorageMode::Memory,
None,
)
.unwrap();
let params = std::collections::HashMap::new();
crate::graph::session::execute::execute_mut(
&mut graph,
"CREATE (a:Paper {concept_id: 'alpha', title: 'Alpha', body: 'The first paper.', \
year: 2026, score: 1.5, open: true}), \
(b:Paper {concept_id: 'beta', title: 'Beta', body: 'The second paper.', year: 2025}), \
(a)-[:CITES {section: 'Background', anchor: 'intro'}]->(b)",
&crate::graph::session::execute::ExecuteOptions::eager(¶ms),
)
.unwrap();
graph
}
#[test]
fn edge_properties_are_dropped_where_no_prose_restates_them() {
let graph = cypher_graph();
let trip = Trip::from_graph(&graph, None, Shape::of(&graph));
assert_eq!(
trip.first.edge_props.get("CITES"),
Some(&2),
"the created edge carries `section` and `anchor`"
);
assert_eq!(
trip.report.edge_properties_dropped, 2,
"and the export counts what it dropped"
);
assert_eq!(
trip.second.edge("CITES"),
1,
"the edge itself survives as a frontmatter wikilink"
);
assert_eq!(
trip.second.edge_props.get("CITES"),
Some(&0),
"but with nothing on it"
);
}
#[test]
fn a_body_stated_edge_keeps_its_properties() {
let dir = vault_of(&[
("Note/a.md", "## Deep dive\n\n[[b#anchor]]\n"),
("Note/b.md", "prose\n"),
]);
let trip = Trip::from_vault(dir.path());
assert_eq!(trip.report.edge_properties_dropped, 2);
assert_eq!(
trip.first.edge_props.get("LINKS_TO"),
Some(&2),
"`section` and `anchor`"
);
assert_eq!(
trip.second.edge_props.get("LINKS_TO"),
Some(&2),
"the prose said it, so the re-import says it again"
);
assert_eq!(trip.second.edge("LINKS_TO"), 1, "and does not double it");
}
#[test]
fn attachment_bytes_travel_only_with_a_source_root() {
let dir = vault_of(MEDIA);
let carried = Trip::from_vault(dir.path());
assert_eq!(
(
carried.report.attachments_copied,
carried.report.attachments_unresolved
),
(3, 0)
);
assert_eq!(carried.first.label("Image"), 3, "two real, one missing");
assert_eq!(carried.second.label("Image"), 3);
assert_eq!(carried.second_report.missing_attachments, 1);
let rootless = Trip::rootless(dir.path());
assert_eq!(
(
rootless.report.attachments_copied,
rootless.report.attachments_unresolved
),
(0, 3)
);
assert_eq!(
rootless.second_report.missing_attachments, 4,
"every reference now names a file the exported vault does not hold"
);
assert_eq!(
rootless
.second
.props(ATTACHMENT_LABEL, "handbook.pdf")
.and_then(|p| p.get("missing"))
.map(String::as_str),
Some("true"),
"the pdf did not travel, so nothing about it is known but its absence — §6.6 still labels the stub from the extension"
);
assert_eq!(
rootless
.second
.props(ATTACHMENT_LABEL, "handbook.pdf")
.unwrap()
.get("size_bytes"),
None,
"and `stat` said nothing, because there was nothing to stat"
);
}
#[test]
fn synthesized_nodes_are_regenerated_not_carried() {
let trip = Trip::from_vault(&golden_vault());
assert!(trip.first.label("Keyword") > 0);
assert_eq!(
trip.second.label("Keyword"),
0,
"a hub needs its declaration"
);
assert_eq!(trip.second.edge("HAS_KEYWORD"), 0);
assert_ne!(
trip.first.label(FOLDER_LABEL),
trip.second.label(FOLDER_LABEL),
"the folders are the export's layout, not the author's"
);
for regenerated in [TAG_LABEL, IMAGE_LABEL, "Concept"] {
assert_eq!(
trip.first.label(regenerated),
trip.second.label(regenerated),
"{regenerated} is re-made from the prose, which travelled verbatim"
);
}
}
#[test]
fn the_vault_yaml_declarations_are_not_carried() {
let first_report = crate::okf::build(&golden_vault(), &vault_opts())
.unwrap()
.report;
assert!(first_report.indexes_declared > 0);
assert!(first_report.text_indexes_built > 0);
assert!(!first_report.embed_targets.is_empty());
let trip = Trip::from_vault(&golden_vault());
assert_eq!(trip.second_report.indexes_declared, 0);
assert_eq!(trip.second_report.text_indexes_built, 0);
assert!(trip.second_report.embed_targets.is_empty());
assert_eq!(trip.first.edge("RELATED"), 0, "the declaration retyped it");
assert!(trip.first.edge("RELATED_TO") > 0);
assert_eq!(
trip.second.edge("RELATED_TO"),
trip.first.edge("RELATED_TO"),
"the typed edge itself is written as a frontmatter key and survives"
);
assert!(
trip.second.edge("RELATED") > 0,
"but the same prose now also reads as the built-in ladder's RELATED"
);
}
#[test]
fn a_declared_string_that_looks_like_a_date_comes_back_a_date() {
let dir = vault_of(TYPING);
let trip = Trip::from_vault(dir.path());
assert_eq!(
trip.first
.props("Note", "typed")
.and_then(|p| p.get("stays_text"))
.map(String::as_str),
Some("2026-02-20"),
"the declaration kept it text"
);
let second = trip
.second
.props("Note", "typed")
.expect("the note came back");
assert_eq!(
second.get("stays_text").map(String::as_str),
Some("2026-02-20")
);
assert_eq!(
property_type(dir.path(), "stays_text"),
"String",
"before the round trip the declaration held"
);
assert_eq!(
property_type(trip.exported_dir(), "stays_text"),
"DateTime",
"after it — §4.2 infers, and nothing is left to say otherwise"
);
}
#[test]
fn a_declared_int_survives_the_round_trip() {
let dir = vault_of(&[
(
".kglite/vault.yaml",
"kglite_vault: 1\ndefault_label: Note\ntypes:\n Note: {toc_depth: int}\n",
),
("notes/atlas.md", "---\ntoc_depth: \"2\"\n---\nprose\n"),
]);
assert_eq!(property_type(dir.path(), "toc_depth"), "Int64");
let trip = Trip::from_vault(dir.path());
assert_eq!(
property_type(trip.exported_dir(), "toc_depth"),
"Int64",
"the export wrote an int, and inference agrees with the declaration"
);
assert_eq!(
trip.second
.props("Note", "atlas")
.and_then(|p| p.get("toc_depth"))
.map(String::as_str),
Some("2")
);
}
#[test]
fn a_re_filed_note_keeps_the_stem_its_links_name() {
let dir = vault_of(&[
(
"AB.md",
"---\ntitle: A/B\n---\nThe note the other one names.\n",
),
("Other.md", "See [[AB]].\n"),
]);
let trip = Trip::from_vault(dir.path());
assert_eq!(trip.first.label("Note"), 2);
assert_eq!(trip.first.edge("LINKS_TO"), 1);
assert!(
trip.exports[0].contains_key("Note/AB.md"),
"only the folder moves: {:?}",
trip.exports[0].keys().collect::<Vec<_>>()
);
assert_eq!(
trip.second.label("Note"),
2,
"the body's `[[AB]]` still resolves, so no stub is minted"
);
assert_eq!(trip.second.label("Concept"), 0);
assert_eq!(trip.second.edge("LINKS_TO"), 1);
assert!(
String::from_utf8_lossy(&trip.exports[0]["Note/AB.md"]).contains("title: A/B\n"),
"{}",
String::from_utf8_lossy(&trip.exports[0]["Note/AB.md"])
);
trip.assert_byte_identical_from_the_first_export("re-filed stem");
}
#[test]
fn a_re_filed_notes_relative_reference_moves_with_it() {
let dir = vault_of(&[
(
"deep/nested/note.md",
"---\ntype: Media\ntitle: Nested\n---\n\n",
),
("img/chart.png", "stand-in bytes\n"),
]);
let build = crate::okf::build(dir.path(), &vault_opts()).unwrap();
assert!(build.report.errors.is_empty(), "{:?}", build.report.errors);
let trip = Trip::from_vault(dir.path());
assert!(
trip.first.props(IMAGE_LABEL, "img/chart.png").is_some(),
"it resolved from `deep/nested/`"
);
assert!(trip.exports[0].contains_key("Media/note.md"));
assert!(
trip.second_report
.errors
.iter()
.any(|e| e.contains("escapes the vault root")),
"the same two dots now name a place outside the vault: {:?}",
trip.second_report.errors
);
assert!(trip.second.props(IMAGE_LABEL, "img/chart.png").is_some());
}
fn property_type(vault: &Path, property: &str) -> String {
let graph = build_vault(vault);
let _arena_guard = graph.graph.begin_query();
for idx in graph.graph.node_indices() {
let Some(view) = graph.node_view(idx) else {
continue;
};
if let Some(value) = view.get_property_value(property) {
return value.type_name().to_string();
}
}
panic!("no node carries `{property}` in {}", vault.display());
}
impl Trip {
fn exported_dir(&self) -> &Path {
self._dirs[0].path()
}
}