cratestack-macros 0.7.1

Rust-native schema-first framework for typed HTTP APIs, generated clients, and backend services.
Documentation
//! Reads and validates `<schema>.pb.lock` at macro-expansion time. The
//! macro must read the *committed* lock, never recompute fresh numbers —
//! `docs/design/protobuf.md` §3.3's whole point is that field numbers are
//! stable across compiles, and silently auto-generating them here would
//! defeat that (and could assign different numbers on different
//! machines/times, since `build_lock`'s tombstone/reservation state lives
//! only in the committed file).
//!
//! Split into a pure predicate ([`validate_pb_lock`], the actual decision
//! logic, unit-tested directly) and a thin I/O wrapper ([`load_pb_lock`])
//! that the proc-macro call site uses — same shape as
//! `reject_grpc::schema_declares_grpc_transport` / `parse::find_composite_id_model`:
//! the `compile_error!`-producing wrapper isn't unit-testable outside a
//! proc-macro context, so the logic it delegates to is.

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

use cratestack_core::{Field, Schema};
use cratestack_proto::PbLock;

/// `<schema>.cstack` -> `<schema>.pb.lock` — same derivation ticket #169's
/// CLI uses (`schema.with_extension("pb.lock")` in
/// `cratestack-cli/src/generate_proto.rs`), so a lock generated by
/// `cratestack generate-proto --schema schema.cstack ...` is found here
/// without any extra configuration.
pub(crate) fn pb_lock_path(schema_resolved: &Path) -> PathBuf {
    schema_resolved.with_extension("pb.lock")
}

#[derive(Debug, PartialEq, Eq)]
pub(crate) enum PbLockValidationError {
    Missing,
    Malformed(String),
    Stale,
}

impl PbLockValidationError {
    pub(crate) fn message(&self, lock_path_display: &str, schema_path_display: &str) -> String {
        match self {
            PbLockValidationError::Missing => format!(
                "no `{lock_path_display}` found; run `cratestack generate-proto --schema \
                 {schema_path_display} --package <name>` first"
            ),
            PbLockValidationError::Malformed(reason) => {
                format!("failed to parse `{lock_path_display}`: {reason}")
            }
            PbLockValidationError::Stale => format!(
                "`{schema_path_display}` has changed since `{lock_path_display}` was last \
                 generated; run `cratestack generate-proto --schema {schema_path_display} \
                 --check` to see the drift, then re-run without `--check` to update the lock"
            ),
        }
    }
}

/// Pure validation: given the schema, the lock file's text (`None` if the
/// file doesn't exist), and the canonical `extra_messages` map (must be
/// `cratestack_proto::synthesize_messages(schema)`'s output — the same
/// derivation the CLI feeds `build_lock`/`lock_would_change`, so drift
/// detection here agrees with `cratestack generate-proto --check` exactly),
/// returns the parsed lock or the specific reason it's unusable.
pub(crate) fn validate_pb_lock(
    schema: &Schema,
    lock_text: Option<&str>,
    extra_messages: &std::collections::BTreeMap<String, Vec<Field>>,
) -> Result<PbLock, PbLockValidationError> {
    let Some(lock_text) = lock_text else {
        return Err(PbLockValidationError::Missing);
    };
    let lock = PbLock::from_toml(lock_text)
        .map_err(|error| PbLockValidationError::Malformed(error.to_string()))?;
    let changed = cratestack_proto::lock_would_change(schema, &lock, extra_messages)
        .map_err(|error| PbLockValidationError::Malformed(error.to_string()))?;
    if changed {
        return Err(PbLockValidationError::Stale);
    }
    Ok(lock)
}

/// I/O wrapper: reads `<schema>.pb.lock` off disk (if present) and
/// validates it via [`validate_pb_lock`].
pub(crate) fn load_pb_lock(
    schema: &Schema,
    schema_resolved: &Path,
    extra_messages: &std::collections::BTreeMap<String, Vec<Field>>,
) -> Result<PbLock, String> {
    let lock_path = pb_lock_path(schema_resolved);
    let lock_text = std::fs::read_to_string(&lock_path).ok();
    validate_pb_lock(schema, lock_text.as_deref(), extra_messages).map_err(|error| {
        error.message(
            &lock_path.display().to_string(),
            &schema_resolved.display().to_string(),
        )
    })
}

#[cfg(test)]
mod tests {
    use std::collections::BTreeMap;

    use super::*;

    fn schema(source: &str) -> Schema {
        cratestack_parser::parse_schema(source).expect("schema should parse")
    }

    #[test]
    fn missing_lock_text_is_reported_as_missing() {
        let schema = schema("model Widget {\n  id Int @id\n}\n");
        let result = validate_pb_lock(&schema, None, &BTreeMap::new());
        assert_eq!(result.unwrap_err(), PbLockValidationError::Missing);
    }

    #[test]
    fn malformed_toml_is_reported_as_malformed() {
        let schema = schema("model Widget {\n  id Int @id\n}\n");
        let result = validate_pb_lock(&schema, Some("not valid toml {{{"), &BTreeMap::new());
        assert!(matches!(
            result.unwrap_err(),
            PbLockValidationError::Malformed(_)
        ));
    }

    #[test]
    fn matching_lock_validates_successfully() {
        let schema = schema("model Widget {\n  id Int @id\n  name String\n}\n");
        let extra = BTreeMap::new();
        let built = cratestack_proto::build_lock(&schema, None, &extra).unwrap();
        let mut with_package = built.clone();
        with_package.package = Some("widgets".to_owned());

        let result = validate_pb_lock(&schema, Some(&with_package.to_toml()), &extra);
        assert!(result.is_ok(), "{result:?}");
    }

    #[test]
    fn stale_lock_is_reported_as_stale() {
        let old_schema = schema("model Widget {\n  id Int @id\n}\n");
        let extra = BTreeMap::new();
        let mut old_lock = cratestack_proto::build_lock(&old_schema, None, &extra).unwrap();
        old_lock.package = Some("widgets".to_owned());

        // Field added since the lock was generated -> the lock is stale.
        let new_schema = schema("model Widget {\n  id Int @id\n  name String\n}\n");
        let result = validate_pb_lock(&new_schema, Some(&old_lock.to_toml()), &extra);
        assert_eq!(result.unwrap_err(), PbLockValidationError::Stale);
    }
}