use std::collections::BTreeSet;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use chrono::{DateTime, FixedOffset};
use sha2::{Digest, Sha256};
use crate::index::{parse_ts, scalar_string, yaml_to_json_value};
use crate::parser::split_frontmatter;
use crate::render::{heading_level, heading_text};
use crate::store::{self, Layer, Store};
#[derive(Debug, Clone, PartialEq)]
pub struct EmittedFile {
pub path: String,
pub layer: Option<Layer>,
pub frontmatter: serde_json::Map<String, serde_json::Value>,
pub type_: Option<String>,
pub meta_type: Option<String>,
pub title: Option<String>,
pub summary: Option<String>,
pub body: String,
pub links: Vec<String>,
pub created: Option<DateTime<FixedOffset>>,
pub updated: Option<DateTime<FixedOffset>>,
pub sha256: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Emit {
pub files: Vec<EmittedFile>,
pub sources: usize,
pub records: usize,
}
pub fn compute(store: &Store) -> crate::Result<Emit> {
let mut rels: Vec<PathBuf> = store.walk()?;
rels.push(PathBuf::from("DB.md"));
rels.sort();
let mut files = Vec::with_capacity(rels.len());
let mut sources = 0usize;
let mut records = 0usize;
for rel in &rels {
let file = emit_file(store, rel)?;
match file.layer {
Some(Layer::Sources) => sources += 1,
Some(Layer::Records) => records += 1,
None => {}
}
files.push(file);
}
Ok(Emit {
files,
sources,
records,
})
}
fn emit_file(store: &Store, rel: &Path) -> crate::Result<EmittedFile> {
let abs = store.abs_path(rel);
let bytes = std::fs::read(&abs)?;
let sha256 = sha256_hex(&bytes);
let text = String::from_utf8_lossy(&bytes);
let (yaml, body) = match split_frontmatter(&text, &abs) {
Ok(parsed) => (parsed.frontmatter_yaml, parsed.body),
Err(_) => (String::new(), text.clone().into_owned()),
};
let map: serde_norway::Mapping = if yaml.trim().is_empty() {
serde_norway::Mapping::new()
} else {
serde_norway::from_str(&yaml).unwrap_or_default()
};
let mut frontmatter = serde_json::Map::new();
let mut type_ = None;
let mut summary = None;
let mut declared_meta_type = None;
let mut name_field = None;
let mut title_field = None;
let mut created = None;
let mut updated = None;
for (k, v) in &map {
let Some(key) = k.as_str() else { continue };
match key {
"type" => type_ = scalar_string(v),
"summary" => summary = scalar_string(v),
"meta-type" => declared_meta_type = scalar_string(v),
"name" => name_field = non_empty(scalar_string(v)),
"title" => title_field = non_empty(scalar_string(v)),
"created" => created = v.as_str().and_then(parse_ts),
"updated" => updated = v.as_str().and_then(parse_ts),
_ => {}
}
frontmatter.insert(key.to_string(), yaml_to_json_value(v));
}
let layer = rel
.components()
.next()
.and_then(|c| c.as_os_str().to_str())
.and_then(Layer::from_dir_name);
let meta_type = match layer {
Some(Layer::Records) => Some(declared_meta_type.unwrap_or_else(|| "fact".to_string())),
_ => None,
};
let title = name_field.or(title_field).or_else(|| first_h1(&body));
let mut links = Vec::new();
let mut seen = BTreeSet::new();
for target in store::extract_edge_targets(&text) {
let with_md = format!("{target}.md");
if seen.insert(with_md.clone()) {
links.push(with_md);
}
}
Ok(EmittedFile {
path: rel.to_string_lossy().replace('\\', "/"),
layer,
frontmatter,
type_,
meta_type,
title,
summary,
body,
links,
created,
updated,
sha256,
})
}
fn non_empty(s: Option<String>) -> Option<String> {
s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
}
fn first_h1(body: &str) -> Option<String> {
let mut fence: Option<(u8, usize)> = None;
for line in body.lines() {
let content = line.trim_end_matches('\r');
if let Some(f) = fence {
if store::fence_closes(content, f) {
fence = None;
}
continue;
}
if let Some(opened) = store::fence_opens(content) {
fence = Some(opened);
continue;
}
if heading_level(content) == 1 {
let text = heading_text(content, 1);
if !text.is_empty() {
return Some(text);
}
}
}
None
}
fn sha256_hex(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut hex = String::with_capacity(64);
for b in digest.iter() {
let _ = write!(hex, "{b:02x}");
}
hex
}
#[cfg(test)]
mod tests {
use super::*;
fn store() -> (tempfile::TempDir, Store) {
let tmp = tempfile::TempDir::new().expect("tempdir");
std::fs::write(
tmp.path().join("DB.md"),
"---\ntype: db-md\nscope: test\n---\n\n# Test store\n",
)
.expect("DB.md");
let store = Store::open_strict(tmp.path()).expect("open store");
(tmp, store)
}
fn seed(root: &Path, rel: &str, contents: &str) {
let abs = root.join(rel);
std::fs::create_dir_all(abs.parent().unwrap()).unwrap();
std::fs::write(abs, contents).unwrap();
}
fn by_path<'a>(emit: &'a Emit, path: &str) -> &'a EmittedFile {
emit.files
.iter()
.find(|f| f.path == path)
.unwrap_or_else(|| panic!("no emitted file {path}"))
}
#[test]
fn title_prefers_name_then_title_then_first_h1() {
let (tmp, store) = store();
seed(
tmp.path(),
"records/contacts/named.md",
"---\ntype: contact\nname: Sarah Chen\ntitle: Ignored\nsummary: s\n---\n\n# Also ignored\n",
);
seed(
tmp.path(),
"records/contacts/titled.md",
"---\ntype: contact\ntitle: The Title\nsummary: s\n---\nbody\n",
);
seed(
tmp.path(),
"records/decisions/h1.md",
"---\ntype: decision\nsummary: s\n---\n\n```\n# fenced, not a title\n```\n\n# Real Title ##\n",
);
let emit = compute(&store).expect("emit");
assert_eq!(
by_path(&emit, "records/contacts/named.md").title.as_deref(),
Some("Sarah Chen")
);
assert_eq!(
by_path(&emit, "records/contacts/titled.md")
.title
.as_deref(),
Some("The Title")
);
assert_eq!(
by_path(&emit, "records/decisions/h1.md").title.as_deref(),
Some("Real Title")
);
}
#[test]
fn no_frontmatter_degrades_to_empty_frontmatter_and_whole_body() {
let (tmp, store) = store();
let text = "Just a plain note, no frontmatter.\n";
seed(tmp.path(), "sources/notes/plain.md", text);
let emit = compute(&store).expect("emit");
let f = by_path(&emit, "sources/notes/plain.md");
assert!(f.frontmatter.is_empty());
assert_eq!(f.type_, None);
assert_eq!(f.body, text);
assert_eq!(f.layer, Some(Layer::Sources));
}
#[test]
fn meta_type_defaults_for_records_only() {
let (tmp, store) = store();
seed(
tmp.path(),
"records/contacts/fact.md",
"---\ntype: contact\nsummary: s\n---\nbody\n",
);
seed(
tmp.path(),
"records/decisions/conclusion.md",
"---\ntype: decision\nmeta-type: conclusion\nsummary: s\n---\nbody\n",
);
seed(
tmp.path(),
"sources/notes/n.md",
"---\ntype: note\nsummary: s\n---\nbody\n",
);
let emit = compute(&store).expect("emit");
assert_eq!(
by_path(&emit, "records/contacts/fact.md")
.meta_type
.as_deref(),
Some("fact")
);
assert_eq!(
by_path(&emit, "records/decisions/conclusion.md")
.meta_type
.as_deref(),
Some("conclusion")
);
assert_eq!(by_path(&emit, "sources/notes/n.md").meta_type, None);
assert_eq!(by_path(&emit, "DB.md").meta_type, None);
}
#[test]
fn links_are_normalized_deduped_and_fence_aware() {
let (tmp, store) = store();
seed(
tmp.path(),
"sources/notes/n.md",
"---\ntype: note\nsummary: s\ncompany: \"[[records/companies/acme]]\"\n---\n\
See [[records/contacts/sarah]] and [[records/contacts/sarah.md|Sarah]].\n\
Dangling: [[records/ghosts/nobody]].\n\
```\n[[records/contacts/fenced]]\n```\n",
);
let emit = compute(&store).expect("emit");
let f = by_path(&emit, "sources/notes/n.md");
assert_eq!(
f.links,
vec![
"records/companies/acme.md".to_string(),
"records/contacts/sarah.md".to_string(),
"records/ghosts/nobody.md".to_string(),
]
);
}
#[test]
fn db_md_is_emitted_with_no_layer_and_counts_ride_the_layers() {
let (tmp, store) = store();
seed(
tmp.path(),
"sources/notes/n.md",
"---\ntype: note\nsummary: s\n---\nbody\n",
);
seed(
tmp.path(),
"records/contacts/c.md",
"---\ntype: contact\nsummary: s\n---\nbody\n",
);
seed(
tmp.path(),
"records/contacts/index.md",
"# Contacts index\n",
);
let emit = compute(&store).expect("emit");
let paths: Vec<&str> = emit.files.iter().map(|f| f.path.as_str()).collect();
assert_eq!(
paths,
vec!["DB.md", "records/contacts/c.md", "sources/notes/n.md"]
);
let db = by_path(&emit, "DB.md");
assert_eq!(db.layer, None);
assert_eq!(db.type_.as_deref(), Some("db-md"));
assert_eq!(db.title.as_deref(), Some("Test store"));
assert_eq!((emit.files.len(), emit.sources, emit.records), (3, 1, 1));
}
}