1use serde_yml::Value;
14
15use crate::parser::Frontmatter;
16use crate::store::Store;
17
18pub const MAX_SUMMARY_LEN: usize = 200;
20
21pub 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 _ => compose_from_body(body),
48 };
49 Ok(normalize(&composed))
50}
51
52pub 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
82pub fn compose_company(fm: &Frontmatter) -> String {
89 join_present(
90 "; ",
91 [field_text(fm, "relationship"), field_text(fm, "industry")],
92 )
93}
94
95pub 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
109pub 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
132pub 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
146pub 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
158pub 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
180pub 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
193pub 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
203pub fn compose_wiki_page(fm: &Frontmatter, body: &str) -> String {
206 field_text(fm, "topic").unwrap_or_else(|| compose_from_body(body))
207}
208
209pub fn compose_from_body(body: &str) -> String {
212 first_paragraph(body).unwrap_or_default()
213}
214
215pub fn normalize(candidate: &str) -> String {
220 let collapsed = candidate.split_whitespace().collect::<Vec<_>>().join(" ");
224 truncate_chars(&collapsed, MAX_SUMMARY_LEN)
225}
226
227fn 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
237fn 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 _ => fm.extra.get(key).cloned(),
250 }
251}
252
253fn 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
267fn 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
295fn 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 Some(n.to_string())
307 }
308 Value::Null | Value::Sequence(_) | Value::Mapping(_) | Value::Tagged(_) => None,
309 }
310}
311
312fn 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 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
339fn 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
350fn 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 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
366fn 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 continue;
377 }
378 break;
380 }
381 if t.starts_with('#') {
382 if collected.is_empty() {
383 continue;
385 }
386 break;
388 }
389 collected.push(t);
390 }
391 if collected.is_empty() {
392 None
393 } else {
394 Some(collected.join(" "))
395 }
396}
397
398fn resolve_company_name(store: &Store, fm: &Frontmatter) -> Option<String> {
407 let raw = match field_value(fm, "company")? {
408 Value::String(s) => s,
409 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 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
435fn 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
455fn 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 value.get("name")?.as_str().map(str::to_string)
466}
467
468fn frontmatter_block(text: &str) -> Option<String> {
471 let mut lines = text.lines();
472 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 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 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 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 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 #[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 let input = "é".repeat(250);
571 let got = normalize(&input);
572 assert_eq!(got.chars().count(), MAX_SUMMARY_LEN);
573 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 #[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 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 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 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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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); 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); }
857
858 #[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 #[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 let f = fm("type: company\n");
907 assert_eq!(
908 compose_default(&Fixture::new().store, "company", &f, "").unwrap(),
909 ""
910 );
911 }
912}