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 _ => fm.extra.get(key).cloned(),
127 }
128}
129
130fn field_text(fm: &Frontmatter, key: &str) -> Option<String> {
134 let v = field_value(fm, key)?;
135 let rendered = render_scalar(&v)?;
136 let trimmed = rendered.trim();
137 if trimmed.is_empty() {
138 None
139 } else {
140 Some(trimmed.to_string())
141 }
142}
143
144fn list_field_texts(fm: &Frontmatter, key: &str) -> Vec<String> {
148 let Some(v) = field_value(fm, key) else {
149 return Vec::new();
150 };
151 match v {
152 Value::Sequence(items) => items
153 .iter()
154 .filter_map(|item| {
155 let r = render_scalar(item)?;
156 let t = r.trim();
157 if t.is_empty() {
158 None
159 } else {
160 Some(t.to_string())
161 }
162 })
163 .collect(),
164 other => render_scalar(&other)
165 .map(|r| r.trim().to_string())
166 .filter(|t| !t.is_empty())
167 .into_iter()
168 .collect(),
169 }
170}
171
172fn render_scalar(v: &Value) -> Option<String> {
177 match v {
178 Value::String(s) => Some(reduce_wiki_link(s)),
179 Value::Sequence(_) => render_unquoted_wiki_link(v),
180 Value::Bool(b) => Some(b.to_string()),
181 Value::Number(n) => {
182 Some(n.to_string())
185 }
186 Value::Null | Value::Mapping(_) | Value::Tagged(_) => None,
187 }
188}
189
190fn render_unquoted_wiki_link(v: &Value) -> Option<String> {
194 let Value::Sequence(outer) = v else {
195 return None;
196 };
197 if outer.len() != 1 {
198 return None;
199 }
200 let Value::Sequence(inner) = &outer[0] else {
201 return None;
202 };
203 let [Value::String(target)] = inner.as_slice() else {
204 return None;
205 };
206 Some(reduce_wiki_link(&format!("[[{target}]]")))
207}
208
209fn reduce_wiki_link(s: &str) -> String {
214 let trimmed = s.trim();
215 let inner = trimmed
216 .strip_prefix("[[")
217 .and_then(|rest| rest.strip_suffix("]]"));
218 let Some(inner) = inner else {
219 return s.to_string();
220 };
221 let (target, display) = match inner.split_once('|') {
223 Some((t, d)) => (t, Some(d)),
224 None => (inner, None),
225 };
226 if let Some(d) = display {
227 let d = d.trim();
228 if !d.is_empty() {
229 return d.to_string();
230 }
231 }
232 let leaf = target.trim().rsplit('/').next().unwrap_or(target).trim();
233 leaf.strip_suffix(".md").unwrap_or(leaf).to_string()
234}
235
236fn first_paragraph(body: &str) -> Option<String> {
240 let mut collected: Vec<&str> = Vec::new();
241 for line in body.lines() {
242 let t = line.trim();
243 if t.is_empty() {
244 if collected.is_empty() {
245 continue;
247 }
248 break;
250 }
251 if t.starts_with('#') {
252 if collected.is_empty() {
253 continue;
255 }
256 break;
258 }
259 collected.push(t);
260 }
261 if collected.is_empty() {
262 None
263 } else {
264 Some(collected.join(" "))
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use crate::parser::{Config, Schema};
272 use std::fs;
273 use tempfile::TempDir;
274
275 fn store_with(config: Config) -> (TempDir, Store) {
281 let tmp = TempDir::new().expect("tempdir");
282 let root = tmp.path().to_path_buf();
283 fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").expect("write DB.md");
284 let store = Store { root, config };
285 (tmp, store)
286 }
287
288 fn store_with_template(type_: &str, template: &str) -> (TempDir, Store) {
290 let mut config = Config::default();
291 config.schemas.insert(
292 type_.to_string(),
293 Schema {
294 summary_template: Some(template.to_string()),
295 ..Schema::default()
296 },
297 );
298 store_with(config)
299 }
300
301 fn fm(yaml: &str) -> Frontmatter {
305 let value: Value = serde_norway::from_str(yaml).expect("test yaml parses");
306 let mapping = value.as_mapping().expect("test yaml is a mapping").clone();
307 let mut f = Frontmatter::default();
308 for (k, v) in mapping {
309 let key = k.as_str().expect("string key").to_string();
310 match key.as_str() {
311 "type" => f.type_ = v.as_str().map(str::to_string),
312 "summary" => f.summary = v.as_str().map(str::to_string),
313 "id" => f.id = v.as_str().map(str::to_string),
314 "status" => f.status = v.as_str().map(str::to_string),
315 _ => {
316 f.extra.insert(key, v);
317 }
318 }
319 }
320 f
321 }
322
323 #[test]
326 fn normalize_collapses_newlines_and_runs_to_single_spaces() {
327 let got = normalize("first line\nsecond\t\tline third");
328 assert_eq!(got, "first line second line third");
329 }
330
331 #[test]
332 fn normalize_trims_surrounding_whitespace() {
333 assert_eq!(normalize(" padded value \n"), "padded value");
334 }
335
336 #[test]
337 fn normalize_caps_at_200_chars_on_char_boundary() {
338 let input = "é".repeat(250);
340 let got = normalize(&input);
341 assert_eq!(got.chars().count(), MAX_SUMMARY_LEN);
342 assert_eq!(got, "é".repeat(MAX_SUMMARY_LEN));
344 }
345
346 #[test]
347 fn normalize_leaves_short_strings_untouched() {
348 assert_eq!(normalize("short"), "short");
349 }
350
351 #[test]
354 fn template_interpolates_scalar_fields() {
355 let (_t, store) =
356 store_with_template("contact", "{role} at {company} (last_touch: {last_touch})");
357 let f = fm("type: contact\n\
358 role: Director of Operations\n\
359 company: \"[[records/companies/northstar]]\"\n\
360 last_touch: 2026-05-22\n");
361 assert_eq!(
364 compose_default(&store, "contact", &f, "ignored body").unwrap(),
365 "Director of Operations at northstar (last_touch: 2026-05-22)"
366 );
367 }
368
369 #[test]
370 fn template_interpolates_unquoted_scalar_wiki_link_fields() {
371 let (_t, store) = store_with_template("contact", "{role} at {company}");
372 let f = fm("type: contact\n\
373 role: Director\n\
374 company: [[records/companies/northstar]]\n");
375 assert_eq!(
376 compose_default(&store, "contact", &f, "").unwrap(),
377 "Director at northstar"
378 );
379 }
380
381 #[test]
382 fn template_drops_absent_fields_to_empty() {
383 let (_t, store) = store_with_template("contact", "{role} at {company}");
384 let f = fm("type: contact\nrole: Advisor\n");
385 assert_eq!(
387 compose_default(&store, "contact", &f, "").unwrap(),
388 "Advisor at"
389 );
390 }
391
392 #[test]
393 fn template_joins_list_fields_comma_separated() {
394 let (_t, store) = store_with_template("meeting", "{date}: {attendees}");
395 let f = fm("type: meeting\n\
396 date: 2026-05-10\n\
397 attendees:\n\
398 \x20 - \"[[records/contacts/alice]]\"\n\
399 \x20 - \"[[records/contacts/bob]]\"\n");
400 assert_eq!(
401 compose_default(&store, "meeting", &f, "").unwrap(),
402 "2026-05-10: alice, bob"
403 );
404 }
405
406 #[test]
407 fn template_joins_unquoted_block_wiki_link_list_fields() {
408 let (_t, store) = store_with_template("meeting", "{attendees}");
409 let f = fm("type: meeting\n\
410 attendees:\n\
411 \x20 - [[records/contacts/alice]]\n\
412 \x20 - [[records/contacts/bob]]\n");
413 assert_eq!(
414 compose_default(&store, "meeting", &f, "").unwrap(),
415 "alice, bob"
416 );
417 }
418
419 #[test]
420 fn template_emits_stray_brace_verbatim() {
421 let (_t, store) = store_with_template("note", "literal { brace {title}");
422 let f = fm("type: note\ntitle: Hello\n");
423 assert_eq!(
424 compose_default(&store, "note", &f, "").unwrap(),
425 "literal { brace Hello"
426 );
427 }
428
429 #[test]
430 fn template_is_deterministic_across_calls() {
431 let (_t, store) = store_with_template("contact", "{role} ({last_touch})");
432 let f = fm("type: contact\nrole: Ops Lead\nlast_touch: 2026-05-22\n");
433 let a = compose_default(&store, "contact", &f, "body").unwrap();
434 let b = compose_default(&store, "contact", &f, "body").unwrap();
435 assert_eq!(a, b);
436 assert_eq!(a, "Ops Lead (2026-05-22)");
437 }
438
439 #[test]
440 fn no_schema_for_type_falls_back_to_body() {
441 let (_t, store) = store_with_template("contact", "{role}");
444 let f = fm("type: note\n");
445 assert_eq!(
446 compose_default(&store, "note", &f, "Body sentence here.").unwrap(),
447 "Body sentence here."
448 );
449 }
450
451 #[test]
454 fn unknown_type_uses_first_non_heading_paragraph() {
455 let (_t, store) = store_with(Config::default());
456 let f = fm("type: proposal\n");
457 let body = "# Title\n\nThis proposal covers the Q3 roadmap.\n\nSecond paragraph.\n";
458 let got = compose_default(&store, "proposal", &f, body).unwrap();
459 assert_eq!(got, "This proposal covers the Q3 roadmap.");
460 }
461
462 #[test]
463 fn first_paragraph_joins_wrapped_lines_until_blank() {
464 let body = "Line one\nline two\n\nlater paragraph";
465 assert_eq!(first_paragraph(body).as_deref(), Some("Line one line two"));
466 }
467
468 #[test]
469 fn first_paragraph_none_for_heading_only_body() {
470 assert_eq!(first_paragraph("# Just a heading\n## And another\n"), None);
471 }
472
473 #[test]
474 fn unknown_type_long_paragraph_is_capped_at_200() {
475 let (_t, store) = store_with(Config::default());
476 let f = fm("type: note\n");
477 let long = "word ".repeat(100); let got = compose_default(&store, "note", &f, &long).unwrap();
479 assert!(got.chars().count() <= MAX_SUMMARY_LEN);
480 assert!(got.chars().count() >= MAX_SUMMARY_LEN - 5); }
482
483 #[test]
486 fn reduce_wiki_link_takes_leaf_segment() {
487 assert_eq!(
488 reduce_wiki_link("[[records/companies/northstar]]"),
489 "northstar"
490 );
491 }
492
493 #[test]
494 fn reduce_wiki_link_prefers_display() {
495 assert_eq!(
496 reduce_wiki_link("[[records/companies/x|Northstar Inc]]"),
497 "Northstar Inc"
498 );
499 }
500
501 #[test]
502 fn reduce_wiki_link_strips_md_extension() {
503 assert_eq!(reduce_wiki_link("[[records/companies/x.md]]"), "x");
504 }
505
506 #[test]
507 fn reduce_wiki_link_passes_through_plain_text() {
508 assert_eq!(reduce_wiki_link("just a vendor name"), "just a vendor name");
509 }
510}