entry.rs
Rust · 234 lines · 9.0 KB
//! An entry on disk: YAML frontmatter + `##` sections, ids and slugs.
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::BTreeMap;
#[derive(Serialize, Deserialize, Clone, Default, Debug)]
pub struct RefState {
pub hash: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub commit: String,
}
#[derive(Serialize, Deserialize, Clone, Default, Debug)]
pub struct Meta {
#[serde(default)]
pub title: String,
#[serde(default)]
pub kind: String,
#[serde(default)]
pub project: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub owner: String,
#[serde(default, deserialize_with = "tag_list", skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, deserialize_with = "plain_list", skip_serializing_if = "Vec::is_empty")]
pub engine: Vec<String>,
#[serde(default, deserialize_with = "tag_list", skip_serializing_if = "Vec::is_empty")]
pub genre: Vec<String>,
#[serde(default, deserialize_with = "tag_list", skip_serializing_if = "Vec::is_empty")]
pub platform: Vec<String>,
#[serde(default, deserialize_with = "boolish")]
pub verified: bool,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub verified_how: String,
#[serde(default, deserialize_with = "plain_list", skip_serializing_if = "Vec::is_empty")]
pub refs: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub ref_state: BTreeMap<String, RefState>,
#[serde(default, deserialize_with = "plain_list", skip_serializing_if = "Vec::is_empty")]
pub related: Vec<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub supersedes: String,
/// `retired` hides the entry from normal search and lists (it stays readable and in git).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub status: String,
/// Why it was retired, and the entry that replaces it, if any.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub retired_why: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub replaced_by: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub author: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub created: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub updated: String,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_yaml::Value>,
}
fn yaml_strings(v: serde_yaml::Value) -> Vec<String> {
use serde_yaml::Value as V;
match v {
V::Null => vec![],
V::String(s) => s.split(',').map(str::to_string).collect(),
V::Sequence(xs) => xs
.into_iter()
.map(|x| match x {
V::String(s) => s,
other => serde_yaml::to_string(&other).unwrap_or_default().trim().to_string(),
})
.collect(),
other => vec![serde_yaml::to_string(&other).unwrap_or_default().trim().to_string()],
}
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
fn plain_list<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
Ok(yaml_strings(serde_yaml::Value::deserialize(d)?))
}
pub fn norm_tag(s: &str) -> String {
let s = s.trim().trim_start_matches('#').to_lowercase();
s.split_whitespace().collect::<Vec<_>>().join("-")
}
fn tag_list<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
Ok(yaml_strings(serde_yaml::Value::deserialize(d)?).iter().map(|s| norm_tag(s)).collect())
}
fn boolish<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
use serde_yaml::Value as V;
Ok(match V::deserialize(d)? {
V::Bool(b) => b,
V::String(s) => matches!(s.trim().to_lowercase().as_str(), "yes" | "y" | "true" | "verified"),
_ => false,
})
}
#[derive(Clone, Debug)]
pub struct Entry {
pub id: String,
pub meta: Meta,
pub body: String,
pub raw: String,
}
pub fn split_frontmatter(text: &str) -> Result<(Meta, String)> {
let t = text.trim_start_matches('\u{feff}');
let rest = t
.strip_prefix("---")
.ok_or_else(|| anyhow!("entry must start with a '---' YAML frontmatter block"))?;
let end = rest
.find("\n---")
.ok_or_else(|| anyhow!("frontmatter is not closed with '---'"))?;
let mut value: serde_yaml::Value = serde_yaml::from_str(&rest[..end]).context(
"frontmatter is not valid YAML (quote any value that contains `: ` or starts with a special \
character, e.g. verified_how: \"live 2026-10-29: works\")",
)?;
// Entries come from other agents and git pulls, and are shown in terminals and agent context:
// no terminal control sequences (also from YAML escapes like "\e"), and a one-line title.
clean_yaml(&mut value);
let mut meta: Meta = serde_yaml::from_value(value).context("frontmatter")?;
meta.title = meta.title.split_whitespace().collect::<Vec<_>>().join(" ");
let after = &rest[end + 4..];
let body = strip_controls(after.split_once('\n').map(|x| x.1).unwrap_or("").trim());
Ok((meta, body))
}
/// `s` without terminal control characters (ESC, other C0/C1 controls, bidi overrides), which could
/// move the cursor, rewrite what's shown, set the clipboard (OSC 52) or reorder text. Line breaks and
/// tabs stay.
/// A short fingerprint of an entry's file, for `get --raw` / `update` (see `revision` there).
pub fn revision(raw: &str) -> String {
use sha2::{Digest, Sha256};
hex::encode(Sha256::digest(raw.as_bytes()))[..12].to_string()
}
/// Characters that change how text displays without showing: zero-width and bidi marks, the
/// soft hyphen, the BOM, and Unicode tag characters (read by models, invisible in terminals).
pub fn is_hidden_char(c: char) -> bool {
matches!(c, '\u{00ad}' | '\u{180e}' | '\u{200b}'..='\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2060}'..='\u{2064}'
| '\u{2066}'..='\u{206f}' | '\u{feff}' | '\u{e0000}'..='\u{e007f}')
}
/// Whether a body still has git conflict markers (a merge that wasn't resolved).
/// Both the opening and closing markers: a lone `=======` is a Markdown heading underline.
pub fn has_conflict_markers(body: &str) -> bool {
body.lines().any(|l| l.starts_with("<<<<<<< ")) && body.lines().any(|l| l.starts_with(">>>>>>> "))
}
pub fn strip_controls(s: &str) -> String {
s.chars()
.filter(|&c| c == '\n' || c == '\t' || !(c.is_control() || is_hidden_char(c)))
.collect()
}
fn clean_yaml(v: &mut serde_yaml::Value) {
match v {
serde_yaml::Value::String(s) => *s = strip_controls(s),
// List items (refs, tags, related) are single words or paths: one line each, so a ref can't
// add lines to the "open these" file list.
serde_yaml::Value::Sequence(xs) => xs.iter_mut().for_each(|x| {
clean_yaml(x);
if let serde_yaml::Value::String(s) = x {
// Line breaks and tabs only: a path's own spaces (`Client Archive`) are kept.
*s = s.replace(['\n', '\r', '\t'], " ").trim().to_string();
}
}),
serde_yaml::Value::Mapping(m) => m.iter_mut().for_each(|(_, x)| clean_yaml(x)),
serde_yaml::Value::Tagged(t) => clean_yaml(&mut t.value),
_ => {}
}
}
pub fn render(meta: &Meta, body: &str) -> String {
format!("---\n{}---\n\n{}\n", serde_yaml::to_string(meta).unwrap_or_default(), body.trim())
}
/// `## Heading` sections of a body, in order.
pub fn sections(body: &str) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = vec![];
let mut cur = (String::new(), String::new());
for line in body.lines() {
if let Some(h) = line.strip_prefix("## ") {
if !cur.0.is_empty() || !cur.1.trim().is_empty() {
out.push(std::mem::take(&mut cur));
}
cur.0 = h.trim().to_string();
} else {
cur.1.push_str(line);
cur.1.push('\n');
}
}
if !cur.0.is_empty() || !cur.1.trim().is_empty() {
out.push(cur);
}
out.into_iter().map(|(h, t)| (h, t.trim().to_string())).collect()
}
pub fn valid_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 120
&& id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
&& !id.starts_with('-')
}
pub fn slugify(s: &str) -> String {
let mut out = String::new();
for c in s.to_lowercase().chars() {
if c.is_ascii_alphanumeric() {
out.push(c);
} else if !out.ends_with('-') {
out.push('-');
}
}
let out = out.trim_matches('-');
// Cut long titles at a word boundary, not mid-word.
let out = if out.len() > 60 {
let cut = &out[..60];
cut.rfind('-').map(|i| &cut[..i]).unwrap_or(cut)
} else {
out
};
let out = out.trim_end_matches('-').to_string();
if out.is_empty() { "entry".into() } else { out }
}
pub fn today() -> String {
chrono::Local::now().format("%Y-%m-%d").to_string()
}