1use serde_norway::Value;
15
16use crate::parser::Frontmatter;
17use crate::store::Store;
18
19pub const MAX_SUMMARY_LEN: usize = 200;
21
22pub 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
48fn 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 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 _ => {
77 out.push('{');
78 rest = after;
79 }
80 }
81 }
82 out.push_str(rest);
83 out
84}
85
86pub fn compose_from_body(body: &str) -> String {
89 first_paragraph(body).unwrap_or_default()
90}
91
92pub fn collapse_whitespace(candidate: &str) -> String {
102 candidate.split_whitespace().collect::<Vec<_>>().join(" ")
106}
107
108pub fn normalize(candidate: &str) -> String {
114 truncate_chars(&collapse_whitespace(candidate), MAX_SUMMARY_LEN)
115}
116
117fn truncate_chars(s: &str, max: usize) -> String {
121 match s.char_indices().nth(max) {
122 Some((byte_idx, _)) => s[..byte_idx].to_string(),
123 None => s.to_string(),
124 }
125}
126
127fn field_value(fm: &Frontmatter, key: &str) -> Option<Value> {
132 match key {
133 "type" => fm.type_.clone().map(Value::String),
134 "id" => fm.id.clone().map(Value::String),
135 "summary" => fm.summary.clone().map(Value::String),
136 "status" => fm.status.clone().map(Value::String),
137 "created" => fm.created.map(|t| Value::String(t.to_rfc3339())),
143 "updated" => fm.updated.map(|t| Value::String(t.to_rfc3339())),
144 "tags" => {
145 if fm.tags.is_empty() {
146 None
147 } else {
148 Some(Value::Sequence(
149 fm.tags.iter().cloned().map(Value::String).collect(),
150 ))
151 }
152 }
153 _ => fm.extra.get(key).cloned(),
154 }
155}
156
157fn field_text(fm: &Frontmatter, key: &str) -> Option<String> {
161 let v = field_value(fm, key)?;
162 let rendered = render_scalar(&v)?;
163 let trimmed = rendered.trim();
164 if trimmed.is_empty() {
165 None
166 } else {
167 Some(trimmed.to_string())
168 }
169}
170
171fn list_field_texts(fm: &Frontmatter, key: &str) -> Vec<String> {
175 let Some(v) = field_value(fm, key) else {
176 return Vec::new();
177 };
178 match v {
179 Value::Sequence(items) => items
180 .iter()
181 .filter_map(|item| {
182 let r = render_scalar(item)?;
183 let t = r.trim();
184 if t.is_empty() {
185 None
186 } else {
187 Some(t.to_string())
188 }
189 })
190 .collect(),
191 other => render_scalar(&other)
192 .map(|r| r.trim().to_string())
193 .filter(|t| !t.is_empty())
194 .into_iter()
195 .collect(),
196 }
197}
198
199fn render_scalar(v: &Value) -> Option<String> {
204 match v {
205 Value::String(s) => Some(reduce_wiki_link(s)),
206 Value::Sequence(_) => render_unquoted_wiki_link(v),
207 Value::Bool(b) => Some(b.to_string()),
208 Value::Number(n) => {
209 Some(n.to_string())
212 }
213 Value::Null | Value::Mapping(_) | Value::Tagged(_) => None,
214 }
215}
216
217fn render_unquoted_wiki_link(v: &Value) -> Option<String> {
221 let Value::Sequence(outer) = v else {
222 return None;
223 };
224 if outer.len() != 1 {
225 return None;
226 }
227 let Value::Sequence(inner) = &outer[0] else {
228 return None;
229 };
230 let [Value::String(target)] = inner.as_slice() else {
231 return None;
232 };
233 Some(reduce_wiki_link(&format!("[[{target}]]")))
234}
235
236fn reduce_wiki_link(s: &str) -> String {
241 let trimmed = s.trim();
242 let inner = trimmed
243 .strip_prefix("[[")
244 .and_then(|rest| rest.strip_suffix("]]"));
245 let Some(inner) = inner else {
246 return s.to_string();
247 };
248 if inner.contains("[[") || inner.contains("]]") {
254 return s.to_string();
255 }
256 let (target, display) = match inner.split_once('|') {
258 Some((t, d)) => (t, Some(d)),
259 None => (inner, None),
260 };
261 if let Some(d) = display {
262 let d = d.trim();
263 if !d.is_empty() {
264 return d.to_string();
265 }
266 }
267 let leaf = target.trim().rsplit('/').next().unwrap_or(target).trim();
268 leaf.strip_suffix(".md").unwrap_or(leaf).to_string()
269}
270
271fn first_paragraph(body: &str) -> Option<String> {
287 let lines: Vec<&str> = body.lines().collect();
288 let mut collected: Vec<&str> = Vec::new();
289 let mut i = 0;
290 while i < lines.len() {
291 let raw = lines[i];
292 let t = raw.trim();
293
294 if collected.is_empty() {
297 if let Some(fence) = code_fence_marker(t) {
298 i += 1;
299 while i < lines.len() {
300 let inner = lines[i].trim();
301 i += 1;
302 if closes_code_fence(inner, fence) {
303 break;
304 }
305 }
306 continue;
307 }
308 }
309
310 if t.is_empty() {
311 if collected.is_empty() {
312 i += 1;
314 continue;
315 }
316 break;
318 }
319
320 if is_atx_heading(t) {
322 if collected.is_empty() {
323 i += 1;
324 continue;
325 }
326 break;
327 }
328
329 if collected.is_empty() {
334 if let Some(next) = lines.get(i + 1).map(|l| l.trim()) {
335 if is_setext_underline(next) {
336 i += 2;
337 continue;
338 }
339 }
340 }
341
342 collected.push(t);
343 i += 1;
344 }
345 if collected.is_empty() {
346 None
347 } else {
348 Some(collected.join(" "))
349 }
350}
351
352fn is_atx_heading(line: &str) -> bool {
357 let hashes = line.chars().take_while(|&c| c == '#').count();
358 if hashes == 0 || hashes > 6 {
359 return false;
360 }
361 match line[hashes..].chars().next() {
362 None => true, Some(c) => c == ' ' || c == '\t', }
365}
366
367fn code_fence_marker(line: &str) -> Option<char> {
371 let first = line.chars().next()?;
372 if first != '`' && first != '~' {
373 return None;
374 }
375 let run = line.chars().take_while(|&c| c == first).count();
376 if run >= 3 {
377 Some(first)
378 } else {
379 None
380 }
381}
382
383fn closes_code_fence(line: &str, fence: char) -> bool {
387 let run = line.chars().take_while(|&c| c == fence).count();
388 run >= 3 && line.chars().all(|c| c == fence)
389}
390
391fn is_setext_underline(line: &str) -> bool {
395 (!line.is_empty() && line.chars().all(|c| c == '='))
396 || (!line.is_empty() && line.chars().all(|c| c == '-'))
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402 use crate::parser::{Config, Schema};
403 use std::fs;
404 use tempfile::TempDir;
405
406 fn store_with(config: Config) -> (TempDir, Store) {
412 let tmp = TempDir::new().expect("tempdir");
413 let root = tmp.path().to_path_buf();
414 fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").expect("write DB.md");
415 let store = Store { root, config };
416 (tmp, store)
417 }
418
419 fn store_with_template(type_: &str, template: &str) -> (TempDir, Store) {
421 let mut config = Config::default();
422 config.schemas.insert(
423 type_.to_string(),
424 Schema {
425 summary_template: Some(template.to_string()),
426 ..Schema::default()
427 },
428 );
429 store_with(config)
430 }
431
432 fn fm(yaml: &str) -> Frontmatter {
436 let value: Value = serde_norway::from_str(yaml).expect("test yaml parses");
437 let mapping = value.as_mapping().expect("test yaml is a mapping").clone();
438 let mut f = Frontmatter::default();
439 for (k, v) in mapping {
440 let key = k.as_str().expect("string key").to_string();
441 match key.as_str() {
442 "type" => f.type_ = v.as_str().map(str::to_string),
443 "summary" => f.summary = v.as_str().map(str::to_string),
444 "id" => f.id = v.as_str().map(str::to_string),
445 "status" => f.status = v.as_str().map(str::to_string),
446 "tags" => {
450 if let Value::Sequence(items) = &v {
451 f.tags = items
452 .iter()
453 .filter_map(|i| i.as_str().map(str::to_string))
454 .collect();
455 }
456 }
457 "created" => {
458 f.created = v
459 .as_str()
460 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
461 }
462 "updated" => {
463 f.updated = v
464 .as_str()
465 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
466 }
467 _ => {
468 f.extra.insert(key, v);
469 }
470 }
471 }
472 f
473 }
474
475 #[test]
478 fn normalize_collapses_newlines_and_runs_to_single_spaces() {
479 let got = normalize("first line\nsecond\t\tline third");
480 assert_eq!(got, "first line second line third");
481 }
482
483 #[test]
484 fn normalize_trims_surrounding_whitespace() {
485 assert_eq!(normalize(" padded value \n"), "padded value");
486 }
487
488 #[test]
489 fn normalize_caps_at_200_chars_on_char_boundary() {
490 let input = "é".repeat(250);
492 let got = normalize(&input);
493 assert_eq!(got.chars().count(), MAX_SUMMARY_LEN);
494 assert_eq!(got, "é".repeat(MAX_SUMMARY_LEN));
496 }
497
498 #[test]
499 fn normalize_leaves_short_strings_untouched() {
500 assert_eq!(normalize("short"), "short");
501 }
502
503 #[test]
506 fn regression_collapse_whitespace_preserves_long_explicit_summary() {
507 let long = format!(
512 "Director of Operations at Northstar; renewal champion who drove the 175-seat expansion and {}",
513 "x".repeat(150)
514 );
515 assert!(long.chars().count() > MAX_SUMMARY_LEN);
516 let collapsed = collapse_whitespace(&long);
517 assert_eq!(collapsed.chars().count(), long.chars().count());
519 assert_eq!(collapsed, long);
520 assert!(normalize(&long).chars().count() == MAX_SUMMARY_LEN);
522 assert_ne!(collapse_whitespace(&long), normalize(&long));
523 }
524
525 #[test]
526 fn collapse_whitespace_still_collapses_to_single_line() {
527 assert_eq!(
530 collapse_whitespace(" multi\nline\tsummary "),
531 "multi line summary"
532 );
533 }
534
535 #[test]
538 fn template_interpolates_scalar_fields() {
539 let (_t, store) =
540 store_with_template("contact", "{role} at {company} (last_touch: {last_touch})");
541 let f = fm("type: contact\n\
542 role: Director of Operations\n\
543 company: \"[[records/companies/northstar]]\"\n\
544 last_touch: 2026-05-22\n");
545 assert_eq!(
548 compose_default(&store, "contact", &f, "ignored body").unwrap(),
549 "Director of Operations at northstar (last_touch: 2026-05-22)"
550 );
551 }
552
553 #[test]
554 fn template_interpolates_unquoted_scalar_wiki_link_fields() {
555 let (_t, store) = store_with_template("contact", "{role} at {company}");
556 let f = fm("type: contact\n\
557 role: Director\n\
558 company: [[records/companies/northstar]]\n");
559 assert_eq!(
560 compose_default(&store, "contact", &f, "").unwrap(),
561 "Director at northstar"
562 );
563 }
564
565 #[test]
566 fn template_drops_absent_fields_to_empty() {
567 let (_t, store) = store_with_template("contact", "{role} at {company}");
568 let f = fm("type: contact\nrole: Advisor\n");
569 assert_eq!(
571 compose_default(&store, "contact", &f, "").unwrap(),
572 "Advisor at"
573 );
574 }
575
576 #[test]
577 fn template_joins_list_fields_comma_separated() {
578 let (_t, store) = store_with_template("meeting", "{date}: {attendees}");
579 let f = fm("type: meeting\n\
580 date: 2026-05-10\n\
581 attendees:\n\
582 \x20 - \"[[records/contacts/alice]]\"\n\
583 \x20 - \"[[records/contacts/bob]]\"\n");
584 assert_eq!(
585 compose_default(&store, "meeting", &f, "").unwrap(),
586 "2026-05-10: alice, bob"
587 );
588 }
589
590 #[test]
591 fn template_interpolates_typed_tags_created_updated() {
592 let (_t, store) = store_with_template("note", "{tags} | {created}");
596 let f = fm("type: note\ntags: [urgent, q3]\ncreated: \"2026-05-01T00:00:00Z\"\n");
597 assert_eq!(
598 compose_default(&store, "note", &f, "").unwrap(),
599 "urgent, q3 | 2026-05-01T00:00:00+00:00"
601 );
602 }
603
604 #[test]
605 fn template_joins_unquoted_block_wiki_link_list_fields() {
606 let (_t, store) = store_with_template("meeting", "{attendees}");
607 let f = fm("type: meeting\n\
608 attendees:\n\
609 \x20 - [[records/contacts/alice]]\n\
610 \x20 - [[records/contacts/bob]]\n");
611 assert_eq!(
612 compose_default(&store, "meeting", &f, "").unwrap(),
613 "alice, bob"
614 );
615 }
616
617 #[test]
618 fn template_emits_stray_brace_verbatim() {
619 let (_t, store) = store_with_template("note", "literal { brace {title}");
620 let f = fm("type: note\ntitle: Hello\n");
621 assert_eq!(
622 compose_default(&store, "note", &f, "").unwrap(),
623 "literal { brace Hello"
624 );
625 }
626
627 #[test]
628 fn template_is_deterministic_across_calls() {
629 let (_t, store) = store_with_template("contact", "{role} ({last_touch})");
630 let f = fm("type: contact\nrole: Ops Lead\nlast_touch: 2026-05-22\n");
631 let a = compose_default(&store, "contact", &f, "body").unwrap();
632 let b = compose_default(&store, "contact", &f, "body").unwrap();
633 assert_eq!(a, b);
634 assert_eq!(a, "Ops Lead (2026-05-22)");
635 }
636
637 #[test]
638 fn no_schema_for_type_falls_back_to_body() {
639 let (_t, store) = store_with_template("contact", "{role}");
642 let f = fm("type: note\n");
643 assert_eq!(
644 compose_default(&store, "note", &f, "Body sentence here.").unwrap(),
645 "Body sentence here."
646 );
647 }
648
649 #[test]
652 fn unknown_type_uses_first_non_heading_paragraph() {
653 let (_t, store) = store_with(Config::default());
654 let f = fm("type: proposal\n");
655 let body = "# Title\n\nThis proposal covers the Q3 roadmap.\n\nSecond paragraph.\n";
656 let got = compose_default(&store, "proposal", &f, body).unwrap();
657 assert_eq!(got, "This proposal covers the Q3 roadmap.");
658 }
659
660 #[test]
661 fn first_paragraph_joins_wrapped_lines_until_blank() {
662 let body = "Line one\nline two\n\nlater paragraph";
663 assert_eq!(first_paragraph(body).as_deref(), Some("Line one line two"));
664 }
665
666 #[test]
667 fn first_paragraph_none_for_heading_only_body() {
668 assert_eq!(first_paragraph("# Just a heading\n## And another\n"), None);
669 }
670
671 #[test]
672 fn unknown_type_long_paragraph_is_capped_at_200() {
673 let (_t, store) = store_with(Config::default());
674 let f = fm("type: note\n");
675 let long = "word ".repeat(100); let got = compose_default(&store, "note", &f, &long).unwrap();
677 assert!(got.chars().count() <= MAX_SUMMARY_LEN);
678 assert!(got.chars().count() >= MAX_SUMMARY_LEN - 5); }
680
681 #[test]
684 fn reduce_wiki_link_takes_leaf_segment() {
685 assert_eq!(
686 reduce_wiki_link("[[records/companies/northstar]]"),
687 "northstar"
688 );
689 }
690
691 #[test]
692 fn reduce_wiki_link_prefers_display() {
693 assert_eq!(
694 reduce_wiki_link("[[records/companies/x|Northstar Inc]]"),
695 "Northstar Inc"
696 );
697 }
698
699 #[test]
700 fn reduce_wiki_link_strips_md_extension() {
701 assert_eq!(reduce_wiki_link("[[records/companies/x.md]]"), "x");
702 }
703
704 #[test]
705 fn reduce_wiki_link_passes_through_plain_text() {
706 assert_eq!(reduce_wiki_link("just a vendor name"), "just a vendor name");
707 }
708
709 #[test]
710 fn regression_reduce_wiki_link_multiple_links_passthrough() {
711 let s = "[[records/companies/acme]] and [[records/companies/globex]]";
716 assert_eq!(reduce_wiki_link(s), s);
717 assert_eq!(reduce_wiki_link("[[records/companies/acme]]"), "acme");
719 assert_eq!(reduce_wiki_link("Acme and Globex"), "Acme and Globex");
720 }
721
722 #[test]
725 fn regression_first_paragraph_skips_setext_heading() {
726 let body = "Launch Plan\n===========\n\nThis is the real first paragraph of prose.\n";
729 assert_eq!(
730 first_paragraph(body).as_deref(),
731 Some("This is the real first paragraph of prose.")
732 );
733 let body = "Section\n-------\n\nBody prose follows.\n";
735 assert_eq!(
736 first_paragraph(body).as_deref(),
737 Some("Body prose follows.")
738 );
739 }
740
741 #[test]
742 fn regression_first_paragraph_hash_without_space_is_prose() {
743 assert_eq!(
747 first_paragraph("#1 priority this week: fix onboarding drop-off.\n").as_deref(),
748 Some("#1 priority this week: fix onboarding drop-off.")
749 );
750 assert_eq!(
751 first_paragraph("#hashtag notes about the launch\n").as_deref(),
752 Some("#hashtag notes about the launch")
753 );
754 assert_eq!(
757 first_paragraph("#1 priority: X\n\nSecond para.\n").as_deref(),
758 Some("#1 priority: X")
759 );
760 assert_eq!(
762 first_paragraph("# Real heading\n\nThe actual prose.\n").as_deref(),
763 Some("The actual prose.")
764 );
765 assert_eq!(
767 first_paragraph("###\n\nProse.\n").as_deref(),
768 Some("Prose.")
769 );
770 }
771
772 #[test]
773 fn regression_first_paragraph_skips_leading_fenced_code_block() {
774 let body =
778 "```bash\n# install dependencies\nnpm install\n```\n\nReal prose paragraph here.\n";
779 assert_eq!(
780 first_paragraph(body).as_deref(),
781 Some("Real prose paragraph here.")
782 );
783 let body = "~~~\ncode line\n~~~\n\nProse after tilde fence.\n";
785 assert_eq!(
786 first_paragraph(body).as_deref(),
787 Some("Prose after tilde fence.")
788 );
789 }
790
791 #[test]
792 fn compose_from_body_handles_hash_prose_setext_and_fence() {
793 assert_eq!(
797 compose_from_body("#1 priority this week: fix onboarding.\n"),
798 "#1 priority this week: fix onboarding."
799 );
800 assert_eq!(
801 compose_from_body("Launch Plan\n===========\n\nThe real prose.\n"),
802 "The real prose."
803 );
804 assert_eq!(
805 compose_from_body("```bash\n# step\n```\n\nThe real prose.\n"),
806 "The real prose."
807 );
808 }
809
810 #[test]
811 fn is_atx_heading_applies_commonmark_space_rule() {
812 assert!(is_atx_heading("# Title"));
813 assert!(is_atx_heading("###### Deep"));
814 assert!(is_atx_heading("###")); assert!(!is_atx_heading("#1 priority"));
816 assert!(!is_atx_heading("#hashtag"));
817 assert!(!is_atx_heading("####### too many")); assert!(!is_atx_heading("plain"));
819 }
820
821 #[test]
822 fn code_fence_and_setext_helpers() {
823 assert_eq!(code_fence_marker("```bash"), Some('`'));
824 assert_eq!(code_fence_marker("~~~"), Some('~'));
825 assert_eq!(code_fence_marker("``"), None); assert_eq!(code_fence_marker("plain"), None);
827 assert!(closes_code_fence("```", '`'));
828 assert!(!closes_code_fence("```bash", '`')); assert!(!closes_code_fence("~~~", '`')); assert!(is_setext_underline("==="));
831 assert!(is_setext_underline("---"));
832 assert!(!is_setext_underline("- item")); assert!(!is_setext_underline(""));
834 }
835}