Mega Mind v0.5.4

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()
}