wsb 0.275.7622

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! Notes: `.wsb/notes/<id>.md` in a project's main repo.
//!
//! A note stands alone. What it is about is said by links
//! ([`super::links`]), not by where it is filed, so one note can be about a
//! card, a file and a machine at once.
//!
//! ```text
//! ---
//! title: Why the bridge retries
//! created: 2026-10-24T21:30:00Z
//! machine: pve
//! ---
//! The body, in markdown.
//! ```

use std::path::{Path, PathBuf};

use anyhow::{bail, Context, Result};
use serde::Serialize;

/// The folder notes live in, inside `.wsb/`.
pub const DIR: &str = "notes";

/// One note.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Note {
    pub id: String,
    pub title: String,
    /// When it was written, RFC 3339.
    pub created: String,
    /// The machine it was written on.
    pub machine: String,
    pub body: String,
}

/// Whether `id` can name a note: lowercase letters, digits and `-`, as
/// [`new_id`] makes them, so it is a file name on every platform.
pub fn check_id(id: &str) -> Result<String> {
    if id.is_empty() || !id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
        bail!("{id:?} is not a note id: lowercase letters, digits and '-'");
    }
    Ok(id.to_string())
}

/// A new note id: when, to the second, and four random hex digits, so two
/// machines writing notes in the same second do not collide.
pub fn new_id(now: chrono::DateTime<chrono::Utc>) -> String {
    let random: u16 = rand::random();
    format!("{}-{random:04x}", now.format("%Y%m%d-%H%M%S"))
}

/// The folder holding the notes of the main repo checked out at `root`.
pub fn dir_of(root: &Path) -> PathBuf {
    wsb_prj::settings::dir_of(root).join(DIR)
}

/// The file of note `id` in the checkout at `root`.
pub fn path_of(root: &Path, id: &str) -> PathBuf {
    dir_of(root).join(format!("{id}.md"))
}

impl Note {
    /// A note written now, on this machine.
    pub fn new(title: &str, body: &str, machine: &str) -> Result<Self> {
        let now = chrono::Utc::now();
        let note = Self {
            id: new_id(now),
            title: title.to_string(),
            created: now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
            machine: machine.to_string(),
            body: body.to_string(),
        };
        note.check()?;
        Ok(note)
    }

    fn check(&self) -> Result<()> {
        check_id(&self.id)?;
        if self.title.trim().is_empty() {
            bail!("a note needs a title; an untitled note is one nobody can find");
        }
        if self.title.contains('\n') || self.title.contains('\r') {
            bail!("a note's title is one line");
        }
        if self.machine.trim().is_empty() || self.machine.contains('\n') {
            bail!("a note names the machine it was written on, in one line");
        }
        chrono::DateTime::parse_from_rfc3339(&self.created)
            .with_context(|| format!("{:?} is not when a note was written (RFC 3339)", self.created))?;
        Ok(())
    }

    /// The file's text.
    pub fn render(&self) -> String {
        let mut text = format!(
            "---\ntitle: {}\ncreated: {}\nmachine: {}\n---\n",
            self.title, self.created, self.machine
        );
        text.push_str(&self.body);
        if !self.body.is_empty() && !self.body.ends_with('\n') {
            text.push('\n');
        }
        text
    }

    /// A note read from its file's text; `id` is the file's name. Refused,
    /// naming what is wrong, when the header is not exactly the one wsb
    /// writes: a note is read on every machine, and one half understood
    /// would be shown wrong everywhere.
    pub fn parse(id: &str, text: &str) -> Result<Self> {
        let rest = text
            .strip_prefix("---\n")
            .context("a note starts with a --- header")?;
        let (header, body) = rest
            .split_once("\n---\n")
            .map(|(header, body)| (header, body.to_string()))
            .or_else(|| rest.strip_suffix("\n---").map(|header| (header, String::new())))
            .context("a note's header ends with ---")?;
        let mut title = None;
        let mut created = None;
        let mut machine = None;
        for line in header.lines() {
            let (key, value) = line
                .split_once(": ")
                .with_context(|| format!("{line:?} in a note's header is not `key: value`"))?;
            let slot = match key {
                "title" => &mut title,
                "created" => &mut created,
                "machine" => &mut machine,
                other => bail!("{other:?} is not a key of a note's header: title, created, machine"),
            };
            if slot.replace(value.to_string()).is_some() {
                bail!("{key} is given twice in a note's header");
            }
        }
        let note = Self {
            id: id.to_string(),
            title: title.context("a note's header has no title")?,
            created: created.context("a note's header has no created")?,
            machine: machine.context("a note's header has no machine")?,
            body,
        };
        note.check()?;
        Ok(note)
    }

    /// Read note `id` from the checkout at `root`, or `None` if it holds no
    /// such note.
    pub fn read(root: &Path, id: &str) -> Result<Option<Self>> {
        let path = path_of(root, &check_id(id)?);
        match std::fs::read_to_string(&path) {
            Ok(text) => Self::parse(id, &text)
                .map(Some)
                .with_context(|| format!("{} is not a note wsb can read", path.display())),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
            Err(e) => Err(e).with_context(|| format!("cannot read {}", path.display())),
        }
    }

    /// Write the note into the checkout at `root`, whole.
    pub fn write(&self, root: &Path) -> Result<PathBuf> {
        self.check()?;
        let path = path_of(root, &self.id);
        super::write_file(&path, &self.render())?;
        Ok(path)
    }

    /// Every note in the checkout at `root`, by id.
    pub fn all(root: &Path) -> Result<Vec<Self>> {
        let dir = dir_of(root);
        let mut notes = Vec::new();
        for (id, path) in super::files_with_extension(&dir, "md")? {
            let text = std::fs::read_to_string(&path).with_context(|| format!("cannot read {}", path.display()))?;
            notes.push(
                Self::parse(&id, &text).with_context(|| format!("{} is not a note wsb can read", path.display()))?,
            );
        }
        notes.sort_by(|a, b| a.id.cmp(&b.id));
        Ok(notes)
    }
}