use serde_norway::Value;
use crate::parser::Frontmatter;
use crate::store::Store;
pub const MAX_SUMMARY_LEN: usize = 200;
pub fn compose_default(
store: &Store,
type_: &str,
frontmatter: &Frontmatter,
body: &str,
) -> crate::Result<String> {
let composed = match store
.config
.schemas
.get(type_)
.and_then(|s| s.summary_template.as_deref())
{
Some(template) => render_template(template, frontmatter),
None => compose_from_body(body),
};
Ok(normalize(&composed))
}
fn render_template(template: &str, fm: &Frontmatter) -> String {
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some(open) = rest.find('{') {
out.push_str(&rest[..open]);
let after = &rest[open + 1..];
let close = after.find('}');
let next_open = after.find('{');
match close {
Some(c) if next_open.is_none_or(|n| n > c) => {
let key = after[..c].trim();
if let Some(scalar) = field_text(fm, key) {
out.push_str(&scalar);
} else {
let list = list_field_texts(fm, key);
if !list.is_empty() {
out.push_str(&list.join(", "));
}
}
rest = &after[c + 1..];
}
_ => {
out.push('{');
rest = after;
}
}
}
out.push_str(rest);
out
}
pub fn compose_from_body(body: &str) -> String {
first_paragraph(body).unwrap_or_default()
}
pub fn collapse_whitespace(candidate: &str) -> String {
candidate.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub fn normalize(candidate: &str) -> String {
truncate_chars(&collapse_whitespace(candidate), MAX_SUMMARY_LEN)
}
fn truncate_chars(s: &str, max: usize) -> String {
match s.char_indices().nth(max) {
Some((byte_idx, _)) => s[..byte_idx].to_string(),
None => s.to_string(),
}
}
fn field_value(fm: &Frontmatter, key: &str) -> Option<Value> {
match key {
"type" => fm.type_.clone().map(Value::String),
"id" => fm.id.clone().map(Value::String),
"summary" => fm.summary.clone().map(Value::String),
"status" => fm.status.clone().map(Value::String),
"created" => fm.created.map(|t| Value::String(t.to_rfc3339())),
"updated" => fm.updated.map(|t| Value::String(t.to_rfc3339())),
"tags" => {
if fm.tags.is_empty() {
None
} else {
Some(Value::Sequence(
fm.tags.iter().cloned().map(Value::String).collect(),
))
}
}
_ => fm.extra.get(key).cloned(),
}
}
fn field_text(fm: &Frontmatter, key: &str) -> Option<String> {
let v = field_value(fm, key)?;
let rendered = render_scalar(&v)?;
let trimmed = rendered.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn list_field_texts(fm: &Frontmatter, key: &str) -> Vec<String> {
let Some(v) = field_value(fm, key) else {
return Vec::new();
};
match v {
Value::Sequence(items) => items
.iter()
.filter_map(|item| {
let r = render_scalar(item)?;
let t = r.trim();
if t.is_empty() {
None
} else {
Some(t.to_string())
}
})
.collect(),
other => render_scalar(&other)
.map(|r| r.trim().to_string())
.filter(|t| !t.is_empty())
.into_iter()
.collect(),
}
}
fn render_scalar(v: &Value) -> Option<String> {
match v {
Value::String(s) => Some(reduce_wiki_link(s)),
Value::Sequence(_) => render_unquoted_wiki_link(v),
Value::Bool(b) => Some(b.to_string()),
Value::Number(n) => {
Some(n.to_string())
}
Value::Null | Value::Mapping(_) | Value::Tagged(_) => None,
}
}
fn render_unquoted_wiki_link(v: &Value) -> Option<String> {
let Value::Sequence(outer) = v else {
return None;
};
if outer.len() != 1 {
return None;
}
let Value::Sequence(inner) = &outer[0] else {
return None;
};
let [Value::String(target)] = inner.as_slice() else {
return None;
};
Some(reduce_wiki_link(&format!("[[{target}]]")))
}
fn reduce_wiki_link(s: &str) -> String {
let trimmed = s.trim();
let inner = trimmed
.strip_prefix("[[")
.and_then(|rest| rest.strip_suffix("]]"));
let Some(inner) = inner else {
return s.to_string();
};
if inner.contains("[[") || inner.contains("]]") {
return s.to_string();
}
let (target, display) = match inner.split_once('|') {
Some((t, d)) => (t, Some(d)),
None => (inner, None),
};
if let Some(d) = display {
let d = d.trim();
if !d.is_empty() {
return d.to_string();
}
}
let leaf = target.trim().rsplit('/').next().unwrap_or(target).trim();
leaf.strip_suffix(".md").unwrap_or(leaf).to_string()
}
fn first_paragraph(body: &str) -> Option<String> {
let lines: Vec<&str> = body.lines().collect();
let mut collected: Vec<&str> = Vec::new();
let mut i = 0;
while i < lines.len() {
let raw = lines[i];
let t = raw.trim();
if collected.is_empty() {
if let Some(fence) = code_fence_marker(t) {
i += 1;
while i < lines.len() {
let inner = lines[i].trim();
i += 1;
if closes_code_fence(inner, fence) {
break;
}
}
continue;
}
}
if t.is_empty() {
if collected.is_empty() {
i += 1;
continue;
}
break;
}
if is_atx_heading(t) {
if collected.is_empty() {
i += 1;
continue;
}
break;
}
if collected.is_empty() {
if let Some(next) = lines.get(i + 1).map(|l| l.trim()) {
if is_setext_underline(next) {
i += 2;
continue;
}
}
}
collected.push(t);
i += 1;
}
if collected.is_empty() {
None
} else {
Some(collected.join(" "))
}
}
fn is_atx_heading(line: &str) -> bool {
let hashes = line.chars().take_while(|&c| c == '#').count();
if hashes == 0 || hashes > 6 {
return false;
}
match line[hashes..].chars().next() {
None => true, Some(c) => c == ' ' || c == '\t', }
}
fn code_fence_marker(line: &str) -> Option<char> {
let first = line.chars().next()?;
if first != '`' && first != '~' {
return None;
}
let run = line.chars().take_while(|&c| c == first).count();
if run >= 3 {
Some(first)
} else {
None
}
}
fn closes_code_fence(line: &str, fence: char) -> bool {
let run = line.chars().take_while(|&c| c == fence).count();
run >= 3 && line.chars().all(|c| c == fence)
}
fn is_setext_underline(line: &str) -> bool {
(!line.is_empty() && line.chars().all(|c| c == '='))
|| (!line.is_empty() && line.chars().all(|c| c == '-'))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::{Config, Schema};
use std::fs;
use tempfile::TempDir;
fn store_with(config: Config) -> (TempDir, Store) {
let tmp = TempDir::new().expect("tempdir");
let root = tmp.path().to_path_buf();
fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").expect("write DB.md");
let store = Store { root, config };
(tmp, store)
}
fn store_with_template(type_: &str, template: &str) -> (TempDir, Store) {
let mut config = Config::default();
config.schemas.insert(
type_.to_string(),
Schema {
summary_template: Some(template.to_string()),
..Schema::default()
},
);
store_with(config)
}
fn fm(yaml: &str) -> Frontmatter {
let value: Value = serde_norway::from_str(yaml).expect("test yaml parses");
let mapping = value.as_mapping().expect("test yaml is a mapping").clone();
let mut f = Frontmatter::default();
for (k, v) in mapping {
let key = k.as_str().expect("string key").to_string();
match key.as_str() {
"type" => f.type_ = v.as_str().map(str::to_string),
"summary" => f.summary = v.as_str().map(str::to_string),
"id" => f.id = v.as_str().map(str::to_string),
"status" => f.status = v.as_str().map(str::to_string),
"tags" => {
if let Value::Sequence(items) = &v {
f.tags = items
.iter()
.filter_map(|i| i.as_str().map(str::to_string))
.collect();
}
}
"created" => {
f.created = v
.as_str()
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
}
"updated" => {
f.updated = v
.as_str()
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
}
_ => {
f.extra.insert(key, v);
}
}
}
f
}
#[test]
fn normalize_collapses_newlines_and_runs_to_single_spaces() {
let got = normalize("first line\nsecond\t\tline third");
assert_eq!(got, "first line second line third");
}
#[test]
fn normalize_trims_surrounding_whitespace() {
assert_eq!(normalize(" padded value \n"), "padded value");
}
#[test]
fn normalize_caps_at_200_chars_on_char_boundary() {
let input = "é".repeat(250);
let got = normalize(&input);
assert_eq!(got.chars().count(), MAX_SUMMARY_LEN);
assert_eq!(got, "é".repeat(MAX_SUMMARY_LEN));
}
#[test]
fn normalize_leaves_short_strings_untouched() {
assert_eq!(normalize("short"), "short");
}
#[test]
fn regression_collapse_whitespace_preserves_long_explicit_summary() {
let long = format!(
"Director of Operations at Northstar; renewal champion who drove the 175-seat expansion and {}",
"x".repeat(150)
);
assert!(long.chars().count() > MAX_SUMMARY_LEN);
let collapsed = collapse_whitespace(&long);
assert_eq!(collapsed.chars().count(), long.chars().count());
assert_eq!(collapsed, long);
assert!(normalize(&long).chars().count() == MAX_SUMMARY_LEN);
assert_ne!(collapse_whitespace(&long), normalize(&long));
}
#[test]
fn collapse_whitespace_still_collapses_to_single_line() {
assert_eq!(
collapse_whitespace(" multi\nline\tsummary "),
"multi line summary"
);
}
#[test]
fn template_interpolates_scalar_fields() {
let (_t, store) =
store_with_template("contact", "{role} at {company} (last_touch: {last_touch})");
let f = fm("type: contact\n\
role: Director of Operations\n\
company: \"[[records/companies/northstar]]\"\n\
last_touch: 2026-05-22\n");
assert_eq!(
compose_default(&store, "contact", &f, "ignored body").unwrap(),
"Director of Operations at northstar (last_touch: 2026-05-22)"
);
}
#[test]
fn template_interpolates_unquoted_scalar_wiki_link_fields() {
let (_t, store) = store_with_template("contact", "{role} at {company}");
let f = fm("type: contact\n\
role: Director\n\
company: [[records/companies/northstar]]\n");
assert_eq!(
compose_default(&store, "contact", &f, "").unwrap(),
"Director at northstar"
);
}
#[test]
fn template_drops_absent_fields_to_empty() {
let (_t, store) = store_with_template("contact", "{role} at {company}");
let f = fm("type: contact\nrole: Advisor\n");
assert_eq!(
compose_default(&store, "contact", &f, "").unwrap(),
"Advisor at"
);
}
#[test]
fn template_joins_list_fields_comma_separated() {
let (_t, store) = store_with_template("meeting", "{date}: {attendees}");
let f = fm("type: meeting\n\
date: 2026-05-10\n\
attendees:\n\
\x20 - \"[[records/contacts/alice]]\"\n\
\x20 - \"[[records/contacts/bob]]\"\n");
assert_eq!(
compose_default(&store, "meeting", &f, "").unwrap(),
"2026-05-10: alice, bob"
);
}
#[test]
fn template_interpolates_typed_tags_created_updated() {
let (_t, store) = store_with_template("note", "{tags} | {created}");
let f = fm("type: note\ntags: [urgent, q3]\ncreated: \"2026-05-01T00:00:00Z\"\n");
assert_eq!(
compose_default(&store, "note", &f, "").unwrap(),
"urgent, q3 | 2026-05-01T00:00:00+00:00"
);
}
#[test]
fn template_joins_unquoted_block_wiki_link_list_fields() {
let (_t, store) = store_with_template("meeting", "{attendees}");
let f = fm("type: meeting\n\
attendees:\n\
\x20 - [[records/contacts/alice]]\n\
\x20 - [[records/contacts/bob]]\n");
assert_eq!(
compose_default(&store, "meeting", &f, "").unwrap(),
"alice, bob"
);
}
#[test]
fn template_emits_stray_brace_verbatim() {
let (_t, store) = store_with_template("note", "literal { brace {title}");
let f = fm("type: note\ntitle: Hello\n");
assert_eq!(
compose_default(&store, "note", &f, "").unwrap(),
"literal { brace Hello"
);
}
#[test]
fn template_is_deterministic_across_calls() {
let (_t, store) = store_with_template("contact", "{role} ({last_touch})");
let f = fm("type: contact\nrole: Ops Lead\nlast_touch: 2026-05-22\n");
let a = compose_default(&store, "contact", &f, "body").unwrap();
let b = compose_default(&store, "contact", &f, "body").unwrap();
assert_eq!(a, b);
assert_eq!(a, "Ops Lead (2026-05-22)");
}
#[test]
fn no_schema_for_type_falls_back_to_body() {
let (_t, store) = store_with_template("contact", "{role}");
let f = fm("type: note\n");
assert_eq!(
compose_default(&store, "note", &f, "Body sentence here.").unwrap(),
"Body sentence here."
);
}
#[test]
fn unknown_type_uses_first_non_heading_paragraph() {
let (_t, store) = store_with(Config::default());
let f = fm("type: proposal\n");
let body = "# Title\n\nThis proposal covers the Q3 roadmap.\n\nSecond paragraph.\n";
let got = compose_default(&store, "proposal", &f, body).unwrap();
assert_eq!(got, "This proposal covers the Q3 roadmap.");
}
#[test]
fn first_paragraph_joins_wrapped_lines_until_blank() {
let body = "Line one\nline two\n\nlater paragraph";
assert_eq!(first_paragraph(body).as_deref(), Some("Line one line two"));
}
#[test]
fn first_paragraph_none_for_heading_only_body() {
assert_eq!(first_paragraph("# Just a heading\n## And another\n"), None);
}
#[test]
fn unknown_type_long_paragraph_is_capped_at_200() {
let (_t, store) = store_with(Config::default());
let f = fm("type: note\n");
let long = "word ".repeat(100); let got = compose_default(&store, "note", &f, &long).unwrap();
assert!(got.chars().count() <= MAX_SUMMARY_LEN);
assert!(got.chars().count() >= MAX_SUMMARY_LEN - 5); }
#[test]
fn reduce_wiki_link_takes_leaf_segment() {
assert_eq!(
reduce_wiki_link("[[records/companies/northstar]]"),
"northstar"
);
}
#[test]
fn reduce_wiki_link_prefers_display() {
assert_eq!(
reduce_wiki_link("[[records/companies/x|Northstar Inc]]"),
"Northstar Inc"
);
}
#[test]
fn reduce_wiki_link_strips_md_extension() {
assert_eq!(reduce_wiki_link("[[records/companies/x.md]]"), "x");
}
#[test]
fn reduce_wiki_link_passes_through_plain_text() {
assert_eq!(reduce_wiki_link("just a vendor name"), "just a vendor name");
}
#[test]
fn regression_reduce_wiki_link_multiple_links_passthrough() {
let s = "[[records/companies/acme]] and [[records/companies/globex]]";
assert_eq!(reduce_wiki_link(s), s);
assert_eq!(reduce_wiki_link("[[records/companies/acme]]"), "acme");
assert_eq!(reduce_wiki_link("Acme and Globex"), "Acme and Globex");
}
#[test]
fn regression_first_paragraph_skips_setext_heading() {
let body = "Launch Plan\n===========\n\nThis is the real first paragraph of prose.\n";
assert_eq!(
first_paragraph(body).as_deref(),
Some("This is the real first paragraph of prose.")
);
let body = "Section\n-------\n\nBody prose follows.\n";
assert_eq!(
first_paragraph(body).as_deref(),
Some("Body prose follows.")
);
}
#[test]
fn regression_first_paragraph_hash_without_space_is_prose() {
assert_eq!(
first_paragraph("#1 priority this week: fix onboarding drop-off.\n").as_deref(),
Some("#1 priority this week: fix onboarding drop-off.")
);
assert_eq!(
first_paragraph("#hashtag notes about the launch\n").as_deref(),
Some("#hashtag notes about the launch")
);
assert_eq!(
first_paragraph("#1 priority: X\n\nSecond para.\n").as_deref(),
Some("#1 priority: X")
);
assert_eq!(
first_paragraph("# Real heading\n\nThe actual prose.\n").as_deref(),
Some("The actual prose.")
);
assert_eq!(
first_paragraph("###\n\nProse.\n").as_deref(),
Some("Prose.")
);
}
#[test]
fn regression_first_paragraph_skips_leading_fenced_code_block() {
let body =
"```bash\n# install dependencies\nnpm install\n```\n\nReal prose paragraph here.\n";
assert_eq!(
first_paragraph(body).as_deref(),
Some("Real prose paragraph here.")
);
let body = "~~~\ncode line\n~~~\n\nProse after tilde fence.\n";
assert_eq!(
first_paragraph(body).as_deref(),
Some("Prose after tilde fence.")
);
}
#[test]
fn compose_from_body_handles_hash_prose_setext_and_fence() {
assert_eq!(
compose_from_body("#1 priority this week: fix onboarding.\n"),
"#1 priority this week: fix onboarding."
);
assert_eq!(
compose_from_body("Launch Plan\n===========\n\nThe real prose.\n"),
"The real prose."
);
assert_eq!(
compose_from_body("```bash\n# step\n```\n\nThe real prose.\n"),
"The real prose."
);
}
#[test]
fn is_atx_heading_applies_commonmark_space_rule() {
assert!(is_atx_heading("# Title"));
assert!(is_atx_heading("###### Deep"));
assert!(is_atx_heading("###")); assert!(!is_atx_heading("#1 priority"));
assert!(!is_atx_heading("#hashtag"));
assert!(!is_atx_heading("####### too many")); assert!(!is_atx_heading("plain"));
}
#[test]
fn code_fence_and_setext_helpers() {
assert_eq!(code_fence_marker("```bash"), Some('`'));
assert_eq!(code_fence_marker("~~~"), Some('~'));
assert_eq!(code_fence_marker("``"), None); assert_eq!(code_fence_marker("plain"), None);
assert!(closes_code_fence("```", '`'));
assert!(!closes_code_fence("```bash", '`')); assert!(!closes_code_fence("~~~", '`')); assert!(is_setext_underline("==="));
assert!(is_setext_underline("---"));
assert!(!is_setext_underline("- item")); assert!(!is_setext_underline(""));
}
}