use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
pub const ALL: &str = "all";
fn string_or_seq<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Vec<String>, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
One(String),
Many(Vec<String>),
}
Ok(match Option::<OneOrMany>::deserialize(d)? {
None => Vec::new(),
Some(OneOrMany::One(s)) => vec![s],
Some(OneOrMany::Many(v)) => v,
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodeRef {
pub repo: String,
pub sha: String,
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub range: Option<[u64; 2]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_hash: Option<String>,
}
fn is_false(b: &bool) -> bool {
!*b
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Frontmatter {
pub id: String,
pub from: String,
#[serde(rename = "type")]
pub msg_type: String,
pub ts: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
#[serde(
default,
deserialize_with = "string_or_seq",
skip_serializing_if = "Vec::is_empty"
)]
pub to: Vec<String>,
#[serde(
default,
deserialize_with = "string_or_seq",
skip_serializing_if = "Vec::is_empty"
)]
pub cc: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub priority: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub topic: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reply_to: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub of: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supersedes: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolution: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub defer: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub via: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub refs: Vec<CodeRef>,
}
#[derive(Debug, Clone)]
pub struct Message {
pub front: Frontmatter,
pub body: String,
}
impl Message {
pub fn to_markdown(&self) -> Result<String> {
let yaml = serde_yaml::to_string(&self.front)?;
Ok(format!("---\n{yaml}---\n\n{}\n", self.body.trim_end()))
}
pub fn summary_line(&self) -> String {
let s = self.front.summary.as_deref().unwrap_or("").trim();
let raw = if !s.is_empty() {
s.to_string()
} else {
self.body
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.unwrap_or("")
.to_string()
};
sanitize_term(&raw, false)
}
}
pub fn sanitize_term(s: &str, multiline: bool) -> String {
s.chars()
.filter(|&c| match c {
'\n' | '\t' => multiline,
_ => {
let cp = c as u32;
!(cp < 0x20 || cp == 0x7f || (0x80..=0x9f).contains(&cp))
}
})
.collect()
}
pub fn parse_message(text: &str) -> Result<Message> {
let mut lines = text.lines();
if lines.next().map(str::trim_end) != Some("---") {
bail!("missing YAML frontmatter (no leading ---)");
}
let mut yaml = String::new();
let mut body = String::new();
let mut in_body = false;
for line in lines {
if !in_body && line.trim_end() == "---" {
in_body = true;
continue;
}
if in_body {
body.push_str(line);
body.push('\n');
} else {
yaml.push_str(line);
yaml.push('\n');
}
}
let front: Frontmatter = serde_yaml::from_str(&yaml)?;
Ok(Message {
front,
body: body.trim().to_string(),
})
}
pub const TYPES: [&str; 8] = [
"note", "request", "claim", "done", "error", "supersede", "blocked", "defer",
];
pub fn is_actionable(m: &Message) -> bool {
matches!(
m.front.msg_type.as_str(),
"request" | "claim" | "done" | "error" | "blocked"
)
}
pub fn targets(m: &Message) -> impl Iterator<Item = &String> {
m.front.to.iter().chain(m.front.cc.iter())
}
pub fn is_recipient(m: &Message, me: &str) -> bool {
targets(m).any(|t| t == me || t == ALL)
}
#[cfg(test)]
mod tests {
use super::*;
fn msg(msg_type: &str, body: &str) -> Message {
Message {
front: Frontmatter {
id: "01J8Z9K3QH7X".into(),
from: "carol".into(),
msg_type: msg_type.into(),
ts: "2026-07-08T17:20:04Z".into(),
host: Some("host-a.local".into()),
to: vec!["bob".into()],
cc: vec!["alice".into()],
priority: Some("high".into()),
topic: Some("px".into()),
reply_to: None,
of: None,
supersedes: None,
resolution: None,
defer: false,
via: None,
src: None,
summary: Some("wire it".into()),
refs: vec![],
},
body: body.into(),
}
}
#[test]
fn round_trip_preserves_fields_and_body() {
let m = msg("request", "hello\n\nsecond para");
let parsed = parse_message(&m.to_markdown().unwrap()).unwrap();
assert_eq!(parsed.front.id, m.front.id);
assert_eq!(parsed.front.msg_type, "request");
assert_eq!(parsed.front.to, vec!["bob".to_string()]);
assert_eq!(parsed.front.cc, vec!["alice".to_string()]);
assert_eq!(parsed.front.priority.as_deref(), Some("high"));
assert_eq!(parsed.body, "hello\n\nsecond para");
}
#[test]
fn body_with_fenced_code_and_dashes_survives() {
let body = "Look:\n\n```yaml\nx: 1\n---\ny: 2\n```\n\nthematic break below\n\n---\n\ndone";
let m = msg("note", body);
let parsed = parse_message(&m.to_markdown().unwrap()).unwrap();
assert_eq!(parsed.body, body);
}
#[test]
fn crlf_frontmatter_parses() {
let text = "---\r\nid: x\r\nfrom: carol\r\ntype: note\r\nts: t\r\nsummary: s\r\n---\r\nbody line\r\n";
let parsed = parse_message(text).unwrap();
assert_eq!(parsed.front.id, "x");
assert_eq!(parsed.body, "body line");
}
#[test]
fn malformed_yaml_errors_not_panics() {
assert!(parse_message("---\n: : : not yaml\n---\nbody").is_err());
}
#[test]
fn missing_frontmatter_errors() {
assert!(parse_message("just a body, no frontmatter").is_err());
}
#[test]
fn summary_line_falls_back_to_first_nonblank_body_line() {
let mut m = msg("note", "\n\n first real line \nsecond");
m.front.summary = None;
assert_eq!(m.summary_line(), "first real line");
}
#[test]
fn sanitize_term_strips_control_keeps_content() {
assert_eq!(sanitize_term("a\x1b[31mred\x1b[0m", true), "a[31mred[0m");
assert_eq!(sanitize_term("hi\x07\x7f\u{85}there", true), "hithere");
assert_eq!(sanitize_term("‼ done → ok ⟶ repo:x", false), "‼ done → ok ⟶ repo:x");
assert_eq!(sanitize_term("line1\nline2\tcol", false), "line1line2col");
assert_eq!(sanitize_term("line1\nline2\tcol", true), "line1\nline2\tcol");
}
#[test]
fn summary_line_sanitizes_ansi() {
let mut m = msg("note", "body");
m.front.summary = Some("evil\x1b]8;;http://x\x07click".to_string());
assert_eq!(m.summary_line(), "evil]8;;http://xclick");
}
#[test]
fn is_recipient_matches_to_cc_and_all() {
let m = msg("request", "x");
assert!(is_recipient(&m, "bob")); assert!(is_recipient(&m, "alice")); assert!(!is_recipient(&m, "nobody"));
let mut m2 = msg("note", "x");
m2.front.to = vec![ALL.into()];
m2.front.cc = vec![];
assert!(is_recipient(&m2, "anyone")); }
#[test]
fn is_actionable_excludes_notes() {
assert!(is_actionable(&msg("request", "x")));
assert!(!is_actionable(&msg("note", "x")));
}
}