dbmd_core/emit.rs
1// SPDX-License-Identifier: Apache-2.0
2
3//! `emit` — the whole-store structured dump (a SWEEP, off the loop).
4//!
5//! [`compute`] walks every content file (`sources/` + `records/`, per the
6//! same [`Store::walk`] discovery every SWEEP uses — derived `index.md`
7//! catalogs are skipped) plus the root `DB.md`, and projects each into an
8//! [`EmittedFile`]: the parsed frontmatter with values verbatim, the derived
9//! fields (layer, `type`, effective `meta-type`, title, `summary`,
10//! timestamps), the verbatim body, the normalized wiki-link targets, and the
11//! SHA-256 of the raw file bytes. The host-integration surface: a hub, an
12//! indexer, or a migration ingests a store as a pure consumer of `dbmd`
13//! output instead of reimplementing the parse.
14//!
15//! **Lenient by design.** A dump must describe the store as it is, so a
16//! malformed file degrades instead of aborting the sweep: a file with no
17//! frontmatter block emits an empty `frontmatter` with the whole text as
18//! `body`; unparseable frontmatter YAML emits an empty `frontmatter` with the
19//! after-fence remainder as `body`; a bad `created`/`updated` scalar leaves
20//! the typed timestamp unset while the raw value still rides in
21//! `frontmatter`. (Reporting those defects is `validate`'s job, not the
22//! dump's.) Only real failures — an unreadable file, a broken walk — error.
23//!
24//! **One notion, shared with the rest of the toolkit.** Link extraction is
25//! [`store::extract_edge_targets`] (fence-aware, alias-stripped,
26//! whitespace-trimmed) with the `.md` extension appended — the same
27//! resolution `graph` applies, in the on-disk spelling a consumer can match
28//! against `path` directly. Scalar coercion and the YAML→JSON value
29//! projection are the `index` module's ([`crate::index`]), so `emit` and
30//! `query --json` present identical value shapes. The effective `meta-type`
31//! mirrors [`Frontmatter::effective_meta_type`]: records only, absent ⇒
32//! `fact`, declared values verbatim. Title derivation reuses the `render`
33//! module's CommonMark ATX heading rules.
34//!
35//! [`Frontmatter::effective_meta_type`]: crate::parser::Frontmatter::effective_meta_type
36
37use std::collections::BTreeSet;
38use std::fmt::Write as _;
39use std::path::{Path, PathBuf};
40
41use chrono::{DateTime, FixedOffset};
42use sha2::{Digest, Sha256};
43
44use crate::index::{parse_ts, scalar_string, yaml_to_json_value};
45use crate::parser::split_frontmatter;
46use crate::render::{heading_level, heading_text};
47use crate::store::{self, EdgeSpan, Layer, Store};
48
49/// One file of the dump: the store-relative identity, the parsed frontmatter
50/// (values verbatim), the derived fields, the verbatim body, the normalized
51/// link targets, and the content hash.
52#[derive(Debug, Clone, PartialEq)]
53pub struct EmittedFile {
54 /// Store-relative path, POSIX separators (`records/contacts/sarah.md`).
55 pub path: String,
56 /// The layer the file lives in; `None` for the root `DB.md`.
57 pub layer: Option<Layer>,
58 /// The full parsed frontmatter mapping, values verbatim (the `index`
59 /// projection: strings/numbers/bools/lists as written; an inline
60 /// `[[...]]`-valued field as its wiki-link literal). Empty when the file
61 /// has no frontmatter block or its YAML does not parse.
62 pub frontmatter: serde_json::Map<String, serde_json::Value>,
63 /// The frontmatter `type`, scalar-coerced like `index`/`validate` coerce it.
64 pub type_: Option<String>,
65 /// The effective `meta-type` — records only: the declared value verbatim,
66 /// or `fact` when absent (SPEC default). `None` for sources and `DB.md`.
67 pub meta_type: Option<String>,
68 /// Display title: the `name` field, else the `title` field, else the
69 /// body's first ATX `#` heading (fence-aware, CommonMark rules).
70 pub title: Option<String>,
71 /// The frontmatter `summary`, scalar-coerced; `None` when absent.
72 pub summary: Option<String>,
73 /// The verbatim markdown body after the frontmatter block (the whole text
74 /// when the file has no frontmatter block).
75 pub body: String,
76 /// Normalized wiki-link targets in first-appearance order, deduped:
77 /// alias stripped (text before `|`), whitespace trimmed, `.md` appended —
78 /// the on-disk spelling, so a target matches a document `path` directly.
79 /// Dangling targets are included (existence is `validate`'s concern).
80 pub links: Vec<String>,
81 /// Every wiki-link OCCURRENCE in the body, in document order, with the byte
82 /// span it covers in `body` — the positional view `links` cannot give
83 /// (`links` is a deduped set; a renderer needs to splice at offsets).
84 ///
85 /// Body-only, deliberately: a `[[…]]` in a frontmatter VALUE is a real edge
86 /// (and appears in `links`) but is field data, never markdown rendered in
87 /// place, so it has no span. Empty for `DB.md` and for bodies with no
88 /// links.
89 pub link_spans: Vec<EdgeSpan>,
90 /// Frontmatter `created`, when present and RFC3339-parseable.
91 pub created: Option<DateTime<FixedOffset>>,
92 /// Frontmatter `updated`, when present and RFC3339-parseable.
93 pub updated: Option<DateTime<FixedOffset>>,
94 /// Lowercase-hex SHA-256 of the raw file bytes — the exact bytes this
95 /// projection was parsed from, so a consumer can detect drift.
96 pub sha256: String,
97}
98
99/// A computed whole-store dump: every emitted file plus the per-layer tally.
100#[derive(Debug, Clone, PartialEq)]
101pub struct Emit {
102 /// Every emitted file (content files + `DB.md`), sorted by path.
103 pub files: Vec<EmittedFile>,
104 /// How many emitted files live in `sources/`.
105 pub sources: usize,
106 /// How many emitted files live in `records/`.
107 pub records: usize,
108}
109
110/// **SWEEP.** Project the whole store into an [`Emit`]: every content file
111/// via [`Store::walk`] (both layers, derived catalogs skipped) plus the root
112/// `DB.md`, sorted by path. Read-only; errors only on real failures (an
113/// unreadable file, a broken walk) — malformed content degrades per the
114/// module contract, it never aborts the dump.
115pub fn compute(store: &Store) -> crate::Result<Emit> {
116 let rels = walk_rels(store)?;
117
118 let mut files = Vec::with_capacity(rels.len());
119 let mut sources = 0usize;
120 let mut records = 0usize;
121 for rel in &rels {
122 let file = emit_file(store, rel)?;
123 match file.layer {
124 Some(Layer::Sources) => sources += 1,
125 Some(Layer::Records) => records += 1,
126 None => {}
127 }
128 files.push(file);
129 }
130 Ok(Emit {
131 files,
132 sources,
133 records,
134 })
135}
136
137/// The exact file set a dump covers, in the exact order it emits: every
138/// content file per [`Store::walk`] plus the root `DB.md`, path-sorted.
139/// One definition, shared by [`compute`] and streaming consumers (the
140/// `--ndjson` CLI mode projects these one at a time), so the two forms can
141/// never disagree on membership or order.
142pub fn walk_rels(store: &Store) -> crate::Result<Vec<PathBuf>> {
143 let mut rels: Vec<PathBuf> = store.walk()?;
144 rels.push(PathBuf::from("DB.md"));
145 rels.sort();
146 Ok(rels)
147}
148
149/// Project one store-relative file into its [`EmittedFile`]. Public for
150/// streaming consumers ([`walk_rels`] supplies the canonical file set);
151/// the lenient-degrade contract is the module's, identical under
152/// [`compute`] and per-file use.
153pub fn emit_file(store: &Store, rel: &Path) -> crate::Result<EmittedFile> {
154 let bytes = store.read_bounded(rel, crate::parser::MAX_DBMD_FILE_BYTES)?;
155 let sha256 = sha256_hex(&bytes);
156
157 // Decode lossily: `sources/` is preserved verbatim per the SPEC and can
158 // carry non-UTF-8 imports; a stray byte substitutes U+FFFD rather than
159 // aborting the sweep (the same posture as the index projection and the
160 // store's link scan).
161 let text = String::from_utf8_lossy(&bytes);
162
163 // Split the frontmatter block with the canonical splitter (BOM + fence
164 // tolerance identical to every write surface). A file with no block — or
165 // an unterminated one — is still a complete dump member: empty
166 // frontmatter, the whole text as body.
167 let (yaml, body) = match split_frontmatter(&text, rel) {
168 Ok(parsed) => (parsed.frontmatter_yaml, parsed.body),
169 Err(_) => (String::new(), text.clone().into_owned()),
170 };
171
172 // Parse the frontmatter YAML leniently: a malformed mapping yields an
173 // empty frontmatter (the body still carries the file), mirroring how a
174 // hand-written store degrades. Non-string keys are skipped, matching the
175 // index projection.
176 let map: serde_norway::Mapping = if yaml.trim().is_empty() {
177 serde_norway::Mapping::new()
178 } else {
179 serde_norway::from_str(&yaml).unwrap_or_default()
180 };
181
182 let mut frontmatter = serde_json::Map::new();
183 let mut type_ = None;
184 let mut summary = None;
185 let mut declared_meta_type = None;
186 let mut name_field = None;
187 let mut title_field = None;
188 let mut created = None;
189 let mut updated = None;
190 for (k, v) in &map {
191 let Some(key) = k.as_str() else { continue };
192 match key {
193 "type" => type_ = scalar_string(v),
194 "summary" => summary = scalar_string(v),
195 "meta-type" => declared_meta_type = scalar_string(v),
196 "name" => name_field = non_empty(scalar_string(v)),
197 "title" => title_field = non_empty(scalar_string(v)),
198 "created" => created = v.as_str().and_then(parse_ts),
199 "updated" => updated = v.as_str().and_then(parse_ts),
200 _ => {}
201 }
202 frontmatter.insert(key.to_string(), yaml_to_json_value(v));
203 }
204
205 let layer = rel
206 .components()
207 .next()
208 .and_then(|c| c.as_os_str().to_str())
209 .and_then(Layer::from_dir_name);
210
211 // Effective meta-type: records only; declared verbatim, absent ⇒ `fact`
212 // (`Frontmatter::effective_meta_type` / the index projection's default).
213 let meta_type = match layer {
214 Some(Layer::Records) => Some(declared_meta_type.unwrap_or_else(|| "fact".to_string())),
215 _ => None,
216 };
217
218 let title = name_field.or(title_field).or_else(|| first_h1(&body));
219
220 // Wiki-link targets over the WHOLE text (frontmatter values + body — the
221 // shared edge extractor handles the split and the fence state), `.md`
222 // appended to the canonical form, deduped in first-appearance order. The
223 // dedup key is the canonical spelling verbatim (byte-portable across
224 // hosts) — the local filesystem's case folding is a resolution concern,
225 // not a dump concern.
226 let mut links = Vec::new();
227 let mut seen = BTreeSet::new();
228 for target in store::extract_edge_targets(&text) {
229 let with_md = format!("{target}.md");
230 if seen.insert(with_md.clone()) {
231 links.push(with_md);
232 }
233 }
234
235 // Positional occurrences over the BODY only (see the field docs). The
236 // shared extractor guarantees these agree with `links` on every fence
237 // decision — one grammar, two views.
238 let link_spans = store::extract_edge_spans(&body);
239
240 Ok(EmittedFile {
241 path: rel.to_string_lossy().replace('\\', "/"),
242 layer,
243 frontmatter,
244 type_,
245 meta_type,
246 title,
247 summary,
248 body,
249 links,
250 link_spans,
251 created,
252 updated,
253 sha256,
254 })
255}
256
257/// A trimmed, non-empty scalar; `None` otherwise. The `name`/`title` fields
258/// only count as a display title when they carry visible text.
259fn non_empty(s: Option<String>) -> Option<String> {
260 s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
261}
262
263/// The body's first ATX `#` (level-1) heading text, fence-aware: a `# ...`
264/// line inside a ``` / `~~~` fenced code block is code, not a title. Heading
265/// recognition and text extraction are the `render` module's CommonMark rules
266/// ([`heading_level`] / [`heading_text`]), so the dump's title agrees with
267/// `dbmd sections` / `dbmd outline` on what a heading is. An empty heading
268/// (`#` alone, `# ##`) yields no title and the scan continues.
269fn first_h1(body: &str) -> Option<String> {
270 let mut fence: Option<(u8, usize)> = None;
271 for line in body.lines() {
272 let content = line.trim_end_matches('\r');
273 if let Some(f) = fence {
274 if store::fence_closes(content, f) {
275 fence = None;
276 }
277 continue;
278 }
279 if let Some(opened) = store::fence_opens(content) {
280 fence = Some(opened);
281 continue;
282 }
283 if heading_level(content) == 1 {
284 let text = heading_text(content, 1);
285 if !text.is_empty() {
286 return Some(text);
287 }
288 }
289 }
290 None
291}
292
293/// Lowercase-hex SHA-256 of `bytes` — hashed over the same in-memory bytes
294/// the projection parsed, so the digest and the emitted content can never
295/// disagree about which file version was read.
296fn sha256_hex(bytes: &[u8]) -> String {
297 let digest = Sha256::digest(bytes);
298 let mut hex = String::with_capacity(64);
299 for b in digest.iter() {
300 let _ = write!(hex, "{b:02x}");
301 }
302 hex
303}
304
305// ─────────────────────────────────────────────────────────────────────────────
306// Tests
307// ─────────────────────────────────────────────────────────────────────────────
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 /// A throwaway store rooted in a tempdir, with a `DB.md` marker.
314 fn store() -> (tempfile::TempDir, Store) {
315 let tmp = tempfile::TempDir::new().expect("tempdir");
316 std::fs::write(
317 tmp.path().join("DB.md"),
318 "---\ntype: db-md\nscope: test\n---\n\n# Test store\n",
319 )
320 .expect("DB.md");
321 let store = Store::open_strict(tmp.path()).expect("open store");
322 (tmp, store)
323 }
324
325 fn seed(root: &Path, rel: &str, contents: &str) {
326 let abs = root.join(rel);
327 std::fs::create_dir_all(abs.parent().unwrap()).unwrap();
328 std::fs::write(abs, contents).unwrap();
329 }
330
331 fn by_path<'a>(emit: &'a Emit, path: &str) -> &'a EmittedFile {
332 emit.files
333 .iter()
334 .find(|f| f.path == path)
335 .unwrap_or_else(|| panic!("no emitted file {path}"))
336 }
337
338 #[test]
339 fn title_prefers_name_then_title_then_first_h1() {
340 let (tmp, store) = store();
341 seed(
342 tmp.path(),
343 "records/contacts/named.md",
344 "---\ntype: contact\nname: Sarah Chen\ntitle: Ignored\nsummary: s\n---\n\n# Also ignored\n",
345 );
346 seed(
347 tmp.path(),
348 "records/contacts/titled.md",
349 "---\ntype: contact\ntitle: The Title\nsummary: s\n---\nbody\n",
350 );
351 seed(
352 tmp.path(),
353 "records/decisions/h1.md",
354 "---\ntype: decision\nsummary: s\n---\n\n```\n# fenced, not a title\n```\n\n# Real Title ##\n",
355 );
356 let emit = compute(&store).expect("emit");
357 assert_eq!(
358 by_path(&emit, "records/contacts/named.md").title.as_deref(),
359 Some("Sarah Chen")
360 );
361 assert_eq!(
362 by_path(&emit, "records/contacts/titled.md")
363 .title
364 .as_deref(),
365 Some("The Title")
366 );
367 // Fence-aware: the fenced `#` line is code; the real H1's closing-hash
368 // run is stripped per the CommonMark ATX rule.
369 assert_eq!(
370 by_path(&emit, "records/decisions/h1.md").title.as_deref(),
371 Some("Real Title")
372 );
373 }
374
375 #[test]
376 fn no_frontmatter_degrades_to_empty_frontmatter_and_whole_body() {
377 let (tmp, store) = store();
378 let text = "Just a plain note, no frontmatter.\n";
379 seed(tmp.path(), "sources/notes/plain.md", text);
380 let emit = compute(&store).expect("emit");
381 let f = by_path(&emit, "sources/notes/plain.md");
382 assert!(f.frontmatter.is_empty());
383 assert_eq!(f.type_, None);
384 assert_eq!(f.body, text);
385 assert_eq!(f.layer, Some(Layer::Sources));
386 }
387
388 #[test]
389 fn meta_type_defaults_for_records_only() {
390 let (tmp, store) = store();
391 seed(
392 tmp.path(),
393 "records/contacts/fact.md",
394 "---\ntype: contact\nsummary: s\n---\nbody\n",
395 );
396 seed(
397 tmp.path(),
398 "records/decisions/conclusion.md",
399 "---\ntype: decision\nmeta-type: conclusion\nsummary: s\n---\nbody\n",
400 );
401 seed(
402 tmp.path(),
403 "sources/notes/n.md",
404 "---\ntype: note\nsummary: s\n---\nbody\n",
405 );
406 let emit = compute(&store).expect("emit");
407 assert_eq!(
408 by_path(&emit, "records/contacts/fact.md")
409 .meta_type
410 .as_deref(),
411 Some("fact")
412 );
413 assert_eq!(
414 by_path(&emit, "records/decisions/conclusion.md")
415 .meta_type
416 .as_deref(),
417 Some("conclusion")
418 );
419 assert_eq!(by_path(&emit, "sources/notes/n.md").meta_type, None);
420 assert_eq!(by_path(&emit, "DB.md").meta_type, None);
421 }
422
423 #[test]
424 fn links_are_normalized_deduped_and_fence_aware() {
425 let (tmp, store) = store();
426 seed(
427 tmp.path(),
428 "sources/notes/n.md",
429 "---\ntype: note\nsummary: s\ncompany: \"[[records/companies/acme]]\"\n---\n\
430 See [[records/contacts/sarah]] and [[records/contacts/sarah.md|Sarah]].\n\
431 Dangling: [[records/ghosts/nobody]].\n\
432 ```\n[[records/contacts/fenced]]\n```\n",
433 );
434 let emit = compute(&store).expect("emit");
435 let f = by_path(&emit, "sources/notes/n.md");
436 // Frontmatter link first (extraction order), then body links in
437 // first-appearance order; the `.md` and bare spellings collapse; the
438 // fenced pseudo-link is code, not an edge; the dangling target stays.
439 assert_eq!(
440 f.links,
441 vec![
442 "records/companies/acme.md".to_string(),
443 "records/contacts/sarah.md".to_string(),
444 "records/ghosts/nobody.md".to_string(),
445 ]
446 );
447 }
448
449 #[test]
450 fn db_md_is_emitted_with_no_layer_and_counts_ride_the_layers() {
451 let (tmp, store) = store();
452 seed(
453 tmp.path(),
454 "sources/notes/n.md",
455 "---\ntype: note\nsummary: s\n---\nbody\n",
456 );
457 seed(
458 tmp.path(),
459 "records/contacts/c.md",
460 "---\ntype: contact\nsummary: s\n---\nbody\n",
461 );
462 // A derived catalog must not be emitted.
463 seed(
464 tmp.path(),
465 "records/contacts/index.md",
466 "# Contacts index\n",
467 );
468 let emit = compute(&store).expect("emit");
469 let paths: Vec<&str> = emit.files.iter().map(|f| f.path.as_str()).collect();
470 assert_eq!(
471 paths,
472 vec!["DB.md", "records/contacts/c.md", "sources/notes/n.md"]
473 );
474 let db = by_path(&emit, "DB.md");
475 assert_eq!(db.layer, None);
476 assert_eq!(db.type_.as_deref(), Some("db-md"));
477 assert_eq!(db.title.as_deref(), Some("Test store"));
478 assert_eq!((emit.files.len(), emit.sources, emit.records), (3, 1, 1));
479 }
480}