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 normalize(candidate: &str) -> String {
97 let collapsed = candidate.split_whitespace().collect::<Vec<_>>().join(" ");
101 truncate_chars(&collapsed, MAX_SUMMARY_LEN)
102}
103
104fn 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
114fn 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 "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
144fn 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
158fn 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
186fn 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 Some(n.to_string())
199 }
200 Value::Null | Value::Mapping(_) | Value::Tagged(_) => None,
201 }
202}
203
204fn 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
223fn 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 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
250fn 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 continue;
261 }
262 break;
264 }
265 if t.starts_with('#') {
266 if collected.is_empty() {
267 continue;
269 }
270 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 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 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 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 "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 #[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 let input = "é".repeat(250);
375 let got = normalize(&input);
376 assert_eq!(got.chars().count(), MAX_SUMMARY_LEN);
377 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 #[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 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 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 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 "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 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 #[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); 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); }
531
532 #[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}