pushkin-core 0.1.1

Core envelope, manifest, pipeline, and waiver types for the pushkin write-gate
Documentation
//! `pushkin.toml` parsing (spec §11). Strict by construction: serde with
//! `deny_unknown_fields` everywhere, unknown-key errors enriched with
//! nearest-candidate suggestions, mapping→contract references resolved once
//! at the boundary (spec §7.1) so shorthand never silently changes meaning.

use globset::{Glob, GlobSet, GlobSetBuilder};
use serde::Deserialize;
use thiserror::Error;

pub const SUPPORTED_VERSION: u32 = 1;

/// Keys a typo in the manifest is matched against for candidate suggestions.
const KNOWN_KEYS: &[&str] = &[
    "version",
    "schema_epoch",
    "canonical",
    "authoring",
    "contracts",
    "name",
    "source",
    "emit",
    "mappings",
    "glob",
    "require",
    "gates",
    "suppression_comments",
    "protected_paths",
    "read_only_paths",
    "db",
    "direction",
    "provider",
    "rls_tests",
];

#[derive(Debug, Error)]
pub enum ManifestError {
    #[error("manifest is not valid TOML or violates the schema: {message}")]
    Invalid { message: String },
    #[error("manifest version {found} is unsupported (this binary supports {supported})")]
    UnsupportedVersion { found: u32, supported: u32 },
    #[error(
        "mapping references undeclared contract '{reference}'; declared contracts: {candidates}"
    )]
    UnknownContract {
        reference: String,
        candidates: String,
    },
    #[error("glob '{glob}' is invalid: {message}")]
    BadGlob { glob: String, message: String },
    #[error(
        "schema_epoch must be a positive integer (a human increments it on \
         epoch-sensitive change, R9); found {found}"
    )]
    NonPositiveEpoch { found: u32 },
}

#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(transparent)]
pub struct ContractName(String);

impl ContractName {
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Contract {
    pub name: ContractName,
    pub source: String,
    pub emit: Vec<String>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Mapping {
    pub glob: String,
    pub contracts: Vec<ContractName>,
    pub require: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Gates {
    pub suppression_comments: Option<String>,
    #[serde(default)]
    pub protected_paths: Vec<String>,
    /// Globs whose COMMITTED files are read-only to agents: new files may
    /// be created (the RED-suite authoring window), files in git HEAD may
    /// not be modified — N10 ("committed first, read-only hereafter") as a
    /// product gate. Unwaivable, like `protected_paths`.
    #[serde(default)]
    pub read_only_paths: Vec<String>,
}

/// `[db]` (spec §5.3, §10): drift-gate configuration. `direction` names
/// the source of truth — "contract" (generated DDL is desired state) or
/// "database" (introspected schema is; contracts must follow).
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Db {
    pub direction: DbDirection,
    pub provider: Option<String>,
    pub rls_tests: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DbDirection {
    Contract,
    Database,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawManifest {
    version: u32,
    schema_epoch: Option<u32>,
    canonical: String,
    authoring: String,
    #[serde(default)]
    contracts: Vec<Contract>,
    #[serde(default)]
    mappings: Vec<Mapping>,
    gates: Gates,
    db: Option<Db>,
}

/// A parsed, boundary-resolved manifest. Globs are compiled once here.
pub struct Manifest {
    pub version: u32,
    /// R9 (approved 2026-08-13): the workspace-wide schema epoch, owned by
    /// the manifest and human-incremented. The SOLE source authoring,
    /// compile, and the daemon probe read. Absent key = 1 (pre-R9
    /// manifests keep parsing; the repo's own manifest declares it).
    pub schema_epoch: u32,
    pub canonical: String,
    pub authoring: String,
    pub contracts: Vec<Contract>,
    pub mappings: Vec<Mapping>,
    pub gates: Gates,
    pub db: Option<Db>,
    mapping_globs: GlobSet,
    protected_globs: GlobSet,
    read_only_globs: GlobSet,
}

impl std::fmt::Debug for Manifest {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // GlobSet has no Debug; show the declarative fields only.
        f.debug_struct("Manifest")
            .field("version", &self.version)
            .field("schema_epoch", &self.schema_epoch)
            .field("canonical", &self.canonical)
            .field("authoring", &self.authoring)
            .field("contracts", &self.contracts)
            .field("mappings", &self.mappings)
            .field("gates", &self.gates)
            .field("db", &self.db)
            .finish_non_exhaustive()
    }
}

impl Manifest {
    /// Parses and boundary-resolves manifest text.
    ///
    /// # Errors
    /// Returns `ManifestError` on TOML/schema violations (with candidate
    /// suggestions for unknown keys), unsupported versions, undeclared
    /// contract references, and invalid globs.
    pub fn parse(text: &str) -> Result<Self, ManifestError> {
        let raw: RawManifest = toml::from_str(text).map_err(|e| enrich_unknown_key(&e))?;

        if raw.version != SUPPORTED_VERSION {
            return Err(ManifestError::UnsupportedVersion {
                found: raw.version,
                supported: SUPPORTED_VERSION,
            });
        }
        if let Some(0) = raw.schema_epoch {
            return Err(ManifestError::NonPositiveEpoch { found: 0 });
        }
        resolve_contract_references(&raw)?;

        let mapping_globs = build_globset(raw.mappings.iter().map(|m| m.glob.as_str()))?;
        let protected_globs = build_globset(raw.gates.protected_paths.iter().map(String::as_str))?;
        let read_only_globs = build_globset(raw.gates.read_only_paths.iter().map(String::as_str))?;

        Ok(Self {
            version: raw.version,
            schema_epoch: raw.schema_epoch.unwrap_or(1),
            canonical: raw.canonical,
            authoring: raw.authoring,
            contracts: raw.contracts,
            mappings: raw.mappings,
            gates: raw.gates,
            db: raw.db,
            mapping_globs,
            protected_globs,
            read_only_globs,
        })
    }

    /// First mapping whose glob matches `path`, if any.
    #[must_use]
    pub fn mapping_for(&self, path: &str) -> Option<&Mapping> {
        self.mapping_globs
            .matches(path)
            .first()
            .map(|&index| &self.mappings[index])
    }

    #[must_use]
    pub fn is_protected(&self, path: &str) -> bool {
        self.protected_globs.is_match(path)
    }

    /// Whether `path` falls under a `read_only_paths` glob. Committed-ness
    /// is the caller's question (it needs git); this is only the glob half.
    #[must_use]
    pub fn is_read_only(&self, path: &str) -> bool {
        self.read_only_globs.is_match(path)
    }
}

fn resolve_contract_references(raw: &RawManifest) -> Result<(), ManifestError> {
    let declared: Vec<&str> = raw.contracts.iter().map(|c| c.name.as_str()).collect();
    for mapping in &raw.mappings {
        for reference in &mapping.contracts {
            if !declared.contains(&reference.as_str()) {
                return Err(ManifestError::UnknownContract {
                    reference: reference.as_str().to_owned(),
                    candidates: declared.join(", "),
                });
            }
        }
    }
    Ok(())
}

fn build_globset<'a>(globs: impl Iterator<Item = &'a str>) -> Result<GlobSet, ManifestError> {
    let mut builder = GlobSetBuilder::new();
    for glob in globs {
        let compiled = Glob::new(glob).map_err(|error| ManifestError::BadGlob {
            glob: glob.to_owned(),
            message: error.to_string(),
        })?;
        builder.add(compiled);
    }
    builder.build().map_err(|error| ManifestError::BadGlob {
        glob: "<combined>".to_owned(),
        message: error.to_string(),
    })
}

/// Appends nearest-candidate suggestions to serde's "unknown field" errors so
/// every rejection is a retry prompt (design principle 5).
fn enrich_unknown_key(error: &toml::de::Error) -> ManifestError {
    let message = error.to_string();
    let Some(unknown) = extract_unknown_field(&message) else {
        return ManifestError::Invalid { message };
    };
    let candidates = nearest_keys(&unknown);
    if candidates.is_empty() {
        return ManifestError::Invalid { message };
    }
    ManifestError::Invalid {
        message: format!("{message}; did you mean: {}?", candidates.join(", ")),
    }
}

fn extract_unknown_field(message: &str) -> Option<String> {
    let marker = "unknown field `";
    let start = message.find(marker)? + marker.len();
    let rest = &message[start..];
    let end = rest.find('`')?;
    Some(rest[..end].to_owned())
}

fn nearest_keys(unknown: &str) -> Vec<&'static str> {
    let mut scored: Vec<(usize, &'static str)> = KNOWN_KEYS
        .iter()
        .map(|&key| (levenshtein(unknown, key), key))
        .filter(|&(distance, _)| distance <= 3)
        .collect();
    scored.sort_unstable();
    scored.into_iter().take(3).map(|(_, key)| key).collect()
}

pub(crate) fn levenshtein(a: &str, b: &str) -> usize {
    let a_chars: Vec<char> = a.chars().collect();
    let b_chars: Vec<char> = b.chars().collect();
    let mut previous: Vec<usize> = (0..=b_chars.len()).collect();
    let mut current = vec![0usize; b_chars.len() + 1];

    for (i, &a_char) in a_chars.iter().enumerate() {
        current[0] = i + 1;
        for (j, &b_char) in b_chars.iter().enumerate() {
            let substitution = usize::from(a_char != b_char);
            current[j + 1] = (previous[j] + substitution)
                .min(previous[j + 1] + 1)
                .min(current[j] + 1);
        }
        std::mem::swap(&mut previous, &mut current);
    }
    previous[b_chars.len()]
}