prov 0.4.0

A self-describing plaintext workspace: structure lives in documents' own embedded metadata.
Documentation
//! Identity — a strictly-additive layer over a path-only workspace.
//!
//! Everything below is optional. The graph and mutation layers operate on
//! paths and never require an ID. This module only decides **when** a document
//! earns a stable ID (the trigger set) and **what** that ID looks like (the
//! mint). *Where* IDs are stored is [`crate::index`].
//!
//! The default is [`NoIdentity`] — identity off, no ID ever written. The
//! recommended lazy policy registers an ID only when something durably refers
//! to a document (a link-by-id or a publish), keeping the authoritative set as
//! small as possible.
//!
//! ## The ID scheme
//!
//! Prov's internal IDs share their lineage with diaryx's ARK blades but
//! carry no NAAN or shoulder — they are workspace-internal, not published
//! permalinks (DESIGN §4's two identity layers). The minting primitives come
//! from the [`moid`] crate (*minimal opaque ID*): an ID is [`BLADE_RANDOM_LEN`]
//! random characters from the 29-character NOID extended-digit alphabet
//! ([`moid::Alphabet::noid_xdigit`] — digits plus consonants: no vowels, so no
//! accidental words; no `l`, so no ambiguity with `1`) plus one NOID check
//! character, so a typo'd ID is *detected* rather than silently resolving to
//! nothing. The alphabet is the canonical NOID one, so the check character
//! agrees with a real NOID minter and not merely with our own arithmetic. An ID
//! may therefore contain — and begin with — a digit; anything stamping one into
//! metadata must keep it a *string* (see [`crate::edit::infer_scalar`]). Minting
//! is random (opaque for free), with uniqueness enforced by rejection against
//! the index — including its tombstones, so a deleted document's ID is never
//! reissued.
//!
//! This module keeps only prov's *policy* layer — [`Id`], the registration
//! trigger set ([`Registration`]/[`Trigger`]), and the [`IdentityPolicy`] trait
//! deciding *when* a document earns an ID. The alphabet, check-character
//! arithmetic, and seeded PRNG all live in [`moid`].

use std::path::Path;

use moid::{Alphabet, SeededRng};

/// A stable, opaque document identifier.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Id(pub String);

impl Id {
    /// The id as a string slice.
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl std::fmt::Display for Id {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

/// Random characters per ID (excluding the check character). 29^6 ≈ 595M —
/// collision-free in practice for a workspace, enforced absolutely by
/// mint-with-rejection.
pub const BLADE_RANDOM_LEN: usize = 6;

/// Total ID length: the random body plus one check character.
pub const BLADE_LEN: usize = BLADE_RANDOM_LEN + 1;

/// The canonical [`moid`] minter for prov IDs: [`BLADE_RANDOM_LEN`] random
/// NOID extended-digit characters plus a NOID check character. Every mint and
/// every [`verify`] goes through this exact configuration, so they agree by
/// construction.
fn canonical_minter() -> moid::Minter {
    moid::Minter::new(Alphabet::noid_xdigit(), BLADE_RANDOM_LEN)
}

/// Whether `id` is a well-formed prov ID: correct length, alphabet-only,
/// and a matching trailing check character. This is what catches a typo'd
/// `prov:` link before it dangles silently.
pub fn verify(id: &str) -> bool {
    canonical_minter().validate(id).is_ok()
}

/// Which events cause a document to be assigned (registered) an ID.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Registration {
    /// Register every document at creation time (eager).
    pub on_create: bool,
    /// Register when a document is first referenced by ID (e.g. a wikilink).
    pub on_link: bool,
    /// Register when a document is published.
    pub on_publish: bool,
}

impl Registration {
    /// Never register — identity is effectively off.
    pub const OFF: Self = Self {
        on_create: false,
        on_link: false,
        on_publish: false,
    };
    /// Register only on a durable reference (link-by-id or publish). Recommended.
    pub const LAZY: Self = Self {
        on_create: false,
        on_link: true,
        on_publish: true,
    };
    /// Register every document the moment it is created.
    pub const EAGER: Self = Self {
        on_create: true,
        on_link: true,
        on_publish: true,
    };

    /// Whether any trigger is active.
    pub fn is_active(&self) -> bool {
        self.on_create || self.on_link || self.on_publish
    }
}

/// The registration event a caller is asking about (see
/// [`crate::workspace::Workspace`]'s `register`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Trigger {
    /// A document was created.
    Create,
    /// Something is about to link to the document by ID.
    Link,
    /// The document is being published.
    Publish,
}

impl Registration {
    /// Whether this trigger set fires for `event`.
    pub fn fires_on(&self, event: Trigger) -> bool {
        match event {
            Trigger::Create => self.on_create,
            Trigger::Link => self.on_link,
            Trigger::Publish => self.on_publish,
        }
    }
}

/// A policy deciding when to register documents and how their IDs are minted.
pub trait IdentityPolicy {
    /// The registration trigger set for this policy.
    fn registration(&self) -> Registration;

    /// Mint a fresh ID for the document at `path`. Only called when a trigger
    /// fires, so a disabled policy need never produce a meaningful value.
    /// Uniqueness is the *caller's* job (mint-with-rejection against the
    /// index); a mint may repeat.
    fn mint(&mut self, path: &Path) -> Id;
}

/// Identity disabled — the default. Paths only; no ID is ever minted or written.
#[derive(Debug, Clone, Copy, Default)]
pub struct NoIdentity;

impl IdentityPolicy for NoIdentity {
    fn registration(&self) -> Registration {
        Registration::OFF
    }

    fn mint(&mut self, _path: &Path) -> Id {
        // Unreachable in practice: `OFF` fires no triggers.
        Id(String::new())
    }
}

/// The bundled minting policy: NOID xdigit + check IDs from a seeded PRNG.
///
/// Minting is delegated to [`moid`]: a [`moid::Minter`] over the canonical
/// alphabet ([`canonical_minter`]) driven by a [`moid::SeededRng`]. The RNG is
/// xorshift64 — *not* cryptographic, and not claimed to be: these are opaque
/// internal handles whose uniqueness is enforced by rejection, not by entropy.
/// Both parts are `Clone`/`Debug`, which keeps this policy (and any workspace
/// carrying it) `Clone`/`Debug`, and a fixed seed makes tests deterministic. A
/// deployment wanting stronger opacity (or ARK permalinks, like diaryx)
/// implements [`IdentityPolicy`] itself.
#[derive(Debug, Clone)]
pub struct Minter {
    registration: Registration,
    minter: moid::Minter,
    rng: SeededRng,
}

impl Minter {
    /// Register only on a durable reference (the recommended default),
    /// randomizing from `seed`.
    pub fn lazy(seed: u64) -> Self {
        Self::with(Registration::LAZY, seed)
    }

    /// Register every document at creation, randomizing from `seed`.
    pub fn eager(seed: u64) -> Self {
        Self::with(Registration::EAGER, seed)
    }

    /// Register on a custom trigger set, randomizing from `seed`. A zero seed is
    /// nudged off xorshift64's fixed point by [`moid::SeededRng`].
    pub fn with(registration: Registration, seed: u64) -> Self {
        Self {
            registration,
            minter: canonical_minter(),
            rng: SeededRng::new(seed),
        }
    }
}

impl IdentityPolicy for Minter {
    fn registration(&self) -> Registration {
        self.registration
    }

    fn mint(&mut self, _path: &Path) -> Id {
        Id(self.minter.mint_seeded(&mut self.rng))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn no_identity_is_off() {
        assert!(!NoIdentity.registration().is_active());
    }

    #[test]
    fn lazy_registers_on_link_and_publish_only() {
        let r = Minter::lazy(1).registration();
        assert!(!r.fires_on(Trigger::Create));
        assert!(r.fires_on(Trigger::Link));
        assert!(r.fires_on(Trigger::Publish));
    }

    #[test]
    fn eager_registers_on_create() {
        assert!(Minter::eager(1).registration().fires_on(Trigger::Create));
    }

    #[test]
    fn mints_verified_distinct_opaque_ids() {
        let mut p = Minter::eager(42);
        let a = p.mint(Path::new("a.md"));
        let b = p.mint(Path::new("b.md"));
        assert_ne!(a, b);
        for id in [&a, &b] {
            assert_eq!(id.as_str().len(), BLADE_LEN);
            assert!(verify(id.as_str()), "{id}");
        }
    }

    #[test]
    fn same_seed_is_deterministic() {
        let a = Minter::lazy(7).mint(Path::new("x"));
        let b = Minter::lazy(7).mint(Path::new("y"));
        assert_eq!(a, b, "path does not participate in the mint");
    }

    #[test]
    fn verify_rejects_typos() {
        let id = Minter::lazy(3).mint(Path::new("x")).0;
        assert!(verify(&id));
        // Flip one body character to another alphabet character.
        let mut chars: Vec<char> = id.chars().collect();
        chars[0] = if chars[0] == 'b' { 'c' } else { 'b' };
        let typo: String = chars.iter().collect();
        assert!(!verify(&typo), "{typo}");
        // Wrong length, wrong alphabet (vowels and `y` are both out).
        assert!(!verify("bcd"));
        assert!(!verify("aeiouAy"));
        assert!(!verify("bcdfghy"));
    }

    #[test]
    fn check_char_matches_the_noid_lineage() {
        // Independently computed: the xdigit alphabet leads with the digits, so
        // ordinals b=10,c=11,d=12,f=13,g=14,h=15 weighted by position 1..=6 →
        // 10+22+36+52+70+90 = 280; 280 % 29 = 19 → the 19th xdigit symbol is
        // 'n'. moid computes the same check character, so a full ID with that
        // body validates.
        assert_eq!(Alphabet::noid_xdigit().check_char("bcdfgh"), 'n');
        assert!(verify("bcdfghn"));
    }

    #[test]
    fn an_id_may_be_all_digits() {
        // The point of the xdigit alphabet: digits are in it, so an ID can look
        // like a number — which is why every stamp writes a string scalar.
        let check = Alphabet::noid_xdigit().check_char("012345");
        assert!(verify(&format!("012345{check}")));
    }
}