rustio-admin-cli 0.28.0

Command-line tools for rustio-admin: project scaffolding, migrations, user management.
//! SchemaHash projection and computation (`DESIGN_BUILDER.md` §5.3).
//!
//! Every file under `src/_generated/` carries a `SPDX-SchemaHash`
//! header line. The hash is SHA-256 over a **closed projection** of
//! `draft.toml` -- the slice of the document that determines that
//! file's contents. The closed nature of the projection is what
//! lets the overwrite contract refuse to clobber stale generated
//! files.
//!
//! Projections this MVP defines:
//!
//! | Generated file | Projection input |
//! |---|---|
//! | `_generated/models/<snake>.rs` | the `[[models]]` entry whose `name` matches |
//! | `_generated/admin.rs` | every `[[models]]` entry (names + table names) |
//! | `_generated/mod.rs` | every `[[models]]` entry's `name` |
//! | `migrations/NNNN_initial.sql` | the full `[[models]]` list and the project name |
//!
//! Hashes are computed over canonical-TOML emissions of these
//! projections -- guaranteeing byte-stable hashes under the §4.4
//! environment fixings. The hash never includes the
//! `// @generated by rustio …` header text, satisfying §10.2.

use sha2::{Digest, Sha256};
use toml_edit::{value, ArrayOfTables, DocumentMut, Item, Table};

use crate::builder::draft::{Draft, Model};
use crate::builder::toml_canon::emit_canonical;

/// The SchemaHash for `_generated/models/<snake>.rs`.
///
/// Input projection: the `[[models]]` entry whose name matches.
/// Returns `None` if the model is not present in the draft.
pub(crate) fn model_hash(draft: &Draft, model_name: &str) -> Option<String> {
    let model = draft.models.iter().find(|m| m.name == model_name)?;
    let mut doc = DocumentMut::new();
    let mut aot = ArrayOfTables::new();
    aot.push(model_projection(model));
    doc["models"] = Item::ArrayOfTables(aot);
    Some(hash_canonical(&emit_canonical(&doc)))
}

/// The SchemaHash for `_generated/admin.rs`.
///
/// Input projection: every model's name + table.
pub(crate) fn admin_hash(draft: &Draft) -> String {
    let mut doc = DocumentMut::new();
    let mut aot = ArrayOfTables::new();
    for model in &draft.models {
        let mut t = Table::new();
        t["name"] = value(model.name.clone());
        t["table"] = value(model.table.clone());
        aot.push(t);
    }
    doc["models"] = Item::ArrayOfTables(aot);
    hash_canonical(&emit_canonical(&doc))
}

/// The SchemaHash for `_generated/mod.rs`.
///
/// Input projection: every model's name only.
pub(crate) fn mod_hash(draft: &Draft) -> String {
    let mut doc = DocumentMut::new();
    let mut aot = ArrayOfTables::new();
    for model in &draft.models {
        let mut t = Table::new();
        t["name"] = value(model.name.clone());
        aot.push(t);
    }
    doc["models"] = Item::ArrayOfTables(aot);
    hash_canonical(&emit_canonical(&doc))
}

/// The SchemaHash for `migrations/NNNN_initial.sql`.
///
/// Input projection: the full models list and the project name.
/// Note this is the *initial* migration only; incremental
/// migrations are out of MVP scope (each only ships at first
/// commit).
pub(crate) fn initial_migration_hash(draft: &Draft) -> String {
    let mut doc = DocumentMut::new();
    let mut project = Table::new();
    project["name"] = value(draft.project.name.clone());
    doc["project"] = Item::Table(project);
    let mut aot = ArrayOfTables::new();
    for model in &draft.models {
        aot.push(model_projection(model));
    }
    doc["models"] = Item::ArrayOfTables(aot);
    hash_canonical(&emit_canonical(&doc))
}

fn model_projection(m: &Model) -> Table {
    let mut t = Table::new();
    t["name"] = value(m.name.clone());
    t["table"] = value(m.table.clone());
    if !m.fields.is_empty() {
        let mut aot = ArrayOfTables::new();
        for f in &m.fields {
            let mut ft = Table::new();
            ft["name"] = value(f.name.clone());
            ft["required"] = value(f.required);
            ft["type"] = value(f.r#type.clone());
            ft["unique"] = value(f.unique);
            aot.push(ft);
        }
        t["fields"] = Item::ArrayOfTables(aot);
    }
    t
}

fn hash_canonical(bytes: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(bytes.as_bytes());
    let digest = hasher.finalize();
    let hex = digest
        .iter()
        .map(|b| format!("{b:02x}"))
        .collect::<String>();
    format!("sha256:{hex}")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::builder::draft::{Field, Project};

    fn sample() -> Draft {
        Draft {
            schema_version: 1,
            project: Project {
                name: "demo".into(),
                rust_version: "1.88".into(),
                builder_pinned: "0.15.1".into(),
                created_at: "2026-05-15T10:30:00Z".into(),
            },
            models: vec![Model {
                name: "Patient".into(),
                table: "patients".into(),
                fields: vec![Field {
                    name: "full_name".into(),
                    r#type: "text".into(),
                    required: true,
                    unique: false,
                }],
            }],
        }
    }

    #[test]
    fn hashes_are_deterministic() {
        let d = sample();
        assert_eq!(model_hash(&d, "Patient"), model_hash(&d, "Patient"));
        assert_eq!(admin_hash(&d), admin_hash(&d));
        assert_eq!(mod_hash(&d), mod_hash(&d));
        assert_eq!(initial_migration_hash(&d), initial_migration_hash(&d));
    }

    #[test]
    fn hashes_share_sha256_prefix() {
        let d = sample();
        for h in [
            model_hash(&d, "Patient").unwrap(),
            admin_hash(&d),
            mod_hash(&d),
            initial_migration_hash(&d),
        ] {
            assert!(h.starts_with("sha256:"), "{h}");
            // sha256: + 64 hex chars = 71 chars
            assert_eq!(h.len(), 71);
        }
    }

    #[test]
    fn model_hash_changes_when_field_added() {
        let mut d = sample();
        let before = model_hash(&d, "Patient").unwrap();
        d.models[0].fields.push(Field {
            name: "dob".into(),
            r#type: "timestamp".into(),
            required: false,
            unique: false,
        });
        let after = model_hash(&d, "Patient").unwrap();
        assert_ne!(before, after);
    }

    #[test]
    fn admin_hash_ignores_unrelated_model_fields() {
        // admin.rs projection includes name+table per model, not
        // the field list. Adding a field to a model must NOT change
        // the admin hash (only the model file's hash).
        let d = sample();
        let admin_before = admin_hash(&d);
        let mut after = d.clone();
        after.models[0].fields.push(Field {
            name: "dob".into(),
            r#type: "timestamp".into(),
            required: false,
            unique: false,
        });
        assert_eq!(admin_before, admin_hash(&after));
    }

    #[test]
    fn model_hash_returns_none_for_absent_model() {
        assert!(model_hash(&sample(), "DoesNotExist").is_none());
    }

    #[test]
    fn hashes_differ_across_projections() {
        // Even with the same draft, different generated files
        // must carry different hashes -- they project different
        // slices of the document.
        let d = sample();
        let m = model_hash(&d, "Patient").unwrap();
        let a = admin_hash(&d);
        let mh = mod_hash(&d);
        let mig = initial_migration_hash(&d);
        let mut all = std::collections::HashSet::new();
        for h in [&m, &a, &mh, &mig] {
            assert!(all.insert(h.clone()), "projection hash collision: {h}");
        }
    }
}