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`] dispatches on `type` to the per-type
5//! composers, matching SPEC.md's "Deterministic defaults per type" table.
6//!
7//! Contract every composer upholds: **deterministic** (same
8//! `(type, frontmatter, body)` → same string), **single-line** (newlines
9//! collapsed to spaces), and **capped at 200 chars** (the SPEC readability
10//! bound). The tool generates a deterministic floor; the agent provides the
11//! ceiling via `dbmd fm set <file> summary='...'`.
12
13use serde_yml::Value;
14
15use crate::parser::Frontmatter;
16use crate::store::Store;
17
18/// The SPEC's `summary` length bound, in characters.
19pub const MAX_SUMMARY_LEN: usize = 200;
20
21/// Compose a deterministic default `summary` for a file from its `type`,
22/// frontmatter, and body. Dispatches to the per-type composer; unknown/custom
23/// types fall back to the first non-heading paragraph of the body. The result
24/// is always single-line and ≤ [`MAX_SUMMARY_LEN`] chars.
25///
26/// `store` is passed because some composers resolve a wiki-link to read a
27/// related file's field (e.g. `contact` resolves `company` to the company's
28/// `name`).
29pub fn compose_default(
30    store: &Store,
31    type_: &str,
32    frontmatter: &Frontmatter,
33    body: &str,
34) -> crate::Result<String> {
35    let composed = match type_ {
36        "contact" => compose_contact(store, frontmatter)?,
37        "company" => compose_company(frontmatter),
38        "expense" => compose_expense(frontmatter),
39        "meeting" => compose_meeting(frontmatter),
40        "decision" => compose_decision(frontmatter, body),
41        "invoice" => compose_invoice(frontmatter),
42        "email" => compose_email(frontmatter),
43        "transcript" => compose_transcript(frontmatter),
44        "pdf-source" => compose_pdf_source(frontmatter),
45        "wiki-page" => compose_wiki_page(frontmatter, body),
46        // Unknown / custom types fall back to the body.
47        _ => compose_from_body(body),
48    };
49    Ok(normalize(&composed))
50}
51
52/// `contact` → `<role> at <company-name> (last_touch: <date>)`. Resolves the
53/// `company` wiki-link to read the company's `name`.
54///
55/// Each segment degrades gracefully when its field is absent: with no `role`
56/// the line opens `Contact`; with no resolvable company the ` at <company>`
57/// segment is dropped; with no `last_touch` the trailing ` (last_touch: …)`
58/// parenthetical is dropped. The company name is the resolved company file's
59/// `name` field; if the link can't be resolved (missing/unreadable target, or
60/// the target has no `name`) the link's own display-or-leaf text is used.
61pub fn compose_contact(store: &Store, fm: &Frontmatter) -> crate::Result<String> {
62    let role = field_text(fm, "role");
63    let company = resolve_company_name(store, fm);
64    let last_touch = field_text(fm, "last_touch");
65
66    let mut out = match role {
67        Some(r) => r,
68        None => "Contact".to_string(),
69    };
70    if let Some(c) = company {
71        out.push_str(" at ");
72        out.push_str(&c);
73    }
74    if let Some(d) = last_touch {
75        out.push_str(" (last_touch: ");
76        out.push_str(&d);
77        out.push(')');
78    }
79    Ok(out)
80}
81
82/// `company` → `<relationship>; <industry>`.
83///
84/// When only one of the two fields is present, the lone value is returned
85/// without the `; ` separator; when both are absent the result is empty (the
86/// caller's `normalize` yields `""`, which validate then flags as
87/// `SUMMARY_EMPTY` so the agent supplies a real one).
88pub fn compose_company(fm: &Frontmatter) -> String {
89    join_present(
90        "; ",
91        [field_text(fm, "relationship"), field_text(fm, "industry")],
92    )
93}
94
95/// `expense` → `<date> — <amount> <currency> — <vendor>`.
96///
97/// `<amount> <currency>` collapse to whichever is present; missing top-level
98/// segments (date / amount-currency / vendor) drop out of the ` — `-joined
99/// line so a partial record still reads cleanly.
100pub fn compose_expense(fm: &Frontmatter) -> String {
101    let money = join_present(" ", [field_text(fm, "amount"), field_text(fm, "currency")]);
102    let money = if money.is_empty() { None } else { Some(money) };
103    join_present(
104        " — ",
105        [field_text(fm, "date"), money, field_text(fm, "vendor")],
106    )
107}
108
109/// `meeting` → `<date> — <first 3 attendees> (+N more)` when more than three
110/// attendees are present.
111///
112/// Attendees render as their wiki-link display-or-leaf names, comma-joined;
113/// the `(+N more)` suffix appears only when `N > 0`.
114pub fn compose_meeting(fm: &Frontmatter) -> String {
115    let attendees = list_field_texts(fm, "attendees");
116    let shown: Vec<String> = attendees.iter().take(3).cloned().collect();
117    let extra = attendees.len().saturating_sub(shown.len());
118
119    let people = if shown.is_empty() {
120        None
121    } else {
122        let mut s = shown.join(", ");
123        if extra > 0 {
124            s.push_str(&format!(" (+{extra} more)"));
125        }
126        Some(s)
127    };
128
129    join_present(" — ", [field_text(fm, "date"), people])
130}
131
132/// `decision` → `<decided_by>: <title-or-first-heading>`.
133///
134/// The title is the first `#` heading text of the body (any depth), falling
135/// back to the first non-heading paragraph when the body has no heading.
136pub fn compose_decision(fm: &Frontmatter, body: &str) -> String {
137    let title = first_heading(body).or_else(|| first_paragraph(body));
138    match (field_text(fm, "decided_by"), title) {
139        (Some(who), Some(t)) => format!("{who}: {t}"),
140        (Some(who), None) => who,
141        (None, Some(t)) => t,
142        (None, None) => String::new(),
143    }
144}
145
146/// `invoice` → `<vendor> — <amount> — <status>`.
147pub fn compose_invoice(fm: &Frontmatter) -> String {
148    join_present(
149        " — ",
150        [
151            field_text(fm, "vendor"),
152            field_text(fm, "amount"),
153            field_text(fm, "status"),
154        ],
155    )
156}
157
158/// `email` → `<from> → <to> — <subject>`.
159///
160/// `to` may be a list; it renders comma-joined. The `<from> → <to>` head and
161/// the `<subject>` tail each drop out when empty.
162pub fn compose_email(fm: &Frontmatter) -> String {
163    let to = {
164        let list = list_field_texts(fm, "to");
165        if list.is_empty() {
166            None
167        } else {
168            Some(list.join(", "))
169        }
170    };
171    let route = match (field_text(fm, "from"), to) {
172        (Some(f), Some(t)) => Some(format!("{f} → {t}")),
173        (Some(f), None) => Some(f),
174        (None, Some(t)) => Some(format!("→ {t}")),
175        (None, None) => None,
176    };
177    join_present(" — ", [route, field_text(fm, "subject")])
178}
179
180/// `transcript` → `<recorded_at> — <attendees>`.
181pub fn compose_transcript(fm: &Frontmatter) -> String {
182    let attendees = {
183        let list = list_field_texts(fm, "attendees");
184        if list.is_empty() {
185            None
186        } else {
187            Some(list.join(", "))
188        }
189    };
190    join_present(" — ", [field_text(fm, "recorded_at"), attendees])
191}
192
193/// `pdf-source` → `<doc_type> from <received_from>`.
194pub fn compose_pdf_source(fm: &Frontmatter) -> String {
195    match (field_text(fm, "doc_type"), field_text(fm, "received_from")) {
196        (Some(dt), Some(rf)) => format!("{dt} from {rf}"),
197        (Some(dt), None) => dt,
198        (None, Some(rf)) => format!("from {rf}"),
199        (None, None) => String::new(),
200    }
201}
202
203/// `wiki-page` → the `topic` frontmatter field, else the file's first
204/// non-heading paragraph.
205pub fn compose_wiki_page(fm: &Frontmatter, body: &str) -> String {
206    field_text(fm, "topic").unwrap_or_else(|| compose_from_body(body))
207}
208
209/// Unknown / custom types → the file's first non-heading paragraph, truncated
210/// to [`MAX_SUMMARY_LEN`] chars (the truncation is applied by [`normalize`]).
211pub fn compose_from_body(body: &str) -> String {
212    first_paragraph(body).unwrap_or_default()
213}
214
215/// Normalize any candidate summary to the contract: collapse runs of
216/// whitespace (including newlines) to single spaces, trim, and truncate to
217/// [`MAX_SUMMARY_LEN`] **chars** (never splitting a UTF-8 codepoint). Every
218/// composer runs its output through this.
219pub fn normalize(candidate: &str) -> String {
220    // `split_whitespace` collapses any run of ASCII/Unicode whitespace
221    // (spaces, tabs, newlines) and trims leading/trailing — giving the
222    // single-line, trimmed form in one pass.
223    let collapsed = candidate.split_whitespace().collect::<Vec<_>>().join(" ");
224    truncate_chars(&collapsed, MAX_SUMMARY_LEN)
225}
226
227// ── Internal helpers ────────────────────────────────────────────────────────
228
229/// Truncate to at most `max` Unicode scalar values, on a char boundary.
230fn truncate_chars(s: &str, max: usize) -> String {
231    match s.char_indices().nth(max) {
232        Some((byte_idx, _)) => s[..byte_idx].to_string(),
233        None => s.to_string(),
234    }
235}
236
237/// Read a frontmatter field's raw YAML value, checking the universal typed
238/// fields first and then [`Frontmatter::extra`] — mirroring the documented
239/// contract of `Frontmatter::get` but reading the struct directly so this
240/// module never depends on another module's body.
241fn field_value(fm: &Frontmatter, key: &str) -> Option<Value> {
242    match key {
243        "type" => fm.type_.clone().map(Value::String),
244        "id" => fm.id.clone().map(Value::String),
245        "summary" => fm.summary.clone().map(Value::String),
246        "status" => fm.status.clone().map(Value::String),
247        // `created` / `updated` are typed timestamps; no composer reads them as
248        // a field, so we don't reconstruct a Value for them here.
249        _ => fm.extra.get(key).cloned(),
250    }
251}
252
253/// Read a single frontmatter field as a rendered plain-text scalar, or `None`
254/// when the field is absent, null, or renders empty. Wiki-link-valued fields
255/// are reduced to their display-or-leaf human form (never the raw `[[...]]`).
256fn field_text(fm: &Frontmatter, key: &str) -> Option<String> {
257    let v = field_value(fm, key)?;
258    let rendered = render_scalar(&v)?;
259    let trimmed = rendered.trim();
260    if trimmed.is_empty() {
261        None
262    } else {
263        Some(trimmed.to_string())
264    }
265}
266
267/// Read a list-valued frontmatter field as rendered plain-text items. A scalar
268/// (non-sequence) value is treated as a single-item list. Wiki-link items are
269/// reduced to their display-or-leaf form. Empty / null items are dropped.
270fn list_field_texts(fm: &Frontmatter, key: &str) -> Vec<String> {
271    let Some(v) = field_value(fm, key) else {
272        return Vec::new();
273    };
274    match v {
275        Value::Sequence(items) => items
276            .iter()
277            .filter_map(|item| {
278                let r = render_scalar(item)?;
279                let t = r.trim();
280                if t.is_empty() {
281                    None
282                } else {
283                    Some(t.to_string())
284                }
285            })
286            .collect(),
287        other => render_scalar(&other)
288            .map(|r| r.trim().to_string())
289            .filter(|t| !t.is_empty())
290            .into_iter()
291            .collect(),
292    }
293}
294
295/// Render a single YAML scalar to plain display text. Strings (including YAML
296/// date scalars, which deserialize as strings) are returned as-is but with any
297/// wiki-link reduced to display-or-leaf; numbers and bools stringify
298/// canonically; null / mapping / nested-sequence yield `None`.
299fn render_scalar(v: &Value) -> Option<String> {
300    match v {
301        Value::String(s) => Some(reduce_wiki_link(s)),
302        Value::Bool(b) => Some(b.to_string()),
303        Value::Number(n) => {
304            // Render integers without a trailing `.0`; keep the natural form
305            // otherwise. `Number`'s Display already does this.
306            Some(n.to_string())
307        }
308        Value::Null | Value::Sequence(_) | Value::Mapping(_) | Value::Tagged(_) => None,
309    }
310}
311
312/// If `s` is a wiki-link (`[[target]]` or `[[target|display]]`), reduce it to
313/// the human form: the `display` override when present, else the last path
314/// segment of the target (with any `.md` suffix dropped). Non-link strings are
315/// returned unchanged.
316fn reduce_wiki_link(s: &str) -> String {
317    let trimmed = s.trim();
318    let inner = trimmed
319        .strip_prefix("[[")
320        .and_then(|rest| rest.strip_suffix("]]"));
321    let Some(inner) = inner else {
322        return s.to_string();
323    };
324    // `target|display` → prefer display.
325    let (target, display) = match inner.split_once('|') {
326        Some((t, d)) => (t, Some(d)),
327        None => (inner, None),
328    };
329    if let Some(d) = display {
330        let d = d.trim();
331        if !d.is_empty() {
332            return d.to_string();
333        }
334    }
335    let leaf = target.trim().rsplit('/').next().unwrap_or(target).trim();
336    leaf.strip_suffix(".md").unwrap_or(leaf).to_string()
337}
338
339/// Join the present (`Some`, non-empty) values with `sep`, dropping the absent
340/// ones. Returns `""` when none are present.
341fn join_present<const N: usize>(sep: &str, parts: [Option<String>; N]) -> String {
342    parts
343        .into_iter()
344        .flatten()
345        .filter(|p| !p.is_empty())
346        .collect::<Vec<_>>()
347        .join(sep)
348}
349
350/// The first `#`-prefixed heading's text (any depth), stripped of leading `#`s
351/// and surrounding whitespace; `None` if the body has no heading.
352fn first_heading(body: &str) -> Option<String> {
353    for line in body.lines() {
354        let t = line.trim();
355        if let Some(rest) = t.strip_prefix('#') {
356            // Strip the remaining `#`s of a deeper heading, then whitespace.
357            let text = rest.trim_start_matches('#').trim();
358            if !text.is_empty() {
359                return Some(text.to_string());
360            }
361        }
362    }
363    None
364}
365
366/// The first non-heading, non-blank paragraph of the body: consecutive
367/// non-heading text lines joined by a space, starting at the first such line.
368/// Heading lines (`#…`) are skipped. `None` when the body has no prose.
369fn first_paragraph(body: &str) -> Option<String> {
370    let mut collected: Vec<&str> = Vec::new();
371    for line in body.lines() {
372        let t = line.trim();
373        if t.is_empty() {
374            if collected.is_empty() {
375                // Still searching for the start of the first paragraph.
376                continue;
377            }
378            // Blank line ends the first paragraph.
379            break;
380        }
381        if t.starts_with('#') {
382            if collected.is_empty() {
383                // A heading before any prose — skip it.
384                continue;
385            }
386            // A heading terminates the running paragraph.
387            break;
388        }
389        collected.push(t);
390    }
391    if collected.is_empty() {
392        None
393    } else {
394        Some(collected.join(" "))
395    }
396}
397
398/// Resolve a `contact`'s `company` wiki-link to the company file's `name`
399/// frontmatter field. Falls back to the link's display-or-leaf text when the
400/// target can't be read or carries no `name`; `None` when there is no `company`
401/// field at all.
402///
403/// Reads the single target file directly (not a store walk) so the cost is
404/// O(1). Any I/O or parse failure degrades to the link's own text rather than
405/// erroring — composing a default summary must never fail on a dangling link.
406fn resolve_company_name(store: &Store, fm: &Frontmatter) -> Option<String> {
407    let raw = match field_value(fm, "company")? {
408        Value::String(s) => s,
409        // A list-valued company is unusual; take the first link if so.
410        Value::Sequence(items) => items.iter().find_map(|i| i.as_str().map(str::to_string))?,
411        _ => return None,
412    };
413    let fallback = {
414        let f = reduce_wiki_link(&raw);
415        let f = f.trim();
416        if f.is_empty() {
417            None
418        } else {
419            Some(f.to_string())
420        }
421    };
422
423    let Some(target) = wiki_link_target(&raw) else {
424        return fallback;
425    };
426    // `target` is a store-relative path without `.md`; load it under the root.
427    let mut abs = store.root.join(&target);
428    abs.set_extension("md");
429    match read_frontmatter_name(&abs) {
430        Some(name) if !name.trim().is_empty() => Some(name.trim().to_string()),
431        _ => fallback,
432    }
433}
434
435/// Extract a wiki-link's bare target path (`[[target]]` / `[[target|x]]` →
436/// `target`, `.md` suffix stripped). `None` when `s` is not a wiki-link.
437fn wiki_link_target(s: &str) -> Option<String> {
438    let inner = s
439        .trim()
440        .strip_prefix("[[")
441        .and_then(|rest| rest.strip_suffix("]]"))?;
442    let target = inner
443        .split_once('|')
444        .map(|(t, _)| t)
445        .unwrap_or(inner)
446        .trim();
447    let target = target.strip_suffix(".md").unwrap_or(target);
448    if target.is_empty() {
449        None
450    } else {
451        Some(target.to_string())
452    }
453}
454
455/// Read just the `name` frontmatter field from a markdown file on disk,
456/// parsing its YAML frontmatter block directly. Returns `None` on any I/O or
457/// parse failure, or when there is no `name`. Self-contained (does not depend
458/// on the rest of the parser, whose body may be unimplemented) and resilient by
459/// design.
460fn read_frontmatter_name(abs: &std::path::Path) -> Option<String> {
461    let text = std::fs::read_to_string(abs).ok()?;
462    let yaml = frontmatter_block(&text)?;
463    let value: Value = serde_yml::from_str(&yaml).ok()?;
464    // `&str` indexes a YAML mapping; `None` for non-mappings or absent `name`.
465    value.get("name")?.as_str().map(str::to_string)
466}
467
468/// Slice out the YAML frontmatter block (text between a leading `---` line and
469/// the next `---` line). `None` if the file doesn't open with a `---` fence.
470fn frontmatter_block(text: &str) -> Option<String> {
471    let mut lines = text.lines();
472    // First non-empty content must be the opening fence (allow a leading BOM).
473    let first = lines.next()?.trim_start_matches('\u{feff}').trim_end();
474    if first != "---" {
475        return None;
476    }
477    let mut block = String::new();
478    for line in lines {
479        if line.trim_end() == "---" {
480            return Some(block);
481        }
482        block.push_str(line);
483        block.push('\n');
484    }
485    // No closing fence.
486    None
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492    use crate::parser::Config;
493    use std::fs;
494    use std::path::PathBuf;
495    use tempfile::TempDir;
496
497    // ── Fixtures ─────────────────────────────────────────────────────────────
498
499    /// A temp store: a `DB.md` marker at the root, returned alongside an opened
500    /// [`Store`] handle. The handle is built directly (not via `Store::open`) so
501    /// these tests exercise the `summary` code under test, not store-open
502    /// plumbing.
503    struct Fixture {
504        _tmp: TempDir,
505        store: Store,
506    }
507
508    impl Fixture {
509        fn new() -> Self {
510            let tmp = TempDir::new().expect("tempdir");
511            let root = tmp.path().to_path_buf();
512            fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").expect("write DB.md");
513            let store = Store {
514                root,
515                config: Config::default(),
516            };
517            Fixture { _tmp: tmp, store }
518        }
519
520        /// Write a company record with the given store-relative path (no `.md`)
521        /// and `name`, so `compose_contact` can resolve it.
522        fn write_company(&self, rel_no_ext: &str, name: &str) {
523            let mut path: PathBuf = self.store.root.join(rel_no_ext);
524            path.set_extension("md");
525            fs::create_dir_all(path.parent().unwrap()).expect("mkdir");
526            let contents =
527                format!("---\ntype: company\nname: {name}\nindustry: SaaS\n---\n\n# {name}\n");
528            fs::write(path, contents).expect("write company");
529        }
530    }
531
532    /// Build a [`Frontmatter`] from a YAML map literal so tests state intent in
533    /// YAML, not by hand-poking `extra`. This goes through `serde_yml` exactly
534    /// like a real file's frontmatter would.
535    fn fm(yaml: &str) -> Frontmatter {
536        let value: Value = serde_yml::from_str(yaml).expect("test yaml parses");
537        let mapping = value.as_mapping().expect("test yaml is a mapping").clone();
538        let mut f = Frontmatter::default();
539        for (k, v) in mapping {
540            let key = k.as_str().expect("string key").to_string();
541            match key.as_str() {
542                "type" => f.type_ = v.as_str().map(str::to_string),
543                "summary" => f.summary = v.as_str().map(str::to_string),
544                "id" => f.id = v.as_str().map(str::to_string),
545                "status" => f.status = v.as_str().map(str::to_string),
546                _ => {
547                    f.extra.insert(key, v);
548                }
549            }
550        }
551        f
552    }
553
554    // ── normalize ────────────────────────────────────────────────────────────
555
556    #[test]
557    fn normalize_collapses_newlines_and_runs_to_single_spaces() {
558        let got = normalize("first line\nsecond\t\tline   third");
559        assert_eq!(got, "first line second line third");
560    }
561
562    #[test]
563    fn normalize_trims_surrounding_whitespace() {
564        assert_eq!(normalize("   padded value \n"), "padded value");
565    }
566
567    #[test]
568    fn normalize_caps_at_200_chars_on_char_boundary() {
569        // 250 multi-byte chars; the cap is by char, not byte.
570        let input = "é".repeat(250);
571        let got = normalize(&input);
572        assert_eq!(got.chars().count(), MAX_SUMMARY_LEN);
573        // Truncation must not corrupt UTF-8 (would panic on slice otherwise).
574        assert_eq!(got, "é".repeat(MAX_SUMMARY_LEN));
575    }
576
577    #[test]
578    fn normalize_leaves_short_strings_untouched() {
579        assert_eq!(normalize("short"), "short");
580    }
581
582    // ── contact ──────────────────────────────────────────────────────────────
583
584    #[test]
585    fn contact_resolves_company_link_to_company_name() {
586        let fx = Fixture::new();
587        fx.write_company("records/companies/northstar", "Northstar Logistics");
588        let f = fm("type: contact\n\
589             role: Director of Operations\n\
590             company: \"[[records/companies/northstar]]\"\n\
591             last_touch: 2026-05-22\n");
592        let got = compose_default(&fx.store, "contact", &f, "").unwrap();
593        assert_eq!(
594            got,
595            "Director of Operations at Northstar Logistics (last_touch: 2026-05-22)"
596        );
597    }
598
599    #[test]
600    fn contact_falls_back_to_link_leaf_when_company_file_missing() {
601        let fx = Fixture::new();
602        // No company file written → resolution must degrade, not error.
603        let f = fm("type: contact\n\
604             role: VP Sales\n\
605             company: \"[[records/companies/acme-corp]]\"\n\
606             last_touch: 2026-01-02\n");
607        let got = compose_default(&fx.store, "contact", &f, "").unwrap();
608        // Falls back to the link's leaf segment, NOT the raw [[...]] form.
609        assert_eq!(got, "VP Sales at acme-corp (last_touch: 2026-01-02)");
610        assert!(!got.contains("[["));
611    }
612
613    #[test]
614    fn contact_prefers_link_display_override_on_fallback() {
615        let fx = Fixture::new();
616        let f = fm("type: contact\n\
617             role: Founder\n\
618             company: \"[[records/companies/acme|Acme Inc]]\"\n");
619        let got = compose_contact(&fx.store, &f).unwrap();
620        // No last_touch → parenthetical dropped; display override used.
621        assert_eq!(got, "Founder at Acme Inc");
622    }
623
624    #[test]
625    fn contact_drops_company_segment_when_absent() {
626        let fx = Fixture::new();
627        let f = fm("type: contact\nrole: Advisor\nlast_touch: 2026-03-03\n");
628        let got = compose_contact(&fx.store, &f).unwrap();
629        assert_eq!(got, "Advisor (last_touch: 2026-03-03)");
630    }
631
632    #[test]
633    fn contact_uses_placeholder_when_role_absent() {
634        let fx = Fixture::new();
635        fx.write_company("records/companies/northstar", "Northstar");
636        let f = fm("type: contact\n\
637             company: \"[[records/companies/northstar]]\"\n");
638        let got = compose_contact(&fx.store, &f).unwrap();
639        assert_eq!(got, "Contact at Northstar");
640    }
641
642    // ── company ──────────────────────────────────────────────────────────────
643
644    #[test]
645    fn company_joins_relationship_and_industry() {
646        let f = fm("type: company\nrelationship: customer\nindustry: Logistics\n");
647        assert_eq!(compose_company(&f), "customer; Logistics");
648    }
649
650    #[test]
651    fn company_drops_separator_when_one_field_missing() {
652        let f = fm("type: company\nrelationship: vendor\n");
653        assert_eq!(compose_company(&f), "vendor");
654        let f2 = fm("type: company\nindustry: Fintech\n");
655        assert_eq!(compose_company(&f2), "Fintech");
656    }
657
658    // ── expense ──────────────────────────────────────────────────────────────
659
660    #[test]
661    fn expense_formats_date_amount_currency_vendor() {
662        let f = fm("type: expense\n\
663             date: 2026-04-01\n\
664             amount: 49.99\n\
665             currency: USD\n\
666             vendor: GitHub\n");
667        assert_eq!(compose_expense(&f), "2026-04-01 — 49.99 USD — GitHub");
668    }
669
670    #[test]
671    fn expense_renders_integer_amount_without_trailing_zero() {
672        let f = fm("type: expense\ndate: 2026-04-01\namount: 50\ncurrency: EUR\nvendor: AWS\n");
673        // 50 must not become 50.0.
674        assert_eq!(compose_expense(&f), "2026-04-01 — 50 EUR — AWS");
675    }
676
677    #[test]
678    fn expense_drops_missing_segments() {
679        let f = fm("type: expense\namount: 12\ncurrency: USD\n");
680        assert_eq!(compose_expense(&f), "12 USD");
681    }
682
683    // ── meeting ──────────────────────────────────────────────────────────────
684
685    #[test]
686    fn meeting_lists_first_three_attendees_with_more_count() {
687        let f = fm("type: meeting\n\
688             date: 2026-05-10\n\
689             attendees:\n\
690             \x20 - \"[[records/contacts/alice]]\"\n\
691             \x20 - \"[[records/contacts/bob]]\"\n\
692             \x20 - \"[[records/contacts/carol]]\"\n\
693             \x20 - \"[[records/contacts/dave]]\"\n\
694             \x20 - \"[[records/contacts/erin]]\"\n");
695        let got = compose_meeting(&f);
696        assert_eq!(got, "2026-05-10 — alice, bob, carol (+2 more)");
697    }
698
699    #[test]
700    fn meeting_omits_more_suffix_at_three_or_fewer() {
701        let f = fm("type: meeting\n\
702             date: 2026-05-10\n\
703             attendees:\n\
704             \x20 - \"[[records/contacts/alice]]\"\n\
705             \x20 - \"[[records/contacts/bob]]\"\n");
706        assert_eq!(compose_meeting(&f), "2026-05-10 — alice, bob");
707    }
708
709    #[test]
710    fn meeting_with_only_date_has_no_dash() {
711        let f = fm("type: meeting\ndate: 2026-05-10\n");
712        assert_eq!(compose_meeting(&f), "2026-05-10");
713    }
714
715    // ── decision ─────────────────────────────────────────────────────────────
716
717    #[test]
718    fn decision_uses_decided_by_and_first_heading() {
719        let f = fm("type: decision\ndecided_by: Carlos\n");
720        let body = "# Adopt Postgres over MySQL\n\nWe chose Postgres for JSONB.\n";
721        assert_eq!(
722            compose_decision(&f, body),
723            "Carlos: Adopt Postgres over MySQL"
724        );
725    }
726
727    #[test]
728    fn decision_falls_back_to_first_paragraph_without_heading() {
729        let f = fm("type: decision\ndecided_by: Board\n");
730        let body = "Ship the v2 pricing on June 1.\n";
731        assert_eq!(
732            compose_decision(&f, body),
733            "Board: Ship the v2 pricing on June 1."
734        );
735    }
736
737    #[test]
738    fn decision_strips_heading_hashes_at_any_depth() {
739        let f = fm("type: decision\ndecided_by: Eng\n");
740        let body = "### Use feature flags for the rollout\n";
741        assert_eq!(
742            compose_decision(&f, body),
743            "Eng: Use feature flags for the rollout"
744        );
745    }
746
747    // ── invoice ──────────────────────────────────────────────────────────────
748
749    #[test]
750    fn invoice_formats_vendor_amount_status() {
751        let f = fm("type: invoice\nvendor: Acme\namount: 1200\nstatus: paid\n");
752        assert_eq!(compose_invoice(&f), "Acme — 1200 — paid");
753    }
754
755    // ── email ────────────────────────────────────────────────────────────────
756
757    #[test]
758    fn email_formats_from_arrow_to_subject() {
759        let f = fm("type: email\n\
760             from: sarah@northstar.io\n\
761             to: carlos@example.com\n\
762             subject: Renewal terms\n");
763        assert_eq!(
764            compose_email(&f),
765            "sarah@northstar.io → carlos@example.com — Renewal terms"
766        );
767    }
768
769    #[test]
770    fn email_joins_multiple_recipients() {
771        let f = fm("type: email\n\
772             from: a@x.com\n\
773             to:\n\
774             \x20 - b@y.com\n\
775             \x20 - c@z.com\n\
776             subject: Kickoff\n");
777        assert_eq!(compose_email(&f), "a@x.com → b@y.com, c@z.com — Kickoff");
778    }
779
780    // ── transcript ───────────────────────────────────────────────────────────
781
782    #[test]
783    fn transcript_formats_recorded_at_and_attendees() {
784        let f = fm("type: transcript\n\
785             recorded_at: 2026-02-14T09:00:00-08:00\n\
786             attendees:\n\
787             \x20 - Alice\n\
788             \x20 - Bob\n");
789        assert_eq!(
790            compose_transcript(&f),
791            "2026-02-14T09:00:00-08:00 — Alice, Bob"
792        );
793    }
794
795    // ── pdf-source ───────────────────────────────────────────────────────────
796
797    #[test]
798    fn pdf_source_formats_doc_type_from_received_from() {
799        let f = fm("type: pdf-source\ndoc_type: contract\nreceived_from: Northstar Legal\n");
800        assert_eq!(compose_pdf_source(&f), "contract from Northstar Legal");
801    }
802
803    // ── wiki-page ────────────────────────────────────────────────────────────
804
805    #[test]
806    fn wiki_page_prefers_topic_field() {
807        let f = fm("type: wiki-page\ntopic: Renewal strategy\n");
808        let body = "# Renewal strategy\n\nLots of detail here.\n";
809        // topic wins over body paragraph.
810        assert_eq!(
811            compose_default(&Fixture::new().store, "wiki-page", &f, body).unwrap(),
812            "Renewal strategy"
813        );
814    }
815
816    #[test]
817    fn wiki_page_falls_back_to_first_paragraph_without_topic() {
818        let f = fm("type: wiki-page\n");
819        let body = "# Heading skipped\n\nThe synthesis of our pricing decisions.\n";
820        assert_eq!(
821            compose_wiki_page(&f, body),
822            "The synthesis of our pricing decisions."
823        );
824    }
825
826    // ── unknown / custom + body extraction ─────────────────────────────────────
827
828    #[test]
829    fn unknown_type_uses_first_non_heading_paragraph() {
830        let fx = Fixture::new();
831        let f = fm("type: proposal\n");
832        let body = "# Title\n\nThis proposal covers the Q3 roadmap.\n\nSecond paragraph.\n";
833        let got = compose_default(&fx.store, "proposal", &f, body).unwrap();
834        assert_eq!(got, "This proposal covers the Q3 roadmap.");
835    }
836
837    #[test]
838    fn first_paragraph_joins_wrapped_lines_until_blank() {
839        let body = "Line one\nline two\n\nlater paragraph";
840        assert_eq!(first_paragraph(body).as_deref(), Some("Line one line two"));
841    }
842
843    #[test]
844    fn first_paragraph_none_for_heading_only_body() {
845        assert_eq!(first_paragraph("# Just a heading\n## And another\n"), None);
846    }
847
848    #[test]
849    fn unknown_type_long_paragraph_is_capped_at_200() {
850        let fx = Fixture::new();
851        let f = fm("type: note\n");
852        let long = "word ".repeat(100); // 500 chars
853        let got = compose_default(&fx.store, "note", &f, &long).unwrap();
854        assert!(got.chars().count() <= MAX_SUMMARY_LEN);
855        assert!(got.chars().count() >= MAX_SUMMARY_LEN - 5); // close to the cap
856    }
857
858    // ── wiki-link reduction ────────────────────────────────────────────────────
859
860    #[test]
861    fn reduce_wiki_link_takes_leaf_segment() {
862        assert_eq!(
863            reduce_wiki_link("[[records/companies/northstar]]"),
864            "northstar"
865        );
866    }
867
868    #[test]
869    fn reduce_wiki_link_prefers_display() {
870        assert_eq!(
871            reduce_wiki_link("[[records/companies/x|Northstar Inc]]"),
872            "Northstar Inc"
873        );
874    }
875
876    #[test]
877    fn reduce_wiki_link_strips_md_extension() {
878        assert_eq!(reduce_wiki_link("[[records/companies/x.md]]"), "x");
879    }
880
881    #[test]
882    fn reduce_wiki_link_passes_through_plain_text() {
883        assert_eq!(reduce_wiki_link("just a vendor name"), "just a vendor name");
884    }
885
886    // ── determinism ────────────────────────────────────────────────────────────
887
888    #[test]
889    fn compose_default_is_deterministic_across_calls() {
890        let fx = Fixture::new();
891        fx.write_company("records/companies/northstar", "Northstar");
892        let f = fm("type: contact\n\
893             role: Ops Lead\n\
894             company: \"[[records/companies/northstar]]\"\n\
895             last_touch: 2026-05-22\n");
896        let a = compose_default(&fx.store, "contact", &f, "body").unwrap();
897        let b = compose_default(&fx.store, "contact", &f, "body").unwrap();
898        let c = compose_default(&fx.store, "contact", &f, "body").unwrap();
899        assert_eq!(a, b);
900        assert_eq!(b, c);
901    }
902
903    #[test]
904    fn empty_frontmatter_company_yields_empty_summary() {
905        // No relationship/industry → empty (validate later flags SUMMARY_EMPTY).
906        let f = fm("type: company\n");
907        assert_eq!(
908            compose_default(&Fixture::new().store, "company", &f, "").unwrap(),
909            ""
910        );
911    }
912}