Skip to main content

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, 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    /// Frontmatter `created`, when present and RFC3339-parseable.
82    pub created: Option<DateTime<FixedOffset>>,
83    /// Frontmatter `updated`, when present and RFC3339-parseable.
84    pub updated: Option<DateTime<FixedOffset>>,
85    /// Lowercase-hex SHA-256 of the raw file bytes — the exact bytes this
86    /// projection was parsed from, so a consumer can detect drift.
87    pub sha256: String,
88}
89
90/// A computed whole-store dump: every emitted file plus the per-layer tally.
91#[derive(Debug, Clone, PartialEq)]
92pub struct Emit {
93    /// Every emitted file (content files + `DB.md`), sorted by path.
94    pub files: Vec<EmittedFile>,
95    /// How many emitted files live in `sources/`.
96    pub sources: usize,
97    /// How many emitted files live in `records/`.
98    pub records: usize,
99}
100
101/// **SWEEP.** Project the whole store into an [`Emit`]: every content file
102/// via [`Store::walk`] (both layers, derived catalogs skipped) plus the root
103/// `DB.md`, sorted by path. Read-only; errors only on real failures (an
104/// unreadable file, a broken walk) — malformed content degrades per the
105/// module contract, it never aborts the dump.
106pub fn compute(store: &Store) -> crate::Result<Emit> {
107    let mut rels: Vec<PathBuf> = store.walk()?;
108    rels.push(PathBuf::from("DB.md"));
109    rels.sort();
110
111    let mut files = Vec::with_capacity(rels.len());
112    let mut sources = 0usize;
113    let mut records = 0usize;
114    for rel in &rels {
115        let file = emit_file(store, rel)?;
116        match file.layer {
117            Some(Layer::Sources) => sources += 1,
118            Some(Layer::Records) => records += 1,
119            None => {}
120        }
121        files.push(file);
122    }
123    Ok(Emit {
124        files,
125        sources,
126        records,
127    })
128}
129
130/// Project one store-relative file into its [`EmittedFile`].
131fn emit_file(store: &Store, rel: &Path) -> crate::Result<EmittedFile> {
132    let abs = store.abs_path(rel);
133    let bytes = std::fs::read(&abs)?;
134    let sha256 = sha256_hex(&bytes);
135
136    // Decode lossily: `sources/` is preserved verbatim per the SPEC and can
137    // carry non-UTF-8 imports; a stray byte substitutes U+FFFD rather than
138    // aborting the sweep (the same posture as the index projection and the
139    // store's link scan).
140    let text = String::from_utf8_lossy(&bytes);
141
142    // Split the frontmatter block with the canonical splitter (BOM + fence
143    // tolerance identical to every write surface). A file with no block — or
144    // an unterminated one — is still a complete dump member: empty
145    // frontmatter, the whole text as body.
146    let (yaml, body) = match split_frontmatter(&text, &abs) {
147        Ok(parsed) => (parsed.frontmatter_yaml, parsed.body),
148        Err(_) => (String::new(), text.clone().into_owned()),
149    };
150
151    // Parse the frontmatter YAML leniently: a malformed mapping yields an
152    // empty frontmatter (the body still carries the file), mirroring how a
153    // hand-written store degrades. Non-string keys are skipped, matching the
154    // index projection.
155    let map: serde_norway::Mapping = if yaml.trim().is_empty() {
156        serde_norway::Mapping::new()
157    } else {
158        serde_norway::from_str(&yaml).unwrap_or_default()
159    };
160
161    let mut frontmatter = serde_json::Map::new();
162    let mut type_ = None;
163    let mut summary = None;
164    let mut declared_meta_type = None;
165    let mut name_field = None;
166    let mut title_field = None;
167    let mut created = None;
168    let mut updated = None;
169    for (k, v) in &map {
170        let Some(key) = k.as_str() else { continue };
171        match key {
172            "type" => type_ = scalar_string(v),
173            "summary" => summary = scalar_string(v),
174            "meta-type" => declared_meta_type = scalar_string(v),
175            "name" => name_field = non_empty(scalar_string(v)),
176            "title" => title_field = non_empty(scalar_string(v)),
177            "created" => created = v.as_str().and_then(parse_ts),
178            "updated" => updated = v.as_str().and_then(parse_ts),
179            _ => {}
180        }
181        frontmatter.insert(key.to_string(), yaml_to_json_value(v));
182    }
183
184    let layer = rel
185        .components()
186        .next()
187        .and_then(|c| c.as_os_str().to_str())
188        .and_then(Layer::from_dir_name);
189
190    // Effective meta-type: records only; declared verbatim, absent ⇒ `fact`
191    // (`Frontmatter::effective_meta_type` / the index projection's default).
192    let meta_type = match layer {
193        Some(Layer::Records) => Some(declared_meta_type.unwrap_or_else(|| "fact".to_string())),
194        _ => None,
195    };
196
197    let title = name_field.or(title_field).or_else(|| first_h1(&body));
198
199    // Wiki-link targets over the WHOLE text (frontmatter values + body — the
200    // shared edge extractor handles the split and the fence state), `.md`
201    // appended to the canonical form, deduped in first-appearance order. The
202    // dedup key is the canonical spelling verbatim (byte-portable across
203    // hosts) — the local filesystem's case folding is a resolution concern,
204    // not a dump concern.
205    let mut links = Vec::new();
206    let mut seen = BTreeSet::new();
207    for target in store::extract_edge_targets(&text) {
208        let with_md = format!("{target}.md");
209        if seen.insert(with_md.clone()) {
210            links.push(with_md);
211        }
212    }
213
214    Ok(EmittedFile {
215        path: rel.to_string_lossy().replace('\\', "/"),
216        layer,
217        frontmatter,
218        type_,
219        meta_type,
220        title,
221        summary,
222        body,
223        links,
224        created,
225        updated,
226        sha256,
227    })
228}
229
230/// A trimmed, non-empty scalar; `None` otherwise. The `name`/`title` fields
231/// only count as a display title when they carry visible text.
232fn non_empty(s: Option<String>) -> Option<String> {
233    s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
234}
235
236/// The body's first ATX `#` (level-1) heading text, fence-aware: a `# ...`
237/// line inside a ``` / `~~~` fenced code block is code, not a title. Heading
238/// recognition and text extraction are the `render` module's CommonMark rules
239/// ([`heading_level`] / [`heading_text`]), so the dump's title agrees with
240/// `dbmd sections` / `dbmd outline` on what a heading is. An empty heading
241/// (`#` alone, `# ##`) yields no title and the scan continues.
242fn first_h1(body: &str) -> Option<String> {
243    let mut fence: Option<(u8, usize)> = None;
244    for line in body.lines() {
245        let content = line.trim_end_matches('\r');
246        if let Some(f) = fence {
247            if store::fence_closes(content, f) {
248                fence = None;
249            }
250            continue;
251        }
252        if let Some(opened) = store::fence_opens(content) {
253            fence = Some(opened);
254            continue;
255        }
256        if heading_level(content) == 1 {
257            let text = heading_text(content, 1);
258            if !text.is_empty() {
259                return Some(text);
260            }
261        }
262    }
263    None
264}
265
266/// Lowercase-hex SHA-256 of `bytes` — hashed over the same in-memory bytes
267/// the projection parsed, so the digest and the emitted content can never
268/// disagree about which file version was read.
269fn sha256_hex(bytes: &[u8]) -> String {
270    let digest = Sha256::digest(bytes);
271    let mut hex = String::with_capacity(64);
272    for b in digest.iter() {
273        let _ = write!(hex, "{b:02x}");
274    }
275    hex
276}
277
278// ─────────────────────────────────────────────────────────────────────────────
279// Tests
280// ─────────────────────────────────────────────────────────────────────────────
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    /// A throwaway store rooted in a tempdir, with a `DB.md` marker.
287    fn store() -> (tempfile::TempDir, Store) {
288        let tmp = tempfile::TempDir::new().expect("tempdir");
289        std::fs::write(
290            tmp.path().join("DB.md"),
291            "---\ntype: db-md\nscope: test\n---\n\n# Test store\n",
292        )
293        .expect("DB.md");
294        let store = Store::open_strict(tmp.path()).expect("open store");
295        (tmp, store)
296    }
297
298    fn seed(root: &Path, rel: &str, contents: &str) {
299        let abs = root.join(rel);
300        std::fs::create_dir_all(abs.parent().unwrap()).unwrap();
301        std::fs::write(abs, contents).unwrap();
302    }
303
304    fn by_path<'a>(emit: &'a Emit, path: &str) -> &'a EmittedFile {
305        emit.files
306            .iter()
307            .find(|f| f.path == path)
308            .unwrap_or_else(|| panic!("no emitted file {path}"))
309    }
310
311    #[test]
312    fn title_prefers_name_then_title_then_first_h1() {
313        let (tmp, store) = store();
314        seed(
315            tmp.path(),
316            "records/contacts/named.md",
317            "---\ntype: contact\nname: Sarah Chen\ntitle: Ignored\nsummary: s\n---\n\n# Also ignored\n",
318        );
319        seed(
320            tmp.path(),
321            "records/contacts/titled.md",
322            "---\ntype: contact\ntitle: The Title\nsummary: s\n---\nbody\n",
323        );
324        seed(
325            tmp.path(),
326            "records/decisions/h1.md",
327            "---\ntype: decision\nsummary: s\n---\n\n```\n# fenced, not a title\n```\n\n# Real Title ##\n",
328        );
329        let emit = compute(&store).expect("emit");
330        assert_eq!(
331            by_path(&emit, "records/contacts/named.md").title.as_deref(),
332            Some("Sarah Chen")
333        );
334        assert_eq!(
335            by_path(&emit, "records/contacts/titled.md")
336                .title
337                .as_deref(),
338            Some("The Title")
339        );
340        // Fence-aware: the fenced `#` line is code; the real H1's closing-hash
341        // run is stripped per the CommonMark ATX rule.
342        assert_eq!(
343            by_path(&emit, "records/decisions/h1.md").title.as_deref(),
344            Some("Real Title")
345        );
346    }
347
348    #[test]
349    fn no_frontmatter_degrades_to_empty_frontmatter_and_whole_body() {
350        let (tmp, store) = store();
351        let text = "Just a plain note, no frontmatter.\n";
352        seed(tmp.path(), "sources/notes/plain.md", text);
353        let emit = compute(&store).expect("emit");
354        let f = by_path(&emit, "sources/notes/plain.md");
355        assert!(f.frontmatter.is_empty());
356        assert_eq!(f.type_, None);
357        assert_eq!(f.body, text);
358        assert_eq!(f.layer, Some(Layer::Sources));
359    }
360
361    #[test]
362    fn meta_type_defaults_for_records_only() {
363        let (tmp, store) = store();
364        seed(
365            tmp.path(),
366            "records/contacts/fact.md",
367            "---\ntype: contact\nsummary: s\n---\nbody\n",
368        );
369        seed(
370            tmp.path(),
371            "records/decisions/conclusion.md",
372            "---\ntype: decision\nmeta-type: conclusion\nsummary: s\n---\nbody\n",
373        );
374        seed(
375            tmp.path(),
376            "sources/notes/n.md",
377            "---\ntype: note\nsummary: s\n---\nbody\n",
378        );
379        let emit = compute(&store).expect("emit");
380        assert_eq!(
381            by_path(&emit, "records/contacts/fact.md")
382                .meta_type
383                .as_deref(),
384            Some("fact")
385        );
386        assert_eq!(
387            by_path(&emit, "records/decisions/conclusion.md")
388                .meta_type
389                .as_deref(),
390            Some("conclusion")
391        );
392        assert_eq!(by_path(&emit, "sources/notes/n.md").meta_type, None);
393        assert_eq!(by_path(&emit, "DB.md").meta_type, None);
394    }
395
396    #[test]
397    fn links_are_normalized_deduped_and_fence_aware() {
398        let (tmp, store) = store();
399        seed(
400            tmp.path(),
401            "sources/notes/n.md",
402            "---\ntype: note\nsummary: s\ncompany: \"[[records/companies/acme]]\"\n---\n\
403             See [[records/contacts/sarah]] and [[records/contacts/sarah.md|Sarah]].\n\
404             Dangling: [[records/ghosts/nobody]].\n\
405             ```\n[[records/contacts/fenced]]\n```\n",
406        );
407        let emit = compute(&store).expect("emit");
408        let f = by_path(&emit, "sources/notes/n.md");
409        // Frontmatter link first (extraction order), then body links in
410        // first-appearance order; the `.md` and bare spellings collapse; the
411        // fenced pseudo-link is code, not an edge; the dangling target stays.
412        assert_eq!(
413            f.links,
414            vec![
415                "records/companies/acme.md".to_string(),
416                "records/contacts/sarah.md".to_string(),
417                "records/ghosts/nobody.md".to_string(),
418            ]
419        );
420    }
421
422    #[test]
423    fn db_md_is_emitted_with_no_layer_and_counts_ride_the_layers() {
424        let (tmp, store) = store();
425        seed(
426            tmp.path(),
427            "sources/notes/n.md",
428            "---\ntype: note\nsummary: s\n---\nbody\n",
429        );
430        seed(
431            tmp.path(),
432            "records/contacts/c.md",
433            "---\ntype: contact\nsummary: s\n---\nbody\n",
434        );
435        // A derived catalog must not be emitted.
436        seed(
437            tmp.path(),
438            "records/contacts/index.md",
439            "# Contacts index\n",
440        );
441        let emit = compute(&store).expect("emit");
442        let paths: Vec<&str> = emit.files.iter().map(|f| f.path.as_str()).collect();
443        assert_eq!(
444            paths,
445            vec!["DB.md", "records/contacts/c.md", "sources/notes/n.md"]
446        );
447        let db = by_path(&emit, "DB.md");
448        assert_eq!(db.layer, None);
449        assert_eq!(db.type_.as_deref(), Some("db-md"));
450        assert_eq!(db.title.as_deref(), Some("Test store"));
451        assert_eq!((emit.files.len(), emit.sources, emit.records), (3, 1, 1));
452    }
453}