use serde_yml::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 type_ {
"contact" => compose_contact(store, frontmatter)?,
"company" => compose_company(frontmatter),
"expense" => compose_expense(frontmatter),
"meeting" => compose_meeting(frontmatter),
"decision" => compose_decision(frontmatter, body),
"invoice" => compose_invoice(frontmatter),
"email" => compose_email(frontmatter),
"transcript" => compose_transcript(frontmatter),
"pdf-source" => compose_pdf_source(frontmatter),
"wiki-page" => compose_wiki_page(frontmatter, body),
_ => compose_from_body(body),
};
Ok(normalize(&composed))
}
pub fn compose_contact(store: &Store, fm: &Frontmatter) -> crate::Result<String> {
let role = field_text(fm, "role");
let company = resolve_company_name(store, fm);
let last_touch = field_text(fm, "last_touch");
let mut out = match role {
Some(r) => r,
None => "Contact".to_string(),
};
if let Some(c) = company {
out.push_str(" at ");
out.push_str(&c);
}
if let Some(d) = last_touch {
out.push_str(" (last_touch: ");
out.push_str(&d);
out.push(')');
}
Ok(out)
}
pub fn compose_company(fm: &Frontmatter) -> String {
join_present(
"; ",
[field_text(fm, "relationship"), field_text(fm, "industry")],
)
}
pub fn compose_expense(fm: &Frontmatter) -> String {
let money = join_present(" ", [field_text(fm, "amount"), field_text(fm, "currency")]);
let money = if money.is_empty() { None } else { Some(money) };
join_present(
" — ",
[field_text(fm, "date"), money, field_text(fm, "vendor")],
)
}
pub fn compose_meeting(fm: &Frontmatter) -> String {
let attendees = list_field_texts(fm, "attendees");
let shown: Vec<String> = attendees.iter().take(3).cloned().collect();
let extra = attendees.len().saturating_sub(shown.len());
let people = if shown.is_empty() {
None
} else {
let mut s = shown.join(", ");
if extra > 0 {
s.push_str(&format!(" (+{extra} more)"));
}
Some(s)
};
join_present(" — ", [field_text(fm, "date"), people])
}
pub fn compose_decision(fm: &Frontmatter, body: &str) -> String {
let title = first_heading(body).or_else(|| first_paragraph(body));
match (field_text(fm, "decided_by"), title) {
(Some(who), Some(t)) => format!("{who}: {t}"),
(Some(who), None) => who,
(None, Some(t)) => t,
(None, None) => String::new(),
}
}
pub fn compose_invoice(fm: &Frontmatter) -> String {
join_present(
" — ",
[
field_text(fm, "vendor"),
field_text(fm, "amount"),
field_text(fm, "status"),
],
)
}
pub fn compose_email(fm: &Frontmatter) -> String {
let to = {
let list = list_field_texts(fm, "to");
if list.is_empty() {
None
} else {
Some(list.join(", "))
}
};
let route = match (field_text(fm, "from"), to) {
(Some(f), Some(t)) => Some(format!("{f} → {t}")),
(Some(f), None) => Some(f),
(None, Some(t)) => Some(format!("→ {t}")),
(None, None) => None,
};
join_present(" — ", [route, field_text(fm, "subject")])
}
pub fn compose_transcript(fm: &Frontmatter) -> String {
let attendees = {
let list = list_field_texts(fm, "attendees");
if list.is_empty() {
None
} else {
Some(list.join(", "))
}
};
join_present(" — ", [field_text(fm, "recorded_at"), attendees])
}
pub fn compose_pdf_source(fm: &Frontmatter) -> String {
match (field_text(fm, "doc_type"), field_text(fm, "received_from")) {
(Some(dt), Some(rf)) => format!("{dt} from {rf}"),
(Some(dt), None) => dt,
(None, Some(rf)) => format!("from {rf}"),
(None, None) => String::new(),
}
}
pub fn compose_wiki_page(fm: &Frontmatter, body: &str) -> String {
field_text(fm, "topic").unwrap_or_else(|| compose_from_body(body))
}
pub fn compose_from_body(body: &str) -> String {
first_paragraph(body).unwrap_or_default()
}
pub fn normalize(candidate: &str) -> String {
let collapsed = candidate.split_whitespace().collect::<Vec<_>>().join(" ");
truncate_chars(&collapsed, 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),
_ => 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::Bool(b) => Some(b.to_string()),
Value::Number(n) => {
Some(n.to_string())
}
Value::Null | Value::Sequence(_) | Value::Mapping(_) | Value::Tagged(_) => None,
}
}
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();
};
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 join_present<const N: usize>(sep: &str, parts: [Option<String>; N]) -> String {
parts
.into_iter()
.flatten()
.filter(|p| !p.is_empty())
.collect::<Vec<_>>()
.join(sep)
}
fn first_heading(body: &str) -> Option<String> {
for line in body.lines() {
let t = line.trim();
if let Some(rest) = t.strip_prefix('#') {
let text = rest.trim_start_matches('#').trim();
if !text.is_empty() {
return Some(text.to_string());
}
}
}
None
}
fn first_paragraph(body: &str) -> Option<String> {
let mut collected: Vec<&str> = Vec::new();
for line in body.lines() {
let t = line.trim();
if t.is_empty() {
if collected.is_empty() {
continue;
}
break;
}
if t.starts_with('#') {
if collected.is_empty() {
continue;
}
break;
}
collected.push(t);
}
if collected.is_empty() {
None
} else {
Some(collected.join(" "))
}
}
fn resolve_company_name(store: &Store, fm: &Frontmatter) -> Option<String> {
let raw = match field_value(fm, "company")? {
Value::String(s) => s,
Value::Sequence(items) => items.iter().find_map(|i| i.as_str().map(str::to_string))?,
_ => return None,
};
let fallback = {
let f = reduce_wiki_link(&raw);
let f = f.trim();
if f.is_empty() {
None
} else {
Some(f.to_string())
}
};
let Some(target) = wiki_link_target(&raw) else {
return fallback;
};
let mut abs = store.root.join(&target);
abs.set_extension("md");
match read_frontmatter_name(&abs) {
Some(name) if !name.trim().is_empty() => Some(name.trim().to_string()),
_ => fallback,
}
}
fn wiki_link_target(s: &str) -> Option<String> {
let inner = s
.trim()
.strip_prefix("[[")
.and_then(|rest| rest.strip_suffix("]]"))?;
let target = inner
.split_once('|')
.map(|(t, _)| t)
.unwrap_or(inner)
.trim();
let target = target.strip_suffix(".md").unwrap_or(target);
if target.is_empty() {
None
} else {
Some(target.to_string())
}
}
fn read_frontmatter_name(abs: &std::path::Path) -> Option<String> {
let text = std::fs::read_to_string(abs).ok()?;
let yaml = frontmatter_block(&text)?;
let value: Value = serde_yml::from_str(&yaml).ok()?;
value.get("name")?.as_str().map(str::to_string)
}
fn frontmatter_block(text: &str) -> Option<String> {
let mut lines = text.lines();
let first = lines.next()?.trim_start_matches('\u{feff}').trim_end();
if first != "---" {
return None;
}
let mut block = String::new();
for line in lines {
if line.trim_end() == "---" {
return Some(block);
}
block.push_str(line);
block.push('\n');
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::Config;
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
struct Fixture {
_tmp: TempDir,
store: Store,
}
impl Fixture {
fn new() -> Self {
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: Config::default(),
};
Fixture { _tmp: tmp, store }
}
fn write_company(&self, rel_no_ext: &str, name: &str) {
let mut path: PathBuf = self.store.root.join(rel_no_ext);
path.set_extension("md");
fs::create_dir_all(path.parent().unwrap()).expect("mkdir");
let contents =
format!("---\ntype: company\nname: {name}\nindustry: SaaS\n---\n\n# {name}\n");
fs::write(path, contents).expect("write company");
}
}
fn fm(yaml: &str) -> Frontmatter {
let value: Value = serde_yml::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),
_ => {
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 contact_resolves_company_link_to_company_name() {
let fx = Fixture::new();
fx.write_company("records/companies/northstar", "Northstar Logistics");
let f = fm("type: contact\n\
role: Director of Operations\n\
company: \"[[records/companies/northstar]]\"\n\
last_touch: 2026-05-22\n");
let got = compose_default(&fx.store, "contact", &f, "").unwrap();
assert_eq!(
got,
"Director of Operations at Northstar Logistics (last_touch: 2026-05-22)"
);
}
#[test]
fn contact_falls_back_to_link_leaf_when_company_file_missing() {
let fx = Fixture::new();
let f = fm("type: contact\n\
role: VP Sales\n\
company: \"[[records/companies/acme-corp]]\"\n\
last_touch: 2026-01-02\n");
let got = compose_default(&fx.store, "contact", &f, "").unwrap();
assert_eq!(got, "VP Sales at acme-corp (last_touch: 2026-01-02)");
assert!(!got.contains("[["));
}
#[test]
fn contact_prefers_link_display_override_on_fallback() {
let fx = Fixture::new();
let f = fm("type: contact\n\
role: Founder\n\
company: \"[[records/companies/acme|Acme Inc]]\"\n");
let got = compose_contact(&fx.store, &f).unwrap();
assert_eq!(got, "Founder at Acme Inc");
}
#[test]
fn contact_drops_company_segment_when_absent() {
let fx = Fixture::new();
let f = fm("type: contact\nrole: Advisor\nlast_touch: 2026-03-03\n");
let got = compose_contact(&fx.store, &f).unwrap();
assert_eq!(got, "Advisor (last_touch: 2026-03-03)");
}
#[test]
fn contact_uses_placeholder_when_role_absent() {
let fx = Fixture::new();
fx.write_company("records/companies/northstar", "Northstar");
let f = fm("type: contact\n\
company: \"[[records/companies/northstar]]\"\n");
let got = compose_contact(&fx.store, &f).unwrap();
assert_eq!(got, "Contact at Northstar");
}
#[test]
fn company_joins_relationship_and_industry() {
let f = fm("type: company\nrelationship: customer\nindustry: Logistics\n");
assert_eq!(compose_company(&f), "customer; Logistics");
}
#[test]
fn company_drops_separator_when_one_field_missing() {
let f = fm("type: company\nrelationship: vendor\n");
assert_eq!(compose_company(&f), "vendor");
let f2 = fm("type: company\nindustry: Fintech\n");
assert_eq!(compose_company(&f2), "Fintech");
}
#[test]
fn expense_formats_date_amount_currency_vendor() {
let f = fm("type: expense\n\
date: 2026-04-01\n\
amount: 49.99\n\
currency: USD\n\
vendor: GitHub\n");
assert_eq!(compose_expense(&f), "2026-04-01 — 49.99 USD — GitHub");
}
#[test]
fn expense_renders_integer_amount_without_trailing_zero() {
let f = fm("type: expense\ndate: 2026-04-01\namount: 50\ncurrency: EUR\nvendor: AWS\n");
assert_eq!(compose_expense(&f), "2026-04-01 — 50 EUR — AWS");
}
#[test]
fn expense_drops_missing_segments() {
let f = fm("type: expense\namount: 12\ncurrency: USD\n");
assert_eq!(compose_expense(&f), "12 USD");
}
#[test]
fn meeting_lists_first_three_attendees_with_more_count() {
let f = fm("type: meeting\n\
date: 2026-05-10\n\
attendees:\n\
\x20 - \"[[records/contacts/alice]]\"\n\
\x20 - \"[[records/contacts/bob]]\"\n\
\x20 - \"[[records/contacts/carol]]\"\n\
\x20 - \"[[records/contacts/dave]]\"\n\
\x20 - \"[[records/contacts/erin]]\"\n");
let got = compose_meeting(&f);
assert_eq!(got, "2026-05-10 — alice, bob, carol (+2 more)");
}
#[test]
fn meeting_omits_more_suffix_at_three_or_fewer() {
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_meeting(&f), "2026-05-10 — alice, bob");
}
#[test]
fn meeting_with_only_date_has_no_dash() {
let f = fm("type: meeting\ndate: 2026-05-10\n");
assert_eq!(compose_meeting(&f), "2026-05-10");
}
#[test]
fn decision_uses_decided_by_and_first_heading() {
let f = fm("type: decision\ndecided_by: Carlos\n");
let body = "# Adopt Postgres over MySQL\n\nWe chose Postgres for JSONB.\n";
assert_eq!(
compose_decision(&f, body),
"Carlos: Adopt Postgres over MySQL"
);
}
#[test]
fn decision_falls_back_to_first_paragraph_without_heading() {
let f = fm("type: decision\ndecided_by: Board\n");
let body = "Ship the v2 pricing on June 1.\n";
assert_eq!(
compose_decision(&f, body),
"Board: Ship the v2 pricing on June 1."
);
}
#[test]
fn decision_strips_heading_hashes_at_any_depth() {
let f = fm("type: decision\ndecided_by: Eng\n");
let body = "### Use feature flags for the rollout\n";
assert_eq!(
compose_decision(&f, body),
"Eng: Use feature flags for the rollout"
);
}
#[test]
fn invoice_formats_vendor_amount_status() {
let f = fm("type: invoice\nvendor: Acme\namount: 1200\nstatus: paid\n");
assert_eq!(compose_invoice(&f), "Acme — 1200 — paid");
}
#[test]
fn email_formats_from_arrow_to_subject() {
let f = fm("type: email\n\
from: sarah@northstar.io\n\
to: carlos@example.com\n\
subject: Renewal terms\n");
assert_eq!(
compose_email(&f),
"sarah@northstar.io → carlos@example.com — Renewal terms"
);
}
#[test]
fn email_joins_multiple_recipients() {
let f = fm("type: email\n\
from: a@x.com\n\
to:\n\
\x20 - b@y.com\n\
\x20 - c@z.com\n\
subject: Kickoff\n");
assert_eq!(compose_email(&f), "a@x.com → b@y.com, c@z.com — Kickoff");
}
#[test]
fn transcript_formats_recorded_at_and_attendees() {
let f = fm("type: transcript\n\
recorded_at: 2026-02-14T09:00:00-08:00\n\
attendees:\n\
\x20 - Alice\n\
\x20 - Bob\n");
assert_eq!(
compose_transcript(&f),
"2026-02-14T09:00:00-08:00 — Alice, Bob"
);
}
#[test]
fn pdf_source_formats_doc_type_from_received_from() {
let f = fm("type: pdf-source\ndoc_type: contract\nreceived_from: Northstar Legal\n");
assert_eq!(compose_pdf_source(&f), "contract from Northstar Legal");
}
#[test]
fn wiki_page_prefers_topic_field() {
let f = fm("type: wiki-page\ntopic: Renewal strategy\n");
let body = "# Renewal strategy\n\nLots of detail here.\n";
assert_eq!(
compose_default(&Fixture::new().store, "wiki-page", &f, body).unwrap(),
"Renewal strategy"
);
}
#[test]
fn wiki_page_falls_back_to_first_paragraph_without_topic() {
let f = fm("type: wiki-page\n");
let body = "# Heading skipped\n\nThe synthesis of our pricing decisions.\n";
assert_eq!(
compose_wiki_page(&f, body),
"The synthesis of our pricing decisions."
);
}
#[test]
fn unknown_type_uses_first_non_heading_paragraph() {
let fx = Fixture::new();
let f = fm("type: proposal\n");
let body = "# Title\n\nThis proposal covers the Q3 roadmap.\n\nSecond paragraph.\n";
let got = compose_default(&fx.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 fx = Fixture::new();
let f = fm("type: note\n");
let long = "word ".repeat(100); let got = compose_default(&fx.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 compose_default_is_deterministic_across_calls() {
let fx = Fixture::new();
fx.write_company("records/companies/northstar", "Northstar");
let f = fm("type: contact\n\
role: Ops Lead\n\
company: \"[[records/companies/northstar]]\"\n\
last_touch: 2026-05-22\n");
let a = compose_default(&fx.store, "contact", &f, "body").unwrap();
let b = compose_default(&fx.store, "contact", &f, "body").unwrap();
let c = compose_default(&fx.store, "contact", &f, "body").unwrap();
assert_eq!(a, b);
assert_eq!(b, c);
}
#[test]
fn empty_frontmatter_company_yields_empty_summary() {
let f = fm("type: company\n");
assert_eq!(
compose_default(&Fixture::new().store, "company", &f, "").unwrap(),
""
);
}
}