use super::*;
use crate::okf::model::{BuildOptions, Dialect};
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
fn golden_vault() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/okf/golden/vault")
}
fn build_vault(dir: &Path) -> Arc<DirGraph> {
crate::okf::build(dir, &BuildOptions::for_dialect(Dialect::Obsidian))
.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 tree(dir: &Path) -> Vec<String> {
let mut out = Vec::new();
collect_tree(dir, dir, &mut out);
out.sort();
out
}
fn collect_tree(root: &Path, dir: &Path, out: &mut Vec<String>) {
for entry in std::fs::read_dir(dir).unwrap() {
let entry = entry.unwrap();
let path = entry.path();
if path.is_dir() {
collect_tree(root, &path, out);
} else {
out.push(
path.strip_prefix(root)
.unwrap()
.to_string_lossy()
.replace('\\', "/"),
);
}
}
}
fn snapshot(dir: &Path) -> BTreeMap<String, Vec<u8>> {
tree(dir)
.into_iter()
.map(|rel| {
let bytes = std::fs::read(dir.join(&rel)).unwrap();
(rel, bytes)
})
.collect()
}
fn read(dir: &Path, rel: &str) -> String {
std::fs::read_to_string(dir.join(rel)).unwrap_or_else(|e| panic!("reading {rel}: {e}"))
}
fn export_to(graph: &DirGraph, dir: &Path) -> ExportReport {
export(graph, dir, &ExportOptions::default()).unwrap()
}
fn export_one(front: &str, body: &str) -> (tempfile::TempDir, String) {
let source = tempfile::tempdir().unwrap();
write_vault(
source.path(),
&[("notes/subject.md", &format!("{front}{body}"))],
);
let graph = build_vault(source.path());
let out = tempfile::tempdir().unwrap();
export_to(&graph, out.path());
let files = tree(out.path());
let note = files
.iter()
.find(|f| f.ends_with("subject.md"))
.unwrap_or_else(|| panic!("no subject.md in {files:?}"))
.clone();
let text = read(out.path(), ¬e);
(out, text)
}
#[test]
fn synthesized_hub_folder_and_stub_nodes_are_not_files() {
let source = tempfile::tempdir().unwrap();
write_vault(
source.path(),
&[(
"one.md",
"---\ntags:\n - alpha\n---\nLinks to [[Nowhere]] and https://example.com/x.\n",
)],
);
let graph = build_vault(source.path());
let out = tempfile::tempdir().unwrap();
export_to(&graph, out.path());
assert_eq!(
tree(out.path()),
vec![".kglite/export-manifest.json", "Note/one.md"],
"the Tag hub, the Source URL, the Folder and the dangling stub all regenerate"
);
}
#[test]
fn the_carried_skill_and_recipe_land_under_kglite() {
let graph = build_vault(&golden_vault());
let out = tempfile::tempdir().unwrap();
let report = export_to(&graph, out.path());
assert_eq!((report.skills_written, report.recipes_written), (1, 1));
let skill = read(out.path(), ".kglite/skills/vault_overview.md");
assert!(
skill.starts_with("---\nname: \"vault_overview\""),
"{skill}"
);
let recipe = read(out.path(), ".kglite/recipes/vault.by_keyword.md");
assert!(
recipe.contains("```cypher\nMATCH (n)-[:HAS_KEYWORD]"),
"{recipe}"
);
assert!(
recipe.contains("\"additionalProperties\":false"),
"{recipe}"
);
}
#[test]
fn a_carried_skill_and_recipe_reimport_from_the_export() {
let graph = build_vault(&golden_vault());
let out = tempfile::tempdir().unwrap();
export_to(&graph, out.path());
let reimported = build_vault(out.path());
let skills = crate::graph::skills::list(&reimported);
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "vault_overview");
let recipes = crate::graph::recipes::list(&reimported);
assert_eq!(recipes.len(), 1);
assert_eq!(
(recipes[0].recipe.as_str(), recipes[0].name.as_str()),
("vault", "by_keyword")
);
assert_eq!(
recipes[0].recipe_description,
"Navigating the golden vault."
);
}
fn add_nodes(graph: &mut DirGraph, label: &str, ids: &[&str]) {
let frame = crate::datatypes::values::DataFrame::from_cypher_rows(
vec!["concept_id".to_string(), "title".to_string()],
ids.iter()
.map(|id| {
vec![
Value::String((*id).to_string()),
Value::String((*id).to_string()),
]
})
.collect(),
)
.unwrap();
crate::graph::mutation::maintain::add_nodes(
graph,
frame,
label.to_string(),
"concept_id".to_string(),
Some("title".to_string()),
Some("update".to_string()),
)
.unwrap();
}
#[test]
fn a_graph_that_never_had_files_exports_every_node_but_the_system_labels() {
let mut graph = DirGraph::new();
add_nodes(&mut graph, "Person", &["a", "b"]);
add_nodes(&mut graph, TAG_LABEL, &["seismic"]);
add_nodes(&mut graph, SOURCE_LABEL, &["https://example.com/x"]);
add_nodes(&mut graph, FOLDER_LABEL, &["notes"]);
add_nodes(&mut graph, IMAGE_LABEL, &["img/x.png"]);
add_nodes(&mut graph, ATTACHMENT_LABEL, &["img/x.pdf"]);
crate::graph::skills::set(
&mut graph,
&crate::graph::skills::SkillRecord {
name: "how_to".to_string(),
description: "What this graph holds.".to_string(),
body: "Query it with Cypher.".to_string(),
references_tools: Vec::new(),
delivery: crate::graph::skills::Delivery::default(),
},
)
.unwrap();
let out = tempfile::tempdir().unwrap();
export_to(&graph, out.path());
assert_eq!(
tree(out.path()),
vec![
".kglite/export-manifest.json",
".kglite/skills/how_to.md",
"Person/a.md",
"Person/b.md"
],
"the system labels are never note files, whatever the graph's provenance"
);
}
#[test]
fn a_path_whose_top_folder_is_the_label_is_preserved() {
let source = tempfile::tempdir().unwrap();
write_vault(
source.path(),
&[
("Note/kept.md", "Stays where it is.\n"),
("Note/deep/also.md", "Top folder still matches.\n"),
("elsewhere/moved.md", "---\ntype: Note\n---\nRe-filed.\n"),
],
);
let graph = build_vault(source.path());
let out = tempfile::tempdir().unwrap();
export_to(&graph, out.path());
let files = tree(out.path());
assert!(files.contains(&"Note/kept.md".to_string()), "{files:?}");
assert!(
files.contains(&"Note/deep/also.md".to_string()),
"{files:?}"
);
assert!(files.contains(&"Note/moved.md".to_string()), "{files:?}");
assert!(
!files.contains(&"elsewhere/moved.md".to_string()),
"{files:?}"
);
}
#[test]
fn a_folder_note_keeps_its_spelling_beside_its_folder() {
let source = tempfile::tempdir().unwrap();
write_vault(
source.path(),
&[
("Note/team.md", "The folder note for `Note/team/`.\n"),
("Note/team/member.md", "Inside it.\n"),
],
);
let graph = build_vault(source.path());
let out = tempfile::tempdir().unwrap();
export_to(&graph, out.path());
let files = tree(out.path());
assert!(
files.contains(&"Note/team.md".to_string())
&& files.contains(&"Note/team/member.md".to_string()),
"the folder note is not moved into its own folder: {files:?}"
);
}
#[test]
fn a_relocated_note_keeps_the_stem_its_links_name() {
let source = tempfile::tempdir().unwrap();
write_vault(
source.path(),
&[
("titled.md", "---\ntitle: A Nice Title\n---\nprose\n"),
("plain.md", "prose\n"),
],
);
let graph = build_vault(source.path());
let out = tempfile::tempdir().unwrap();
export_to(&graph, out.path());
assert_eq!(
tree(out.path()),
vec![
".kglite/export-manifest.json",
"Note/plain.md",
"Note/titled.md"
],
"only the folder moves"
);
assert!(
read(out.path(), "Note/titled.md").contains("title: A Nice Title\n"),
"and the title the stem does not spell is written out"
);
}
#[test]
fn a_note_no_file_ever_backed_is_named_by_its_title_then_its_id() {
let graph = titled_notes(&[("titled", "A Nice Title"), ("plain", "")]);
let out = tempfile::tempdir().unwrap();
export_to(&graph, out.path());
assert_eq!(
tree(out.path()),
vec![
".kglite/export-manifest.json",
"Note/A Nice Title.md",
"Note/plain.md"
]
);
}
#[test]
fn forbidden_characters_in_a_generated_name_are_replaced() {
let graph = titled_notes(&[("n", "a/b:c*d?e\"f<g>h|i")]);
let out = tempfile::tempdir().unwrap();
export_to(&graph, out.path());
assert_eq!(
tree(out.path()),
vec![".kglite/export-manifest.json", "Note/a-b-c-d-e-f-g-h-i.md"]
);
}
fn titled_notes(pairs: &[(&str, &str)]) -> DirGraph {
let mut graph = DirGraph::new();
let frame = crate::datatypes::values::DataFrame::from_cypher_rows(
vec!["concept_id".to_string(), "title".to_string()],
pairs
.iter()
.map(|(id, title)| {
vec![
Value::String((*id).to_string()),
Value::String((*title).to_string()),
]
})
.collect(),
)
.unwrap();
crate::graph::mutation::maintain::add_nodes(
&mut graph,
frame,
"Note".to_string(),
"concept_id".to_string(),
Some("title".to_string()),
Some("update".to_string()),
)
.unwrap();
graph
}
#[test]
fn a_case_only_path_collision_appends_the_id() {
let source = tempfile::tempdir().unwrap();
write_vault(
source.path(),
&[
("a/Roadmap.md", "---\ntype: Doc\n---\nUpper.\n"),
("b/roadmap.md", "---\ntype: Doc\n---\nLower.\n"),
],
);
let graph = build_vault(source.path());
let out = tempfile::tempdir().unwrap();
export_to(&graph, out.path());
assert_eq!(
tree(out.path()),
vec![
".kglite/export-manifest.json",
"Doc/Roadmap.md",
"Doc/roadmap-roadmap.md"
],
"the second note in (label, id) order takes the suffix"
);
}
#[test]
fn type_is_never_emitted() {
let (_dir, text) = export_one("---\ntype: Report\n---\n", "prose\n");
assert!(!text.contains("type:"), "{text}");
}
#[test]
fn id_and_title_are_emitted_only_when_the_stem_does_not_say_them() {
let (_dir, silent) = export_one("---\ntitle: subject\n---\n", "prose\n");
assert_eq!(silent, "prose\n", "stem == id == title, so nothing to say");
let (_dir, spoken) = export_one("---\nid: other-id\ntitle: Other Title\n---\n", "prose\n");
assert!(spoken.contains("id: other-id\n"), "{spoken}");
assert!(spoken.contains("title: Other Title\n"), "{spoken}");
}
#[test]
fn dotted_keys_become_nested_maps_again() {
let (_dir, text) = export_one(
"---\nmetadata:\n source: vendor\n depth: 2\n---\n",
"prose\n",
);
assert!(
text.contains("metadata:\n depth: 2\n source: vendor\n"),
"{text}"
);
}
#[test]
fn lists_become_yaml_sequences() {
let (_dir, text) = export_one("---\ntags:\n - one\n - two\n---\n", "prose\n");
assert!(text.contains("tags:\n - one\n - two\n"), "{text}");
}
#[test]
fn keys_are_sorted() {
let (_dir, text) = export_one("---\nzebra: 1\nalpha: 2\nmid: 3\n---\n", "prose\n");
let keys: Vec<&str> = text
.lines()
.filter(|l| l.contains(": "))
.map(|l| l.split(':').next().unwrap())
.collect();
assert_eq!(keys, vec!["alpha", "mid", "zebra"], "{text}");
}
#[test]
fn temporal_values_become_iso_strings() {
let (_dir, text) = export_one(
"---\nupdated: 2026-01-15\nreviewed: '2026-01-15T09:30:00Z'\n---\n",
"prose\n",
);
assert!(text.contains("updated: 2026-01-15\n"), "{text}");
assert!(text.contains("reviewed: 2026-01-15T09:30:00Z\n"), "{text}");
}
#[test]
fn a_point_becomes_wkt() {
let mut graph = DirGraph::new();
let frame = crate::datatypes::values::DataFrame::from_cypher_rows(
vec![
"concept_id".to_string(),
"title".to_string(),
"where".to_string(),
],
vec![vec![
Value::String("p".to_string()),
Value::String("p".to_string()),
Value::Point {
lat: 60.5,
lon: 5.25,
},
]],
)
.unwrap();
crate::graph::mutation::maintain::add_nodes(
&mut graph,
frame,
"Site".to_string(),
"concept_id".to_string(),
Some("title".to_string()),
Some("update".to_string()),
)
.unwrap();
let out = tempfile::tempdir().unwrap();
export_to(&graph, out.path());
assert!(
read(out.path(), "Site/p.md").contains("where: POINT(5.25 60.5)"),
"{}",
read(out.path(), "Site/p.md")
);
}
#[test]
fn the_body_property_is_never_frontmatter() {
let (_dir, text) = export_one("---\ntitle: subject\n---\n", "the prose\n");
assert!(!text.contains("body:"), "{text}");
assert!(text.ends_with("the prose\n"), "{text}");
}
#[test]
fn a_string_that_would_reparse_as_something_else_is_quoted() {
let source = tempfile::tempdir().unwrap();
write_vault(
source.path(),
&[
(
".kglite/vault.yaml",
"kglite_vault: 1\ntypes:\n Note:\n numeric: string\n floaty: string\n \
flag: string\n empty: string\n dated: string\n",
),
(
"n.md",
"---\nnumeric: \"12\"\nfloaty: \"1.5\"\nflag: \"true\"\nempty: \"\"\n\
dated: \"2026-01-15\"\ncolon: \"a: b\"\nhashed: \"a #b\"\n\
leading: \"- dash\"\n\
mixed: [\"[[A]]\", \"plain\", 2026-01-15, \"12\", \"true\"]\n---\nprose\n",
),
],
);
let graph = build_vault(source.path());
let out = tempfile::tempdir().unwrap();
export_to(&graph, out.path());
let text = read(out.path(), "Note/n.md");
for expected in [
"numeric: \"12\"",
"floaty: \"1.5\"",
"flag: \"true\"",
"empty: \"\"",
"dated: \"2026-01-15\"",
"colon: \"a: b\"",
"hashed: \"a #b\"",
"leading: \"- dash\"",
" - \"[[A]]\"",
" - plain",
" - \"2026-01-15\"",
] {
assert!(text.contains(expected), "missing {expected} in\n{text}");
}
let back = build_vault(out.path());
for (key, expected) in [
("numeric", "12"),
("floaty", "1.5"),
("flag", "true"),
("colon", "a: b"),
("hashed", "a #b"),
("leading", "- dash"),
] {
assert_eq!(
property(&back, "n", key),
Some(Value::String(expected.to_string())),
"{key} did not come back a string"
);
}
assert_eq!(
property(&back, "n", "mixed"),
Some(Value::List(
["[[A]]", "plain", "2026-01-15", "12", "true"]
.into_iter()
.map(|s| Value::String(s.to_string()))
.collect()
)),
"a mixed list keeps every element a string"
);
}
fn property(graph: &DirGraph, id: &str, key: &str) -> Option<Value> {
graph.graph.node_indices().find_map(|idx| {
let view = graph.node_view(idx)?;
(view.id().as_ref() == &Value::String(id.to_string()))
.then(|| view.get_property_value(key))
.flatten()
})
}
#[test]
fn the_body_is_verbatim_and_a_bodyless_note_is_frontmatter_only() {
let body = "# Heading\n\nSome *prose* with odd spacing.\n";
let (_dir, text) = export_one("---\nkeep: 1\n---\n", body);
assert!(text.ends_with(body), "{text}");
let source = tempfile::tempdir().unwrap();
write_vault(source.path(), &[("n.md", "---\nkeep: 1\n---\n")]);
let graph = build_vault(source.path());
let out = tempfile::tempdir().unwrap();
export_to(&graph, out.path());
assert_eq!(read(out.path(), "Note/n.md"), "---\nkeep: 1\n---\n");
}
#[test]
fn typed_edges_become_sorted_wikilink_lists() {
let source = tempfile::tempdir().unwrap();
write_vault(
source.path(),
&[
(
"a.md",
"---\ndepends_on:\n - \"[[c]]\"\n - \"[[b]]\"\n---\nprose\n",
),
("b.md", "prose\n"),
("c.md", "prose\n"),
],
);
let graph = build_vault(source.path());
let out = tempfile::tempdir().unwrap();
export_to(&graph, out.path());
assert!(
read(out.path(), "Note/a.md").contains("depends_on:\n - \"[[b]]\"\n - \"[[c]]\"\n"),
"{}",
read(out.path(), "Note/a.md")
);
}
#[test]
fn a_link_already_in_the_body_is_not_repeated_in_frontmatter() {
let source = tempfile::tempdir().unwrap();
write_vault(
source.path(),
&[
("a.md", "Body links to [[b]] and embeds ![[c]].\n"),
("b.md", "prose\n"),
("c.md", "prose\n"),
],
);
let graph = build_vault(source.path());
let out = tempfile::tempdir().unwrap();
export_to(&graph, out.path());
let text = read(out.path(), "Note/a.md");
assert!(!text.contains("links_to:"), "{text}");
assert!(!text.contains("embeds:"), "{text}");
assert_eq!(text, "Body links to [[b]] and embeds ![[c]].\n");
}
#[test]
fn a_typed_edge_is_emitted_even_when_the_body_links_the_same_target() {
let source = tempfile::tempdir().unwrap();
write_vault(
source.path(),
&[
("a.md", "---\ndepends_on: \"[[b]]\"\n---\nAlso see [[b]].\n"),
("b.md", "prose\n"),
],
);
let graph = build_vault(source.path());
let out = tempfile::tempdir().unwrap();
export_to(&graph, out.path());
assert!(
read(out.path(), "Note/a.md").contains("depends_on:\n - \"[[b]]\"\n"),
"{}",
read(out.path(), "Note/a.md")
);
}
#[test]
fn structural_and_synthesized_edges_are_not_emitted() {
let source = tempfile::tempdir().unwrap();
write_vault(
source.path(),
&[(
"a.md",
"---\ntags:\n - alpha\n---\nSee https://example.com/x.\n",
)],
);
let graph = build_vault(source.path());
let out = tempfile::tempdir().unwrap();
export_to(&graph, out.path());
let text = read(out.path(), "Note/a.md");
assert!(!text.contains("tagged:"), "{text}");
assert!(!text.contains("contains:"), "{text}");
assert!(
!text.contains("links_to:"),
"the Source URL is not a file: {text}"
);
assert!(
text.contains("tags:\n - alpha\n"),
"the property stays: {text}"
);
}
#[test]
fn an_ambiguous_target_is_written_folder_qualified() {
let source = tempfile::tempdir().unwrap();
write_vault(
source.path(),
&[
(
"start.md",
"---\ntype: Start\nsee_also: \"[[one/dup]]\"\n---\nprose\n",
),
("one/dup.md", "---\ntype: One\n---\nprose\n"),
("two/dup.md", "---\ntype: Two\n---\nprose\n"),
],
);
let graph = build_vault(source.path());
let out = tempfile::tempdir().unwrap();
export_to(&graph, out.path());
let text = read(out.path(), "Start/start.md");
assert!(text.contains("see_also:\n - \"[[One/dup]]\"\n"), "{text}");
}
#[test]
fn a_parent_the_layout_no_longer_expresses_is_emitted_as_its_edge_key() {
let source = tempfile::tempdir().unwrap();
write_vault(
source.path(),
&[
("hub.md", "---\ntitle: Hub\n---\nprose\n"),
("leaf.md", "---\nparent: \"[[hub]]\"\n---\nprose\n"),
],
);
let graph = build_vault(source.path());
let out = tempfile::tempdir().unwrap();
export_to(&graph, out.path());
let text = read(out.path(), "Note/leaf.md");
assert!(text.contains("child_of:\n - \"[[hub]]\"\n"), "{text}");
let back = build_vault(out.path());
assert!(
edge_types(&back).contains(&"CHILD_OF".to_string()),
"{:?}",
edge_types(&back)
);
}
fn edge_types(graph: &DirGraph) -> Vec<String> {
let mut out: Vec<String> = graph
.graph
.edge_indices()
.filter_map(|e| {
graph
.graph
.edge_weight(e)
.map(|d| d.connection_type_str(&graph.interner).to_string())
})
.collect();
out.sort();
out.dedup();
out
}
#[test]
fn edge_properties_are_dropped_and_counted() {
let source = tempfile::tempdir().unwrap();
write_vault(
source.path(),
&[
("a.md", "---\ntitle: a\n---\n## Deep\n\n[[b#anchor]]\n"),
("b.md", "prose\n"),
],
);
let graph = build_vault(source.path());
let out = tempfile::tempdir().unwrap();
let report = export_to(&graph, out.path());
assert_eq!(
report.edge_properties_dropped, 2,
"`section` and `anchor` on the one body link"
);
let text = read(out.path(), "Note/a.md");
assert!(
!text.contains("links_to"),
"the link is already in the body, so no edge key repeats it: {text}"
);
}
#[test]
fn no_separator_line_is_inserted_between_the_frontmatter_and_the_body() {
let (_dir, tight) = export_one("---\nid: x\n---\n", "prose\n");
assert!(tight.ends_with("---\nprose\n"), "{tight}");
let (_dir, spaced) = export_one("---\nid: x\n---\n", "\nprose\n");
assert!(
spaced.ends_with("---\n\nprose\n"),
"the author's own blank line is body, and survives: {spaced}"
);
}
#[test]
fn a_title_a_heading_would_shadow_is_written_out() {
let (_dir, text) = export_one("---\ntitle: subject\n---\n", "## Deep dive\n\nprose\n");
assert!(
text.starts_with("---\ntitle: subject\n---\n"),
"the heading would have taken the title: {text}"
);
}
#[test]
fn a_title_a_name_key_already_states_is_left_out() {
let (_dir, text) = export_one("---\nname: Claude memory\n---\n", "## Deep dive\n\nprose\n");
assert!(
text.contains("name: Claude memory"),
"`name:` is a property, not a reserved key: {text}"
);
assert!(
!text.contains("title:"),
"the reader reads `name:` before the heading: {text}"
);
let (dir, _) = export_one("---\nname: Claude memory\n---\n", "## Deep dive\n\nprose\n");
let back = build_vault(dir.path());
assert!(
title_of(&back, "subject") == "Claude memory",
"and recovers exactly that"
);
}
#[test]
fn a_body_link_resolved_through_an_alias_is_not_repeated_in_frontmatter() {
let source = tempfile::tempdir().unwrap();
write_vault(
source.path(),
&[
("Note/a.md", "## Deep dive\n\nSee [[The Other One]].\n"),
(
"Note/b.md",
"---\naliases:\n - The Other One\n---\nprose\n",
),
],
);
let graph = build_vault(source.path());
assert_eq!(links_to_count(&graph), 1);
let out = tempfile::tempdir().unwrap();
export_to(&graph, out.path());
let text = read(out.path(), "Note/a.md");
assert!(!text.contains("links_to"), "{text}");
assert_eq!(
links_to_count(&build_vault(out.path())),
1,
"and the re-import still has one edge, not the body's plus a copy"
);
}
fn links_to_count(graph: &DirGraph) -> usize {
let _arena_guard = graph.graph.begin_query();
graph
.graph
.edge_indices()
.filter(|e| {
graph
.graph
.edge_weight(*e)
.is_some_and(|d| d.connection_type_str(&graph.interner) == "LINKS_TO")
})
.count()
}
fn title_of(graph: &DirGraph, id: &str) -> String {
let _arena_guard = graph.graph.begin_query();
for idx in graph.graph.node_indices() {
if let Some(view) = graph.node_view(idx) {
if matches!(view.id().as_ref(), Value::String(s) if s == id) {
return crate::datatypes::values::raw_string(&view.title());
}
}
}
panic!("no node with id `{id}`");
}
#[test]
fn a_title_the_reader_recovers_on_its_own_is_left_out() {
let (_dir, heading) = export_one("---\ntitle: Deep dive\n---\n", "## Deep dive\n\nprose\n");
assert!(
!heading.contains("title:"),
"the body's own heading already says it: {heading}"
);
let (_dir, stem) = export_one("---\ntitle: subject\n---\n", "prose with no heading\n");
assert!(
!stem.contains("title:"),
"the filename stem already says it: {stem}"
);
}
#[test]
fn the_manifest_names_every_file_the_export_wrote() {
let graph = build_vault(&golden_vault());
let out = tempfile::tempdir().unwrap();
export_to(&graph, out.path());
let manifest: serde_json::Value =
serde_json::from_str(&read(out.path(), ".kglite/export-manifest.json")).unwrap();
assert_eq!(manifest["kglite_vault"], 1);
let files = manifest["files"].as_object().unwrap();
let mut listed: Vec<&String> = files.keys().collect();
listed.sort();
let mut written = tree(out.path());
written.retain(|f| f != ".kglite/export-manifest.json");
assert_eq!(
listed.into_iter().cloned().collect::<Vec<String>>(),
written,
"the manifest is not allowed to omit a file it wrote"
);
}
#[test]
fn a_foreign_file_is_refused_and_force_replaces_it() {
let source = tempfile::tempdir().unwrap();
write_vault(source.path(), &[("n.md", "prose\n")]);
let graph = build_vault(source.path());
let out = tempfile::tempdir().unwrap();
std::fs::create_dir_all(out.path().join("Note")).unwrap();
std::fs::write(out.path().join("Note/n.md"), "somebody else's file\n").unwrap();
let report = export_to(&graph, out.path());
assert_eq!((report.files_written, report.files_refused), (0, 1));
assert_eq!(
report.refusals,
vec!["Note/n.md: not written by an export (use force to replace)"]
);
assert_eq!(read(out.path(), "Note/n.md"), "somebody else's file\n");
let forced = export(
&graph,
out.path(),
&ExportOptions {
force: true,
..ExportOptions::default()
},
)
.unwrap();
assert_eq!((forced.files_written, forced.files_refused), (1, 0));
assert_eq!(read(out.path(), "Note/n.md"), "prose\n");
}
#[test]
fn an_edited_owned_file_is_refused_and_stays_owned() {
let source = tempfile::tempdir().unwrap();
write_vault(source.path(), &[("n.md", "prose\n")]);
let graph = build_vault(source.path());
let out = tempfile::tempdir().unwrap();
export_to(&graph, out.path());
std::fs::write(out.path().join("Note/n.md"), "a human wrote this\n").unwrap();
let report = export_to(&graph, out.path());
assert_eq!((report.files_written, report.files_refused), (0, 1));
assert_eq!(
report.refusals,
vec!["Note/n.md: edited since the last export (use force to replace)"]
);
assert_eq!(read(out.path(), "Note/n.md"), "a human wrote this\n");
let empty = DirGraph::new();
let after = export_to(&empty, out.path());
assert_eq!((after.files_deleted, after.files_refused), (0, 1));
assert_eq!(read(out.path(), "Note/n.md"), "a human wrote this\n");
}
#[test]
fn an_unchanged_file_is_not_rewritten() {
let source = tempfile::tempdir().unwrap();
write_vault(source.path(), &[("n.md", "prose\n")]);
let graph = build_vault(source.path());
let out = tempfile::tempdir().unwrap();
export_to(&graph, out.path());
let before = std::fs::metadata(out.path().join("Note/n.md"))
.unwrap()
.modified()
.unwrap();
let again = export_to(&graph, out.path());
assert_eq!((again.files_written, again.files_unchanged), (0, 1));
let after = std::fs::metadata(out.path().join("Note/n.md"))
.unwrap()
.modified()
.unwrap();
assert_eq!(
before, after,
"an unchanged file keeps its modification time"
);
}
#[test]
fn only_manifest_owned_files_are_deleted() {
let source = tempfile::tempdir().unwrap();
write_vault(
source.path(),
&[("gone.md", "prose\n"), ("stays.md", "prose\n")],
);
let graph = build_vault(source.path());
let out = tempfile::tempdir().unwrap();
export_to(&graph, out.path());
std::fs::write(out.path().join("Note/theirs.md"), "not ours\n").unwrap();
let smaller = tempfile::tempdir().unwrap();
write_vault(smaller.path(), &[("stays.md", "prose\n")]);
let report = export_to(&build_vault(smaller.path()), out.path());
assert_eq!(report.files_deleted, 1);
let files = tree(out.path());
assert!(!files.contains(&"Note/gone.md".to_string()), "{files:?}");
assert!(files.contains(&"Note/stays.md".to_string()), "{files:?}");
assert!(
files.contains(&"Note/theirs.md".to_string()),
"a file the manifest never named is never deleted: {files:?}"
);
}
#[test]
fn a_manifest_of_an_unknown_version_is_an_error() {
let source = tempfile::tempdir().unwrap();
write_vault(source.path(), &[("n.md", "prose\n")]);
let graph = build_vault(source.path());
let out = tempfile::tempdir().unwrap();
std::fs::create_dir_all(out.path().join(".kglite")).unwrap();
std::fs::write(
out.path().join(".kglite/export-manifest.json"),
"{\"kglite_vault\": 99, \"files\": {}}",
)
.unwrap();
let error = export(&graph, out.path(), &ExportOptions::default()).unwrap_err();
assert!(error.contains("kglite_vault: 99"), "{error}");
}
#[test]
fn attachments_are_copied_when_the_source_root_is_known() {
let graph = build_vault(&golden_vault());
let out = tempfile::tempdir().unwrap();
let report = export(
&graph,
out.path(),
&ExportOptions {
source_root: Some(golden_vault()),
..ExportOptions::default()
},
)
.unwrap();
assert_eq!(
(report.attachments_copied, report.attachments_unresolved),
(3, 0)
);
for rel in ["img/diagram.png", "img/faults.png", "img/handbook.pdf"] {
assert!(out.path().join(rel).is_file(), "{rel} was not copied");
}
}
#[test]
fn attachments_are_reported_unresolved_without_a_source_root() {
let mut graph = (*build_vault(&golden_vault())).clone();
graph.source_root = None;
let out = tempfile::tempdir().unwrap();
let report = export_to(&graph, out.path());
assert_eq!(
(report.attachments_copied, report.attachments_unresolved),
(0, 3)
);
assert!(!out.path().join("img").exists());
}
#[test]
fn a_vault_built_graph_exports_its_attachments_without_being_told_the_root() {
let graph = build_vault(&golden_vault());
assert!(graph.source_root.is_some(), "the build stamped a root");
let out = tempfile::tempdir().unwrap();
let report = export_to(&graph, out.path());
assert_eq!(
(report.attachments_copied, report.attachments_unresolved),
(3, 0)
);
assert!(out.path().join("img/faults.png").is_file());
}
const MEDIA: &[(&str, &str)] = &[
(
"Media/figures.md",
"---\ntitle: Figures\n---\n is note-relative.\n",
),
("img/faults.png", "stand-in bytes\n"),
];
fn fixed_mtime() -> SystemTime {
SystemTime::UNIX_EPOCH + Duration::from_secs(1_577_836_800)
}
fn set_mtime(path: &Path, when: SystemTime) {
std::fs::File::options()
.write(true)
.open(path)
.unwrap()
.set_modified(when)
.unwrap();
}
fn modified(path: &Path) -> SystemTime {
std::fs::metadata(path).unwrap().modified().unwrap()
}
fn backdated_media() -> (tempfile::TempDir, Arc<DirGraph>) {
let source = tempfile::tempdir().unwrap();
write_vault(source.path(), MEDIA);
set_mtime(&source.path().join("img/faults.png"), fixed_mtime());
let graph = build_vault(source.path());
(source, graph)
}
#[test]
fn a_copied_attachment_keeps_the_source_files_modification_time() {
let (source, graph) = backdated_media();
let out = tempfile::tempdir().unwrap();
let report = export(
&graph,
out.path(),
&ExportOptions {
source_root: Some(source.path().to_path_buf()),
..ExportOptions::default()
},
)
.unwrap();
assert_eq!(
(report.attachments_copied, report.attachments_unresolved),
(1, 0)
);
assert_eq!(
modified(&out.path().join("img/faults.png")),
fixed_mtime(),
"the copy was stamped with the time of the copy, not the source's"
);
}
#[test]
fn a_second_export_leaves_an_unchanged_attachment_alone() {
let (source, graph) = backdated_media();
let out = tempfile::tempdir().unwrap();
let opts = ExportOptions {
source_root: Some(source.path().to_path_buf()),
..ExportOptions::default()
};
let first = export(&graph, out.path(), &opts).unwrap();
assert!(first.files_written > 0);
let second = export(&graph, out.path(), &opts).unwrap();
assert_eq!(
(second.files_written, second.files_unchanged),
(0, first.files_written),
"the second export rewrote a file whose bytes had not moved"
);
assert_eq!(
modified(&out.path().join("img/faults.png")),
fixed_mtime(),
"and left the modification time where the first export put it"
);
}
#[test]
fn exporting_twice_produces_identical_bytes() {
let graph = build_vault(&golden_vault());
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
let opts = ExportOptions {
source_root: Some(golden_vault()),
..ExportOptions::default()
};
export(&graph, first.path(), &opts).unwrap();
export(&build_vault(&golden_vault()), second.path(), &opts).unwrap();
assert_eq!(
snapshot(first.path()),
snapshot(second.path()),
"the same graph must produce the same tree, byte for byte"
);
}
#[test]
fn the_golden_vault_round_trips_apart_from_its_documented_losses() {
let source = golden_vault();
let original = build_vault(&source);
let out = tempfile::tempdir().unwrap();
export(
&original,
out.path(),
&ExportOptions {
source_root: Some(source.clone()),
..ExportOptions::default()
},
)
.unwrap();
let back = build_vault(out.path());
assert_eq!(
label_counts(&back).get("Article"),
label_counts(&original).get("Article")
);
assert_eq!(
label_counts(&back).get("Initiative"),
label_counts(&original).get("Initiative")
);
assert_eq!(label_counts(&back).get("Keyword"), None);
assert_eq!(label_counts(&back).get("Concept"), Some(&1));
for conn in ["DEPENDS_ON", "CHILD_OF", "EMBEDS", "TAGGED", "HAS_IMAGE"] {
assert!(
edge_types(&back).contains(&conn.to_string()),
"{conn} did not survive: {:?}",
edge_types(&back)
);
}
assert!(!edge_types(&back).contains(&"HAS_KEYWORD".to_string()));
}
fn label_counts(graph: &DirGraph) -> BTreeMap<String, usize> {
let mut out: BTreeMap<String, usize> = BTreeMap::new();
for idx in graph.graph.node_indices() {
if let Some(view) = graph.node_view(idx) {
*out.entry(view.node_type_str(&graph.interner).to_string())
.or_default() += 1;
}
}
out
}