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 let (target, display) = match inner.split_once('|') {
250 Some((t, d)) => (t, Some(d)),
251 None => (inner, None),
252 };
253 if let Some(d) = display {
254 let d = d.trim();
255 if !d.is_empty() {
256 return d.to_string();
257 }
258 }
259 let leaf = target.trim().rsplit('/').next().unwrap_or(target).trim();
260 leaf.strip_suffix(".md").unwrap_or(leaf).to_string()
261}
262
263fn first_paragraph(body: &str) -> Option<String> {
267 let mut collected: Vec<&str> = Vec::new();
268 for line in body.lines() {
269 let t = line.trim();
270 if t.is_empty() {
271 if collected.is_empty() {
272 continue;
274 }
275 break;
277 }
278 if t.starts_with('#') {
279 if collected.is_empty() {
280 continue;
282 }
283 break;
285 }
286 collected.push(t);
287 }
288 if collected.is_empty() {
289 None
290 } else {
291 Some(collected.join(" "))
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 use crate::parser::{Config, Schema};
299 use std::fs;
300 use tempfile::TempDir;
301
302 fn store_with(config: Config) -> (TempDir, Store) {
308 let tmp = TempDir::new().expect("tempdir");
309 let root = tmp.path().to_path_buf();
310 fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").expect("write DB.md");
311 let store = Store { root, config };
312 (tmp, store)
313 }
314
315 fn store_with_template(type_: &str, template: &str) -> (TempDir, Store) {
317 let mut config = Config::default();
318 config.schemas.insert(
319 type_.to_string(),
320 Schema {
321 summary_template: Some(template.to_string()),
322 ..Schema::default()
323 },
324 );
325 store_with(config)
326 }
327
328 fn fm(yaml: &str) -> Frontmatter {
332 let value: Value = serde_norway::from_str(yaml).expect("test yaml parses");
333 let mapping = value.as_mapping().expect("test yaml is a mapping").clone();
334 let mut f = Frontmatter::default();
335 for (k, v) in mapping {
336 let key = k.as_str().expect("string key").to_string();
337 match key.as_str() {
338 "type" => f.type_ = v.as_str().map(str::to_string),
339 "summary" => f.summary = v.as_str().map(str::to_string),
340 "id" => f.id = v.as_str().map(str::to_string),
341 "status" => f.status = v.as_str().map(str::to_string),
342 "tags" => {
346 if let Value::Sequence(items) = &v {
347 f.tags = items
348 .iter()
349 .filter_map(|i| i.as_str().map(str::to_string))
350 .collect();
351 }
352 }
353 "created" => {
354 f.created = v
355 .as_str()
356 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
357 }
358 "updated" => {
359 f.updated = v
360 .as_str()
361 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
362 }
363 _ => {
364 f.extra.insert(key, v);
365 }
366 }
367 }
368 f
369 }
370
371 #[test]
374 fn normalize_collapses_newlines_and_runs_to_single_spaces() {
375 let got = normalize("first line\nsecond\t\tline third");
376 assert_eq!(got, "first line second line third");
377 }
378
379 #[test]
380 fn normalize_trims_surrounding_whitespace() {
381 assert_eq!(normalize(" padded value \n"), "padded value");
382 }
383
384 #[test]
385 fn normalize_caps_at_200_chars_on_char_boundary() {
386 let input = "é".repeat(250);
388 let got = normalize(&input);
389 assert_eq!(got.chars().count(), MAX_SUMMARY_LEN);
390 assert_eq!(got, "é".repeat(MAX_SUMMARY_LEN));
392 }
393
394 #[test]
395 fn normalize_leaves_short_strings_untouched() {
396 assert_eq!(normalize("short"), "short");
397 }
398
399 #[test]
402 fn regression_collapse_whitespace_preserves_long_explicit_summary() {
403 let long = format!(
408 "Director of Operations at Northstar; renewal champion who drove the 175-seat expansion and {}",
409 "x".repeat(150)
410 );
411 assert!(long.chars().count() > MAX_SUMMARY_LEN);
412 let collapsed = collapse_whitespace(&long);
413 assert_eq!(collapsed.chars().count(), long.chars().count());
415 assert_eq!(collapsed, long);
416 assert!(normalize(&long).chars().count() == MAX_SUMMARY_LEN);
418 assert_ne!(collapse_whitespace(&long), normalize(&long));
419 }
420
421 #[test]
422 fn collapse_whitespace_still_collapses_to_single_line() {
423 assert_eq!(
426 collapse_whitespace(" multi\nline\tsummary "),
427 "multi line summary"
428 );
429 }
430
431 #[test]
434 fn template_interpolates_scalar_fields() {
435 let (_t, store) =
436 store_with_template("contact", "{role} at {company} (last_touch: {last_touch})");
437 let f = fm("type: contact\n\
438 role: Director of Operations\n\
439 company: \"[[records/companies/northstar]]\"\n\
440 last_touch: 2026-05-22\n");
441 assert_eq!(
444 compose_default(&store, "contact", &f, "ignored body").unwrap(),
445 "Director of Operations at northstar (last_touch: 2026-05-22)"
446 );
447 }
448
449 #[test]
450 fn template_interpolates_unquoted_scalar_wiki_link_fields() {
451 let (_t, store) = store_with_template("contact", "{role} at {company}");
452 let f = fm("type: contact\n\
453 role: Director\n\
454 company: [[records/companies/northstar]]\n");
455 assert_eq!(
456 compose_default(&store, "contact", &f, "").unwrap(),
457 "Director at northstar"
458 );
459 }
460
461 #[test]
462 fn template_drops_absent_fields_to_empty() {
463 let (_t, store) = store_with_template("contact", "{role} at {company}");
464 let f = fm("type: contact\nrole: Advisor\n");
465 assert_eq!(
467 compose_default(&store, "contact", &f, "").unwrap(),
468 "Advisor at"
469 );
470 }
471
472 #[test]
473 fn template_joins_list_fields_comma_separated() {
474 let (_t, store) = store_with_template("meeting", "{date}: {attendees}");
475 let f = fm("type: meeting\n\
476 date: 2026-05-10\n\
477 attendees:\n\
478 \x20 - \"[[records/contacts/alice]]\"\n\
479 \x20 - \"[[records/contacts/bob]]\"\n");
480 assert_eq!(
481 compose_default(&store, "meeting", &f, "").unwrap(),
482 "2026-05-10: alice, bob"
483 );
484 }
485
486 #[test]
487 fn template_interpolates_typed_tags_created_updated() {
488 let (_t, store) = store_with_template("note", "{tags} | {created}");
492 let f = fm("type: note\ntags: [urgent, q3]\ncreated: \"2026-05-01T00:00:00Z\"\n");
493 assert_eq!(
494 compose_default(&store, "note", &f, "").unwrap(),
495 "urgent, q3 | 2026-05-01T00:00:00+00:00"
497 );
498 }
499
500 #[test]
501 fn template_joins_unquoted_block_wiki_link_list_fields() {
502 let (_t, store) = store_with_template("meeting", "{attendees}");
503 let f = fm("type: meeting\n\
504 attendees:\n\
505 \x20 - [[records/contacts/alice]]\n\
506 \x20 - [[records/contacts/bob]]\n");
507 assert_eq!(
508 compose_default(&store, "meeting", &f, "").unwrap(),
509 "alice, bob"
510 );
511 }
512
513 #[test]
514 fn template_emits_stray_brace_verbatim() {
515 let (_t, store) = store_with_template("note", "literal { brace {title}");
516 let f = fm("type: note\ntitle: Hello\n");
517 assert_eq!(
518 compose_default(&store, "note", &f, "").unwrap(),
519 "literal { brace Hello"
520 );
521 }
522
523 #[test]
524 fn template_is_deterministic_across_calls() {
525 let (_t, store) = store_with_template("contact", "{role} ({last_touch})");
526 let f = fm("type: contact\nrole: Ops Lead\nlast_touch: 2026-05-22\n");
527 let a = compose_default(&store, "contact", &f, "body").unwrap();
528 let b = compose_default(&store, "contact", &f, "body").unwrap();
529 assert_eq!(a, b);
530 assert_eq!(a, "Ops Lead (2026-05-22)");
531 }
532
533 #[test]
534 fn no_schema_for_type_falls_back_to_body() {
535 let (_t, store) = store_with_template("contact", "{role}");
538 let f = fm("type: note\n");
539 assert_eq!(
540 compose_default(&store, "note", &f, "Body sentence here.").unwrap(),
541 "Body sentence here."
542 );
543 }
544
545 #[test]
548 fn unknown_type_uses_first_non_heading_paragraph() {
549 let (_t, store) = store_with(Config::default());
550 let f = fm("type: proposal\n");
551 let body = "# Title\n\nThis proposal covers the Q3 roadmap.\n\nSecond paragraph.\n";
552 let got = compose_default(&store, "proposal", &f, body).unwrap();
553 assert_eq!(got, "This proposal covers the Q3 roadmap.");
554 }
555
556 #[test]
557 fn first_paragraph_joins_wrapped_lines_until_blank() {
558 let body = "Line one\nline two\n\nlater paragraph";
559 assert_eq!(first_paragraph(body).as_deref(), Some("Line one line two"));
560 }
561
562 #[test]
563 fn first_paragraph_none_for_heading_only_body() {
564 assert_eq!(first_paragraph("# Just a heading\n## And another\n"), None);
565 }
566
567 #[test]
568 fn unknown_type_long_paragraph_is_capped_at_200() {
569 let (_t, store) = store_with(Config::default());
570 let f = fm("type: note\n");
571 let long = "word ".repeat(100); let got = compose_default(&store, "note", &f, &long).unwrap();
573 assert!(got.chars().count() <= MAX_SUMMARY_LEN);
574 assert!(got.chars().count() >= MAX_SUMMARY_LEN - 5); }
576
577 #[test]
580 fn reduce_wiki_link_takes_leaf_segment() {
581 assert_eq!(
582 reduce_wiki_link("[[records/companies/northstar]]"),
583 "northstar"
584 );
585 }
586
587 #[test]
588 fn reduce_wiki_link_prefers_display() {
589 assert_eq!(
590 reduce_wiki_link("[[records/companies/x|Northstar Inc]]"),
591 "Northstar Inc"
592 );
593 }
594
595 #[test]
596 fn reduce_wiki_link_strips_md_extension() {
597 assert_eq!(reduce_wiki_link("[[records/companies/x.md]]"), "x");
598 }
599
600 #[test]
601 fn reduce_wiki_link_passes_through_plain_text() {
602 assert_eq!(reduce_wiki_link("just a vendor name"), "just a vendor name");
603 }
604}