Skip to main content

dbmd_core/
summary.rs

1//! `summary` — the deterministic default-`summary` composer.
2//!
3//! Used by `dbmd fm init` and `dbmd write` when the agent doesn't supply a
4//! `summary`. [`compose_default`] renders the type's `summary_template` (from
5//! the store's `DB.md ## Schemas`) when one is declared, and otherwise falls
6//! back to the body's first non-heading paragraph. No type carries a built-in
7//! template — the template, like the schema, is the store's to declare.
8//!
9//! Contract: **deterministic** (same `(type, frontmatter, body)` → same
10//! string), **single-line** (newlines collapsed to spaces), and **capped at 200
11//! chars** (the SPEC readability bound). The tool generates a deterministic
12//! floor; the agent provides the ceiling via `dbmd fm set <file> summary='…'`.
13
14use serde_norway::Value;
15
16use crate::parser::Frontmatter;
17use crate::store::Store;
18
19/// The SPEC's `summary` length bound, in characters.
20pub const MAX_SUMMARY_LEN: usize = 200;
21
22/// Compose a deterministic default `summary` for a file from its `type`,
23/// frontmatter, and body. If the store's `## Schemas` declares a
24/// `summary_template` for the type, it is rendered with `{field}` interpolation;
25/// otherwise the default is the body's first non-heading paragraph. The result
26/// is always single-line and ≤ [`MAX_SUMMARY_LEN`] chars.
27///
28/// The tool generates a deterministic floor; the agent provides the ceiling via
29/// `dbmd fm set <file> summary='…'`.
30pub fn compose_default(
31    store: &Store,
32    type_: &str,
33    frontmatter: &Frontmatter,
34    body: &str,
35) -> crate::Result<String> {
36    let composed = match store
37        .config
38        .schemas
39        .get(type_)
40        .and_then(|s| s.summary_template.as_deref())
41    {
42        Some(template) => render_template(template, frontmatter),
43        None => compose_from_body(body),
44    };
45    Ok(normalize(&composed))
46}
47
48/// Render a `summary_template` — substitute each `{field}` with the file's
49/// frontmatter value for `field`. A scalar (incl. a wiki-link, reduced to its
50/// display-or-leaf form) renders inline; a list renders comma-joined; an
51/// absent/empty field renders empty. An unmatched `{` is emitted verbatim
52/// (templates are simple field-interpolation floors, not a templating language).
53fn render_template(template: &str, fm: &Frontmatter) -> String {
54    let mut out = String::with_capacity(template.len());
55    let mut rest = template;
56    while let Some(open) = rest.find('{') {
57        out.push_str(&rest[..open]);
58        let after = &rest[open + 1..];
59        let close = after.find('}');
60        let next_open = after.find('{');
61        match close {
62            // A clean `{field}` — no nested `{` before the closing `}`.
63            Some(c) if next_open.is_none_or(|n| n > c) => {
64                let key = after[..c].trim();
65                if let Some(scalar) = field_text(fm, key) {
66                    out.push_str(&scalar);
67                } else {
68                    let list = list_field_texts(fm, key);
69                    if !list.is_empty() {
70                        out.push_str(&list.join(", "));
71                    }
72                }
73                rest = &after[c + 1..];
74            }
75            // A stray `{` (no `}`, or another `{` first) — emit it verbatim.
76            _ => {
77                out.push('{');
78                rest = after;
79            }
80        }
81    }
82    out.push_str(rest);
83    out
84}
85
86/// The body fallback: the file's first non-heading paragraph, truncated to
87/// [`MAX_SUMMARY_LEN`] chars (the truncation is applied by [`normalize`]).
88pub fn compose_from_body(body: &str) -> String {
89    first_paragraph(body).unwrap_or_default()
90}
91
92/// Normalize any candidate summary to the contract: collapse runs of
93/// whitespace (including newlines) to single spaces, trim, and truncate to
94/// [`MAX_SUMMARY_LEN`] **chars** (never splitting a UTF-8 codepoint). Every
95/// composer runs its output through this.
96pub fn normalize(candidate: &str) -> String {
97    // `split_whitespace` collapses any run of ASCII/Unicode whitespace
98    // (spaces, tabs, newlines) and trims leading/trailing — giving the
99    // single-line, trimmed form in one pass.
100    let collapsed = candidate.split_whitespace().collect::<Vec<_>>().join(" ");
101    truncate_chars(&collapsed, MAX_SUMMARY_LEN)
102}
103
104// ── Internal helpers ────────────────────────────────────────────────────────
105
106/// Truncate to at most `max` Unicode scalar values, on a char boundary.
107fn truncate_chars(s: &str, max: usize) -> String {
108    match s.char_indices().nth(max) {
109        Some((byte_idx, _)) => s[..byte_idx].to_string(),
110        None => s.to_string(),
111    }
112}
113
114/// Read a frontmatter field's raw YAML value, checking the universal typed
115/// fields first and then [`Frontmatter::extra`] — mirroring the documented
116/// contract of `Frontmatter::get` but reading the struct directly so this
117/// module never depends on another module's body.
118fn field_value(fm: &Frontmatter, key: &str) -> Option<Value> {
119    match key {
120        "type" => fm.type_.clone().map(Value::String),
121        "id" => fm.id.clone().map(Value::String),
122        "summary" => fm.summary.clone().map(Value::String),
123        "status" => fm.status.clone().map(Value::String),
124        // Typed universal fields a `summary_template` may legitimately
125        // interpolate. `created`/`updated` render as their canonical RFC3339
126        // string; `tags` as a sequence (which `list_field_texts` comma-joins).
127        // Without these arms, `{created}` / `{updated}` / `{tags}` would
128        // silently render empty even when the value is present.
129        "created" => fm.created.map(|t| Value::String(t.to_rfc3339())),
130        "updated" => fm.updated.map(|t| Value::String(t.to_rfc3339())),
131        "tags" => {
132            if fm.tags.is_empty() {
133                None
134            } else {
135                Some(Value::Sequence(
136                    fm.tags.iter().cloned().map(Value::String).collect(),
137                ))
138            }
139        }
140        _ => fm.extra.get(key).cloned(),
141    }
142}
143
144/// Read a single frontmatter field as a rendered plain-text scalar, or `None`
145/// when the field is absent, null, or renders empty. Wiki-link-valued fields
146/// are reduced to their display-or-leaf human form (never the raw `[[...]]`).
147fn field_text(fm: &Frontmatter, key: &str) -> Option<String> {
148    let v = field_value(fm, key)?;
149    let rendered = render_scalar(&v)?;
150    let trimmed = rendered.trim();
151    if trimmed.is_empty() {
152        None
153    } else {
154        Some(trimmed.to_string())
155    }
156}
157
158/// Read a list-valued frontmatter field as rendered plain-text items. A scalar
159/// (non-sequence) value is treated as a single-item list. Wiki-link items are
160/// reduced to their display-or-leaf form. Empty / null items are dropped.
161fn list_field_texts(fm: &Frontmatter, key: &str) -> Vec<String> {
162    let Some(v) = field_value(fm, key) else {
163        return Vec::new();
164    };
165    match v {
166        Value::Sequence(items) => items
167            .iter()
168            .filter_map(|item| {
169                let r = render_scalar(item)?;
170                let t = r.trim();
171                if t.is_empty() {
172                    None
173                } else {
174                    Some(t.to_string())
175                }
176            })
177            .collect(),
178        other => render_scalar(&other)
179            .map(|r| r.trim().to_string())
180            .filter(|t| !t.is_empty())
181            .into_iter()
182            .collect(),
183    }
184}
185
186/// Render a single YAML scalar to plain display text. Strings (including YAML
187/// date scalars, which deserialize as strings) are returned as-is but with any
188/// wiki-link reduced to display-or-leaf; numbers and bools stringify
189/// canonically; null / mapping / nested-sequence yield `None`.
190fn render_scalar(v: &Value) -> Option<String> {
191    match v {
192        Value::String(s) => Some(reduce_wiki_link(s)),
193        Value::Sequence(_) => render_unquoted_wiki_link(v),
194        Value::Bool(b) => Some(b.to_string()),
195        Value::Number(n) => {
196            // Render integers without a trailing `.0`; keep the natural form
197            // otherwise. `Number`'s Display already does this.
198            Some(n.to_string())
199        }
200        Value::Null | Value::Mapping(_) | Value::Tagged(_) => None,
201    }
202}
203
204/// YAML parses an unquoted wiki-link scalar (`company: [[records/x]]`) as a
205/// nested sequence, not a string. Recognize that shape so summary templates
206/// render it exactly like the quoted scalar form.
207fn render_unquoted_wiki_link(v: &Value) -> Option<String> {
208    let Value::Sequence(outer) = v else {
209        return None;
210    };
211    if outer.len() != 1 {
212        return None;
213    }
214    let Value::Sequence(inner) = &outer[0] else {
215        return None;
216    };
217    let [Value::String(target)] = inner.as_slice() else {
218        return None;
219    };
220    Some(reduce_wiki_link(&format!("[[{target}]]")))
221}
222
223/// If `s` is a wiki-link (`[[target]]` or `[[target|display]]`), reduce it to
224/// the human form: the `display` override when present, else the last path
225/// segment of the target (with any `.md` suffix dropped). Non-link strings are
226/// returned unchanged.
227fn reduce_wiki_link(s: &str) -> String {
228    let trimmed = s.trim();
229    let inner = trimmed
230        .strip_prefix("[[")
231        .and_then(|rest| rest.strip_suffix("]]"));
232    let Some(inner) = inner else {
233        return s.to_string();
234    };
235    // `target|display` → prefer display.
236    let (target, display) = match inner.split_once('|') {
237        Some((t, d)) => (t, Some(d)),
238        None => (inner, None),
239    };
240    if let Some(d) = display {
241        let d = d.trim();
242        if !d.is_empty() {
243            return d.to_string();
244        }
245    }
246    let leaf = target.trim().rsplit('/').next().unwrap_or(target).trim();
247    leaf.strip_suffix(".md").unwrap_or(leaf).to_string()
248}
249
250/// The first non-heading, non-blank paragraph of the body: consecutive
251/// non-heading text lines joined by a space, starting at the first such line.
252/// Heading lines (`#…`) are skipped. `None` when the body has no prose.
253fn first_paragraph(body: &str) -> Option<String> {
254    let mut collected: Vec<&str> = Vec::new();
255    for line in body.lines() {
256        let t = line.trim();
257        if t.is_empty() {
258            if collected.is_empty() {
259                // Still searching for the start of the first paragraph.
260                continue;
261            }
262            // Blank line ends the first paragraph.
263            break;
264        }
265        if t.starts_with('#') {
266            if collected.is_empty() {
267                // A heading before any prose — skip it.
268                continue;
269            }
270            // A heading terminates the running paragraph.
271            break;
272        }
273        collected.push(t);
274    }
275    if collected.is_empty() {
276        None
277    } else {
278        Some(collected.join(" "))
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use crate::parser::{Config, Schema};
286    use std::fs;
287    use tempfile::TempDir;
288
289    // ── Fixtures ─────────────────────────────────────────────────────────────
290
291    /// A temp store with a `DB.md` marker and the given parsed config, built
292    /// directly (not via `Store::open`) so these tests exercise the `summary`
293    /// code under test, not store-open plumbing.
294    fn store_with(config: Config) -> (TempDir, Store) {
295        let tmp = TempDir::new().expect("tempdir");
296        let root = tmp.path().to_path_buf();
297        fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").expect("write DB.md");
298        let store = Store { root, config };
299        (tmp, store)
300    }
301
302    /// A store whose `## Schemas` declares a `summary_template` for `type_`.
303    fn store_with_template(type_: &str, template: &str) -> (TempDir, Store) {
304        let mut config = Config::default();
305        config.schemas.insert(
306            type_.to_string(),
307            Schema {
308                summary_template: Some(template.to_string()),
309                ..Schema::default()
310            },
311        );
312        store_with(config)
313    }
314
315    /// Build a [`Frontmatter`] from a YAML map literal so tests state intent in
316    /// YAML, not by hand-poking `extra`. This goes through `serde_norway` exactly
317    /// like a real file's frontmatter would.
318    fn fm(yaml: &str) -> Frontmatter {
319        let value: Value = serde_norway::from_str(yaml).expect("test yaml parses");
320        let mapping = value.as_mapping().expect("test yaml is a mapping").clone();
321        let mut f = Frontmatter::default();
322        for (k, v) in mapping {
323            let key = k.as_str().expect("string key").to_string();
324            match key.as_str() {
325                "type" => f.type_ = v.as_str().map(str::to_string),
326                "summary" => f.summary = v.as_str().map(str::to_string),
327                "id" => f.id = v.as_str().map(str::to_string),
328                "status" => f.status = v.as_str().map(str::to_string),
329                // Route the typed universal fields to their struct slots (NOT
330                // `extra`) so tests exercise the real `field_value` arms for
331                // `{tags}` / `{created}` / `{updated}` instead of masking them.
332                "tags" => {
333                    if let Value::Sequence(items) = &v {
334                        f.tags = items
335                            .iter()
336                            .filter_map(|i| i.as_str().map(str::to_string))
337                            .collect();
338                    }
339                }
340                "created" => {
341                    f.created = v
342                        .as_str()
343                        .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
344                }
345                "updated" => {
346                    f.updated = v
347                        .as_str()
348                        .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
349                }
350                _ => {
351                    f.extra.insert(key, v);
352                }
353            }
354        }
355        f
356    }
357
358    // ── normalize ────────────────────────────────────────────────────────────
359
360    #[test]
361    fn normalize_collapses_newlines_and_runs_to_single_spaces() {
362        let got = normalize("first line\nsecond\t\tline   third");
363        assert_eq!(got, "first line second line third");
364    }
365
366    #[test]
367    fn normalize_trims_surrounding_whitespace() {
368        assert_eq!(normalize("   padded value \n"), "padded value");
369    }
370
371    #[test]
372    fn normalize_caps_at_200_chars_on_char_boundary() {
373        // 250 multi-byte chars; the cap is by char, not byte.
374        let input = "é".repeat(250);
375        let got = normalize(&input);
376        assert_eq!(got.chars().count(), MAX_SUMMARY_LEN);
377        // Truncation must not corrupt UTF-8 (would panic on slice otherwise).
378        assert_eq!(got, "é".repeat(MAX_SUMMARY_LEN));
379    }
380
381    #[test]
382    fn normalize_leaves_short_strings_untouched() {
383        assert_eq!(normalize("short"), "short");
384    }
385
386    // ── summary_template rendering ───────────────────────────────────────────
387
388    #[test]
389    fn template_interpolates_scalar_fields() {
390        let (_t, store) =
391            store_with_template("contact", "{role} at {company} (last_touch: {last_touch})");
392        let f = fm("type: contact\n\
393             role: Director of Operations\n\
394             company: \"[[records/companies/northstar]]\"\n\
395             last_touch: 2026-05-22\n");
396        // A wiki-link value reduces to its leaf; the template is the store's, not
397        // a built-in — that is the whole point.
398        assert_eq!(
399            compose_default(&store, "contact", &f, "ignored body").unwrap(),
400            "Director of Operations at northstar (last_touch: 2026-05-22)"
401        );
402    }
403
404    #[test]
405    fn template_interpolates_unquoted_scalar_wiki_link_fields() {
406        let (_t, store) = store_with_template("contact", "{role} at {company}");
407        let f = fm("type: contact\n\
408             role: Director\n\
409             company: [[records/companies/northstar]]\n");
410        assert_eq!(
411            compose_default(&store, "contact", &f, "").unwrap(),
412            "Director at northstar"
413        );
414    }
415
416    #[test]
417    fn template_drops_absent_fields_to_empty() {
418        let (_t, store) = store_with_template("contact", "{role} at {company}");
419        let f = fm("type: contact\nrole: Advisor\n");
420        // `{company}` absent → empty; `normalize` trims the trailing run.
421        assert_eq!(
422            compose_default(&store, "contact", &f, "").unwrap(),
423            "Advisor at"
424        );
425    }
426
427    #[test]
428    fn template_joins_list_fields_comma_separated() {
429        let (_t, store) = store_with_template("meeting", "{date}: {attendees}");
430        let f = fm("type: meeting\n\
431             date: 2026-05-10\n\
432             attendees:\n\
433             \x20 - \"[[records/contacts/alice]]\"\n\
434             \x20 - \"[[records/contacts/bob]]\"\n");
435        assert_eq!(
436            compose_default(&store, "meeting", &f, "").unwrap(),
437            "2026-05-10: alice, bob"
438        );
439    }
440
441    #[test]
442    fn template_interpolates_typed_tags_created_updated() {
443        // Regression: `field_value` skipped the typed `tags` / `created` /
444        // `updated` fields, so these `{…}` placeholders silently rendered empty
445        // even when the values were present.
446        let (_t, store) = store_with_template("note", "{tags} | {created}");
447        let f = fm("type: note\ntags: [urgent, q3]\ncreated: \"2026-05-01T00:00:00Z\"\n");
448        assert_eq!(
449            compose_default(&store, "note", &f, "").unwrap(),
450            // {tags} comma-joins; {created} renders canonical RFC3339 (offset form).
451            "urgent, q3 | 2026-05-01T00:00:00+00:00"
452        );
453    }
454
455    #[test]
456    fn template_joins_unquoted_block_wiki_link_list_fields() {
457        let (_t, store) = store_with_template("meeting", "{attendees}");
458        let f = fm("type: meeting\n\
459             attendees:\n\
460             \x20 - [[records/contacts/alice]]\n\
461             \x20 - [[records/contacts/bob]]\n");
462        assert_eq!(
463            compose_default(&store, "meeting", &f, "").unwrap(),
464            "alice, bob"
465        );
466    }
467
468    #[test]
469    fn template_emits_stray_brace_verbatim() {
470        let (_t, store) = store_with_template("note", "literal { brace {title}");
471        let f = fm("type: note\ntitle: Hello\n");
472        assert_eq!(
473            compose_default(&store, "note", &f, "").unwrap(),
474            "literal { brace Hello"
475        );
476    }
477
478    #[test]
479    fn template_is_deterministic_across_calls() {
480        let (_t, store) = store_with_template("contact", "{role} ({last_touch})");
481        let f = fm("type: contact\nrole: Ops Lead\nlast_touch: 2026-05-22\n");
482        let a = compose_default(&store, "contact", &f, "body").unwrap();
483        let b = compose_default(&store, "contact", &f, "body").unwrap();
484        assert_eq!(a, b);
485        assert_eq!(a, "Ops Lead (2026-05-22)");
486    }
487
488    #[test]
489    fn no_schema_for_type_falls_back_to_body() {
490        // Only `contact` has a template; `note` falls back to the body paragraph,
491        // proving no type carries a built-in template.
492        let (_t, store) = store_with_template("contact", "{role}");
493        let f = fm("type: note\n");
494        assert_eq!(
495            compose_default(&store, "note", &f, "Body sentence here.").unwrap(),
496            "Body sentence here."
497        );
498    }
499
500    // ── unknown / custom + body extraction ─────────────────────────────────────
501
502    #[test]
503    fn unknown_type_uses_first_non_heading_paragraph() {
504        let (_t, store) = store_with(Config::default());
505        let f = fm("type: proposal\n");
506        let body = "# Title\n\nThis proposal covers the Q3 roadmap.\n\nSecond paragraph.\n";
507        let got = compose_default(&store, "proposal", &f, body).unwrap();
508        assert_eq!(got, "This proposal covers the Q3 roadmap.");
509    }
510
511    #[test]
512    fn first_paragraph_joins_wrapped_lines_until_blank() {
513        let body = "Line one\nline two\n\nlater paragraph";
514        assert_eq!(first_paragraph(body).as_deref(), Some("Line one line two"));
515    }
516
517    #[test]
518    fn first_paragraph_none_for_heading_only_body() {
519        assert_eq!(first_paragraph("# Just a heading\n## And another\n"), None);
520    }
521
522    #[test]
523    fn unknown_type_long_paragraph_is_capped_at_200() {
524        let (_t, store) = store_with(Config::default());
525        let f = fm("type: note\n");
526        let long = "word ".repeat(100); // 500 chars
527        let got = compose_default(&store, "note", &f, &long).unwrap();
528        assert!(got.chars().count() <= MAX_SUMMARY_LEN);
529        assert!(got.chars().count() >= MAX_SUMMARY_LEN - 5); // close to the cap
530    }
531
532    // ── wiki-link reduction ────────────────────────────────────────────────────
533
534    #[test]
535    fn reduce_wiki_link_takes_leaf_segment() {
536        assert_eq!(
537            reduce_wiki_link("[[records/companies/northstar]]"),
538            "northstar"
539        );
540    }
541
542    #[test]
543    fn reduce_wiki_link_prefers_display() {
544        assert_eq!(
545            reduce_wiki_link("[[records/companies/x|Northstar Inc]]"),
546            "Northstar Inc"
547        );
548    }
549
550    #[test]
551    fn reduce_wiki_link_strips_md_extension() {
552        assert_eq!(reduce_wiki_link("[[records/companies/x.md]]"), "x");
553    }
554
555    #[test]
556    fn reduce_wiki_link_passes_through_plain_text() {
557        assert_eq!(reduce_wiki_link("just a vendor name"), "just a vendor name");
558    }
559}