Skip to main content

rustio_core/
schema.rs

1//! Schema export: a deterministic, machine-readable description of every
2//! model the admin knows about.
3//!
4//! The emitted `rustio.schema.json` file is **the** interface between a
5//! RustIO project and external tooling — including the Phase 2 AI layer.
6//! Its shape is versioned and expected to stay stable across patch
7//! releases. Additions in minor releases are allowed; renames and removals
8//! are breaking changes and must bump [`SCHEMA_VERSION`].
9//!
10//! The schema is produced by introspecting a built [`Admin`] registry,
11//! not by parsing source code. This guarantees that whatever the admin
12//! actually serves is what the schema describes.
13//!
14//! ## Determinism contract
15//!
16//! For a given registered model set, `Schema::from_admin` produces
17//! **byte-for-byte identical JSON** on every invocation:
18//!
19//! - Models are emitted sorted by name.
20//! - Fields within a model are emitted sorted by name.
21//! - No timestamps, hashes, or environment-derived values are written
22//!   to the file.
23//!
24//! This is what makes the schema usable as a diff target in CI and as a
25//! stable anchor for AI-layer tooling.
26
27use std::collections::BTreeSet;
28use std::fs;
29use std::path::Path;
30
31use serde::{Deserialize, Serialize};
32
33use crate::admin::{Admin, AdminField, FieldType};
34use crate::error::Error;
35
36/// Version of the `rustio.schema.json` format itself. Independent of the
37/// rustio-core crate version — a single schema version can outlive many
38/// rustio-core releases as long as the wire format doesn't change.
39///
40/// Bumping this value is a **breaking** change: every consumer of the
41/// schema (including the AI layer) will refuse to load older or newer
42/// documents until they are explicitly migrated.
43pub const SCHEMA_VERSION: u32 = 2;
44
45/// The complete set of type names that may appear in
46/// `SchemaField.ty`. Anything outside this set is a schema error and the
47/// AI boundary rejects it. Kept as a `const` so tests and validators
48/// share a single source of truth.
49pub const VALID_TYPE_NAMES: &[&str] = &["i32", "i64", "String", "bool", "DateTime"];
50
51/// Top-level schema document. Serialised as `rustio.schema.json`.
52///
53/// `#[serde(deny_unknown_fields)]` locks the wire format: a future
54/// schema version adding a field will fail to load under the older
55/// rustio-core unless the version number is bumped in lockstep. Combined
56/// with [`SCHEMA_VERSION`], this catches accidental silent drift.
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct Schema {
60    pub version: u32,
61    pub rustio_version: String,
62    pub models: Vec<SchemaModel>,
63}
64
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66#[serde(deny_unknown_fields)]
67pub struct SchemaModel {
68    pub name: String,
69    pub table: String,
70    pub admin_name: String,
71    pub display_name: String,
72    pub singular_name: String,
73    pub fields: Vec<SchemaField>,
74    /// Placeholder for Phase 2. Always empty in 0.4.0 — reserving the
75    /// field now means 0.5.0 can add relations without a breaking change.
76    pub relations: Vec<SchemaRelation>,
77    /// `true` for built-in infrastructure models (e.g. `User`). The AI
78    /// layer uses this to refuse destructive primitives (remove_model,
79    /// remove_field) against core models.
80    #[serde(default)]
81    pub core: bool,
82}
83
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
85#[serde(deny_unknown_fields)]
86pub struct SchemaField {
87    pub name: String,
88    #[serde(rename = "type")]
89    pub ty: String,
90    pub nullable: bool,
91    pub editable: bool,
92    /// 0.8.0: optional typed relation descriptor when the field is a
93    /// foreign key. Old schema files without this key parse cleanly
94    /// (defaults to `None`) and serialise back without it (skipped on
95    /// `None`), so projects that don't use relations are byte-identical
96    /// to the 0.7.x format.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub relation: Option<Relation>,
99}
100
101/// First-class foreign-key annotation on a field.
102///
103/// Only `belongs_to` is stored explicitly. The inverse direction
104/// (`has_many`) is *inferred* at runtime by
105/// [`Schema::incoming_relations`]; adding it as a stored variant
106/// would double-book the same information and drift over time.
107///
108/// Conservative by design: if any of these fields is missing, old
109/// consumers ignore the whole `relation` key because the parent
110/// field is `Option<Relation>`.
111///
112/// ## 0.9.0 write path
113///
114/// The primary writer is the `#[rustio(belongs_to = "...")]` macro
115/// attribute, which populates `AdminRelation` on the compiled model.
116/// `Schema::from_admin` copies that into `SchemaField.relation` so
117/// `rustio.schema.json` always matches the compiled types. Hand-edits
118/// to `rustio.schema.json` still work (the AI layer reads this shape),
119/// but the macro is authoritative: the next `rustio schema` overwrites
120/// any hand-added block without a matching macro attribute.
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
122#[serde(deny_unknown_fields)]
123pub struct Relation {
124    /// Target model name (e.g. `"Patient"`).
125    pub model: String,
126    /// Target field name — conventionally `"id"`, but explicit so a
127    /// future release can support multi-column keys without another
128    /// schema bump.
129    pub field: String,
130    /// Direction marker. Stored writes only accept `BelongsTo`;
131    /// `HasMany` is reserved for inferred inverse results.
132    pub kind: RelationKind,
133    /// 0.9.0 — optional name of the target's column whose value should
134    /// be rendered as the human label for this foreign key in the
135    /// admin. `None` means the admin will show `#<id>` rather than
136    /// guess a column. No inference (`full_name` / `name` / `title`
137    /// fallback) is ever applied — this is opt-in on purpose so that
138    /// operators see raw IDs only when the model author has not
139    /// declared a display field.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub display_field: Option<String>,
142    /// 0.9.0 — whether the FK column is `NOT NULL`. `None` is treated
143    /// the same as `Some(false)` (nullable) — the Option lets 0.8.x
144    /// schema files round-trip byte-identically when they don't know
145    /// about this key.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub required: Option<bool>,
148    /// 0.9.0 — SQL `ON DELETE` policy. Stored as the serialised form
149    /// (`"restrict"` / `"cascade"` / `"set_null"`) so older tooling
150    /// just sees a string. Missing → treat as `restrict`.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub on_delete: Option<String>,
153}
154
155/// Typed relation direction. Kept `#[non_exhaustive]` so a later
156/// pass can add variants (`OneToOne`, `ManyToMany`) without breaking
157/// downstream matchers. Callers must include a wildcard arm.
158#[non_exhaustive]
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(rename_all = "snake_case")]
161pub enum RelationKind {
162    BelongsTo,
163    HasMany,
164}
165
166impl RelationKind {
167    pub fn as_str(self) -> &'static str {
168        match self {
169            RelationKind::BelongsTo => "belongs_to",
170            RelationKind::HasMany => "has_many",
171        }
172    }
173}
174
175/// Placeholder relation shape left from 0.4.0. Still serialised in
176/// `SchemaModel.relations` for backward compatibility (reserved slot
177/// for future per-model metadata). The 0.8.0 flow uses
178/// [`SchemaField::relation`] instead — the per-field location is the
179/// source of truth.
180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181#[serde(deny_unknown_fields)]
182pub struct SchemaRelation {
183    pub kind: String,
184    pub to: String,
185    pub via: String,
186}
187
188/// Reasons a schema can be rejected. Named variants (never raw strings)
189/// so tooling can branch on the failure kind.
190#[non_exhaustive]
191#[derive(Debug, Clone, PartialEq)]
192pub enum SchemaError {
193    /// The document's `version` field doesn't match [`SCHEMA_VERSION`].
194    VersionMismatch { found: u32, expected: u32 },
195    /// Two models share the same `name`.
196    DuplicateModel(String),
197    /// Two fields in the same model share the same `name`.
198    DuplicateField { model: String, field: String },
199    /// A field's `type` is not in [`VALID_TYPE_NAMES`].
200    InvalidType {
201        model: String,
202        field: String,
203        ty: String,
204    },
205    /// A relation's `to` doesn't name any model in the schema.
206    UnknownRelationTarget { from: String, to: String },
207    /// An identifier-shaped string is empty. Guards against callers that
208    /// forget to fill in `name`, `table`, etc.
209    EmptyIdentifier(&'static str),
210    /// Failed to parse a schema document from its on-disk bytes.
211    Parse(String),
212}
213
214impl std::fmt::Display for SchemaError {
215    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216        match self {
217            Self::VersionMismatch { found, expected } => write!(
218                f,
219                "schema version mismatch: found {found}, expected {expected}"
220            ),
221            Self::DuplicateModel(name) => write!(f, "duplicate model `{name}`"),
222            Self::DuplicateField { model, field } => {
223                write!(f, "duplicate field `{field}` in model `{model}`")
224            }
225            Self::InvalidType { model, field, ty } => write!(
226                f,
227                "field `{model}.{field}` has invalid type `{ty}` (valid: {valid})",
228                valid = VALID_TYPE_NAMES.join(", "),
229            ),
230            Self::UnknownRelationTarget { from, to } => {
231                write!(f, "relation from `{from}` targets unknown model `{to}`")
232            }
233            Self::EmptyIdentifier(which) => write!(f, "empty {which}"),
234            Self::Parse(msg) => write!(f, "schema parse error: {msg}"),
235        }
236    }
237}
238
239impl std::error::Error for SchemaError {}
240
241impl From<SchemaError> for Error {
242    fn from(e: SchemaError) -> Self {
243        Error::Internal(e.to_string())
244    }
245}
246
247/// 0.8.0 — one inferred incoming relation. Produced by
248/// [`Schema::incoming_relations`]; not stored on disk.
249#[derive(Debug, Clone, PartialEq, Eq)]
250pub struct IncomingRelation {
251    /// The child model name (holder of the foreign-key field).
252    pub from_model: String,
253    /// The child field that points at the target.
254    pub from_field: String,
255    /// Always the target's own name — supplied for symmetry.
256    pub to_model: String,
257    /// Always `RelationKind::BelongsTo` in the stored direction;
258    /// `HasMany` is the implicit inverse a caller is asking about.
259    pub kind: RelationKind,
260}
261
262impl Schema {
263    /// Look up the `Relation` descriptor attached to `(model, field)`,
264    /// if the field carries one. Returns `None` if the model or field
265    /// doesn't exist, or if the field has no relation metadata. A
266    /// schema without relations behaves identically to pre-0.8.0 —
267    /// this accessor just returns `None` everywhere.
268    pub fn relation_for(&self, model: &str, field: &str) -> Option<&Relation> {
269        self.models
270            .iter()
271            .find(|m| m.name == model)?
272            .fields
273            .iter()
274            .find(|f| f.name == field)?
275            .relation
276            .as_ref()
277    }
278
279    /// Enumerate every `belongs_to` relation in the schema that points
280    /// *at* `model` — i.e. the `has_many` view. Order follows model
281    /// order, then field order inside the model. Empty when no field
282    /// in the schema references `model`. Deterministic.
283    pub fn incoming_relations(&self, model: &str) -> Vec<IncomingRelation> {
284        let mut out: Vec<IncomingRelation> = Vec::new();
285        for m in &self.models {
286            for f in &m.fields {
287                if let Some(rel) = &f.relation {
288                    if rel.model == model && matches!(rel.kind, RelationKind::BelongsTo) {
289                        out.push(IncomingRelation {
290                            from_model: m.name.clone(),
291                            from_field: f.name.clone(),
292                            to_model: model.to_string(),
293                            kind: RelationKind::HasMany,
294                        });
295                    }
296                }
297            }
298        }
299        out
300    }
301
302    /// Build a schema from an already-constructed [`Admin`]. This is the
303    /// single supported path — we don't parse Rust sources or read the
304    /// DB, so whatever the admin is serving is exactly what the schema
305    /// describes.
306    ///
307    /// Output is deterministic: models and fields are emitted in sorted
308    /// order so two invocations on the same registry produce identical
309    /// JSON bytes.
310    pub fn from_admin(admin: &Admin) -> Self {
311        let mut models: Vec<SchemaModel> = admin
312            .entries()
313            .iter()
314            .map(SchemaModel::from_entry)
315            .collect();
316        models.sort_by(|a, b| a.name.cmp(&b.name));
317        Self {
318            version: SCHEMA_VERSION,
319            rustio_version: env!("CARGO_PKG_VERSION").to_string(),
320            models,
321        }
322    }
323
324    /// Check the schema for internal consistency. Every production
325    /// writer should call this before persisting and every consumer
326    /// (including the AI layer) should call it after loading. The error
327    /// is the first problem found; fix and revalidate.
328    pub fn validate(&self) -> Result<(), SchemaError> {
329        if self.version != SCHEMA_VERSION {
330            return Err(SchemaError::VersionMismatch {
331                found: self.version,
332                expected: SCHEMA_VERSION,
333            });
334        }
335
336        let mut model_names: BTreeSet<&str> = BTreeSet::new();
337        for model in &self.models {
338            if model.name.is_empty() {
339                return Err(SchemaError::EmptyIdentifier("model name"));
340            }
341            if model.table.is_empty() {
342                return Err(SchemaError::EmptyIdentifier("model table"));
343            }
344            if !model_names.insert(model.name.as_str()) {
345                return Err(SchemaError::DuplicateModel(model.name.clone()));
346            }
347        }
348
349        let valid_types: BTreeSet<&str> = VALID_TYPE_NAMES.iter().copied().collect();
350
351        for model in &self.models {
352            let mut field_names: BTreeSet<&str> = BTreeSet::new();
353            for field in &model.fields {
354                if field.name.is_empty() {
355                    return Err(SchemaError::EmptyIdentifier("field name"));
356                }
357                if !field_names.insert(field.name.as_str()) {
358                    return Err(SchemaError::DuplicateField {
359                        model: model.name.clone(),
360                        field: field.name.clone(),
361                    });
362                }
363                if !valid_types.contains(field.ty.as_str()) {
364                    return Err(SchemaError::InvalidType {
365                        model: model.name.clone(),
366                        field: field.name.clone(),
367                        ty: field.ty.clone(),
368                    });
369                }
370            }
371
372            for relation in &model.relations {
373                if !model_names.contains(relation.to.as_str()) {
374                    return Err(SchemaError::UnknownRelationTarget {
375                        from: model.name.clone(),
376                        to: relation.to.clone(),
377                    });
378                }
379            }
380        }
381
382        Ok(())
383    }
384
385    /// Parse + validate a schema document. Both deserialization failure
386    /// (unknown fields, wrong types, missing keys) and any semantic
387    /// problem surface as [`SchemaError`]. Safe default for anything
388    /// reading a `rustio.schema.json` off disk.
389    pub fn parse(json: &str) -> Result<Self, SchemaError> {
390        let schema: Schema =
391            serde_json::from_str(json).map_err(|e| SchemaError::Parse(e.to_string()))?;
392        schema.validate()?;
393        Ok(schema)
394    }
395
396    /// Serialise to pretty JSON with a trailing newline. We pretty-print
397    /// on purpose: the file is meant to be read by humans during code
398    /// review and by AI tools that benefit from stable line-level
399    /// anchors.
400    pub fn to_pretty_json(&self) -> Result<String, Error> {
401        let mut out =
402            serde_json::to_string_pretty(self).map_err(|e| Error::Internal(e.to_string()))?;
403        out.push('\n');
404        Ok(out)
405    }
406
407    /// Write the schema to a file, atomically. Validates first so a
408    /// broken schema never lands on disk. Uses a temp-file + rename so
409    /// a concurrent reader can never observe a half-written JSON file.
410    pub fn write_to(&self, path: &Path) -> Result<(), Error> {
411        self.validate()?;
412        let json = self.to_pretty_json()?;
413        let tmp = path.with_extension("json.tmp");
414        // Best-effort cleanup if a previous aborted run left the tmp
415        // behind; we ignore errors because `write` will surface any
416        // real permission problem.
417        let _ = fs::remove_file(&tmp);
418        fs::write(&tmp, json).map_err(|e| Error::Internal(e.to_string()))?;
419        if let Err(e) = fs::rename(&tmp, path) {
420            // Rename failed — clean up the tmp so we don't leave a
421            // stale `.json.tmp` next to the target on retry.
422            let _ = fs::remove_file(&tmp);
423            return Err(Error::Internal(e.to_string()));
424        }
425        Ok(())
426    }
427}
428
429impl SchemaModel {
430    fn from_entry(entry: &crate::admin::AdminEntry) -> Self {
431        let mut fields: Vec<SchemaField> = entry
432            .fields
433            .iter()
434            .map(SchemaField::from_admin_field)
435            .collect();
436        fields.sort_by(|a, b| a.name.cmp(&b.name));
437        Self {
438            name: entry.singular_name.to_string(),
439            table: entry.table.to_string(),
440            admin_name: entry.admin_name.to_string(),
441            display_name: entry.display_name.to_string(),
442            singular_name: entry.singular_name.to_string(),
443            fields,
444            relations: Vec::new(),
445            core: entry.core,
446        }
447    }
448}
449
450impl SchemaField {
451    fn from_admin_field(f: &AdminField) -> Self {
452        // 0.9.0: `#[rustio(belongs_to = "Target", display = "col")]` on
453        // a struct field is the only writer the admin blessed. The
454        // compile-time `AdminRelation` carries the declaration; we copy
455        // it verbatim into the owned-string schema shape. Fields with
456        // no macro annotation stay `relation: None`, preserving
457        // byte-identical output for projects that don't use relations.
458        let relation = f.relation.map(|r| Relation {
459            model: r.model.to_string(),
460            field: "id".to_string(),
461            kind: r.kind,
462            display_field: r.display_field.map(|s| s.to_string()),
463            // 0.9.0 fields — only populated from primitives / retrofits.
464            // `Schema::from_admin` cannot see the FK metadata yet, so
465            // leave them as `None` (treated as "nullable + restrict").
466            required: None,
467            on_delete: None,
468        });
469        Self {
470            name: f.name.to_string(),
471            ty: field_type_name(f.ty).to_string(),
472            nullable: f.nullable,
473            editable: f.editable,
474            relation,
475        }
476    }
477}
478
479/// Stable string identifier for each [`FieldType`] variant. Used in the
480/// exported schema and as the primary key external tools key off of.
481/// **Changing a mapping here is a breaking change** — bump
482/// [`SCHEMA_VERSION`] if you ever have to.
483///
484/// We deliberately do NOT include a wildcard arm. `FieldType` is
485/// `#[non_exhaustive]` only to downstream crates; inside rustio-core any
486/// added variant must be mapped here or the build breaks — exactly the
487/// signal we want when extending the type system.
488pub(crate) fn field_type_name(ty: FieldType) -> &'static str {
489    match ty {
490        FieldType::I32 => "i32",
491        FieldType::I64 => "i64",
492        FieldType::String => "String",
493        FieldType::Bool => "bool",
494        FieldType::DateTime => "DateTime",
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501    use crate::admin::{Admin, AdminField, AdminModel, FieldType, FormData};
502    use crate::error::Error;
503    use crate::orm::{Model, Row, Value};
504
505    struct Post;
506
507    impl Model for Post {
508        const TABLE: &'static str = "posts";
509        const COLUMNS: &'static [&'static str] = &["id", "title", "published_at"];
510        const INSERT_COLUMNS: &'static [&'static str] = &["title", "published_at"];
511        fn id(&self) -> i64 {
512            0
513        }
514        fn from_row(_: Row<'_>) -> Result<Self, Error> {
515            unimplemented!()
516        }
517        fn insert_values(&self) -> Vec<Value> {
518            Vec::new()
519        }
520    }
521
522    impl AdminModel for Post {
523        const ADMIN_NAME: &'static str = "posts";
524        const DISPLAY_NAME: &'static str = "Posts";
525        const FIELDS: &'static [AdminField] = &[
526            AdminField {
527                name: "id",
528                ty: FieldType::I64,
529                editable: false,
530                nullable: false,
531                relation: None,
532            },
533            AdminField {
534                name: "title",
535                ty: FieldType::String,
536                editable: true,
537                nullable: false,
538                relation: None,
539            },
540            AdminField {
541                name: "published_at",
542                ty: FieldType::DateTime,
543                editable: true,
544                nullable: true,
545                relation: None,
546            },
547        ];
548        fn singular_name() -> &'static str {
549            "Post"
550        }
551        fn field_display(&self, _: &str) -> Option<String> {
552            None
553        }
554        fn from_form(_: &FormData, _: Option<i64>) -> Result<Self, Error> {
555            unimplemented!()
556        }
557    }
558
559    // A second non-core model for tests that need two entries side by
560    // side. Not called `User` because the built-in core `User` entry is
561    // already seeded by `Admin::new()`.
562    struct Book;
563
564    impl Model for Book {
565        const TABLE: &'static str = "books";
566        const COLUMNS: &'static [&'static str] = &["id", "title"];
567        const INSERT_COLUMNS: &'static [&'static str] = &["title"];
568        fn id(&self) -> i64 {
569            0
570        }
571        fn from_row(_: Row<'_>) -> Result<Self, Error> {
572            unimplemented!()
573        }
574        fn insert_values(&self) -> Vec<Value> {
575            Vec::new()
576        }
577    }
578
579    impl AdminModel for Book {
580        const ADMIN_NAME: &'static str = "books";
581        const DISPLAY_NAME: &'static str = "Books";
582        const FIELDS: &'static [AdminField] = &[
583            AdminField {
584                name: "id",
585                ty: FieldType::I64,
586                editable: false,
587                nullable: false,
588                relation: None,
589            },
590            AdminField {
591                name: "title",
592                ty: FieldType::String,
593                editable: true,
594                nullable: false,
595                relation: None,
596            },
597        ];
598        fn singular_name() -> &'static str {
599            "Book"
600        }
601        fn field_display(&self, _: &str) -> Option<String> {
602            None
603        }
604        fn from_form(_: &FormData, _: Option<i64>) -> Result<Self, Error> {
605            unimplemented!()
606        }
607    }
608
609    /// Find a model by name. Used through the tests because `Admin::new()`
610    /// seeds the built-in core `User`, so `schema.models[0]` isn't a
611    /// stable reference to "the first user-registered model".
612    fn find<'a>(schema: &'a Schema, name: &str) -> &'a SchemaModel {
613        schema
614            .models
615            .iter()
616            .find(|m| m.name == name)
617            .unwrap_or_else(|| panic!("no model named `{name}` in schema"))
618    }
619
620    #[test]
621    fn schema_reflects_admin_registry() {
622        let admin = Admin::new().model::<Post>();
623        let schema = Schema::from_admin(&admin);
624
625        assert_eq!(schema.version, SCHEMA_VERSION);
626        // Core `User` + registered `Post`.
627        assert_eq!(schema.models.len(), 2);
628
629        let m = find(&schema, "Post");
630        assert_eq!(m.table, "posts");
631        assert_eq!(m.admin_name, "posts");
632        assert_eq!(m.display_name, "Posts");
633        assert_eq!(m.singular_name, "Post");
634        assert_eq!(m.fields.len(), 3);
635        assert!(m.relations.is_empty());
636        assert!(!m.core, "user models must not be marked core");
637
638        let title = m.fields.iter().find(|f| f.name == "title").unwrap();
639        assert_eq!(title.ty, "String");
640        assert!(!title.nullable);
641        assert!(title.editable);
642
643        let pub_at = m.fields.iter().find(|f| f.name == "published_at").unwrap();
644        assert_eq!(pub_at.ty, "DateTime");
645        assert!(pub_at.nullable);
646        assert!(pub_at.editable);
647    }
648
649    #[test]
650    fn core_user_model_is_always_present() {
651        // The spec requires User in every project's schema. This is the
652        // test that fails if someone accidentally removes the seeding
653        // from `Admin::new()`.
654        let schema = Schema::from_admin(&Admin::new());
655        let user = find(&schema, "User");
656        assert!(user.core, "User must be flagged as a core model");
657        assert_eq!(user.table, "rustio_users");
658        let pw = user
659            .fields
660            .iter()
661            .find(|f| f.name == "password_hash")
662            .unwrap();
663        assert!(
664            !pw.editable,
665            "password_hash must never be exposed as editable via admin"
666        );
667        // created_at mirrors the real DB column — guards against the
668        // schema under-describing the actual table shape.
669        let created_at = user.fields.iter().find(|f| f.name == "created_at").unwrap();
670        assert_eq!(created_at.ty, "DateTime");
671        assert!(!created_at.editable);
672    }
673
674    #[test]
675    fn schema_fields_are_sorted_by_name() {
676        // Admin declares id, title, published_at in that order. The
677        // schema must re-emit them alphabetically so the file is a
678        // diffable source-of-truth.
679        let schema = Schema::from_admin(&Admin::new().model::<Post>());
680        let post = find(&schema, "Post");
681        let names: Vec<&str> = post.fields.iter().map(|f| f.name.as_str()).collect();
682        assert_eq!(names, vec!["id", "published_at", "title"]);
683    }
684
685    #[test]
686    fn schema_models_are_sorted_by_name() {
687        // Register Post + Book (not User — that name collides with the
688        // core model). Expect alphabetical output: Book, Post, User.
689        let schema = Schema::from_admin(&Admin::new().model::<Post>().model::<Book>());
690        let names: Vec<&str> = schema.models.iter().map(|m| m.name.as_str()).collect();
691        assert_eq!(names, vec!["Book", "Post", "User"]);
692    }
693
694    #[test]
695    fn to_pretty_json_round_trips() {
696        let schema = Schema::from_admin(&Admin::new().model::<Post>());
697        let json = schema.to_pretty_json().unwrap();
698        let parsed = Schema::parse(&json).unwrap();
699        assert_eq!(parsed, schema);
700    }
701
702    #[test]
703    fn to_pretty_json_ends_with_newline() {
704        let schema = Schema::from_admin(&Admin::new().model::<Post>());
705        let json = schema.to_pretty_json().unwrap();
706        assert!(json.ends_with('\n'), "schema JSON must end with newline");
707    }
708
709    #[test]
710    fn same_registry_produces_identical_bytes() {
711        // The determinism contract: identical inputs → identical bytes.
712        // If this ever fails, someone added a clock, hash, or env read
713        // to the serialisation path.
714        let a = Schema::from_admin(&Admin::new().model::<Post>().model::<Book>())
715            .to_pretty_json()
716            .unwrap();
717        let b = Schema::from_admin(&Admin::new().model::<Post>().model::<Book>())
718            .to_pretty_json()
719            .unwrap();
720        assert_eq!(a, b);
721    }
722
723    /// Byte-for-byte snapshot.
724    ///
725    /// Locks the wire format of `rustio.schema.json`. Any diff in field
726    /// ordering, type-name mapping, or surrounding JSON punctuation
727    /// fails this test. If an intentional shape change is landing, bump
728    /// [`SCHEMA_VERSION`] and update both the expected string and every
729    /// consumer in the same PR.
730    #[test]
731    fn schema_snapshot_is_byte_for_byte_stable() {
732        // Register only `Post`; the core `User` is seeded automatically
733        // by `Admin::new()`. The expected JSON below is the *complete*
734        // wire format: locking both the test model and the core User
735        // fields in place.
736        let schema = Schema::from_admin(&Admin::new().model::<Post>());
737        let actual = schema.to_pretty_json().unwrap();
738
739        let expected = format!(
740            r#"{{
741  "version": {sv},
742  "rustio_version": "{rv}",
743  "models": [
744    {{
745      "name": "Post",
746      "table": "posts",
747      "admin_name": "posts",
748      "display_name": "Posts",
749      "singular_name": "Post",
750      "fields": [
751        {{
752          "name": "id",
753          "type": "i64",
754          "nullable": false,
755          "editable": false
756        }},
757        {{
758          "name": "published_at",
759          "type": "DateTime",
760          "nullable": true,
761          "editable": true
762        }},
763        {{
764          "name": "title",
765          "type": "String",
766          "nullable": false,
767          "editable": true
768        }}
769      ],
770      "relations": [],
771      "core": false
772    }},
773    {{
774      "name": "User",
775      "table": "rustio_users",
776      "admin_name": "users",
777      "display_name": "Users",
778      "singular_name": "User",
779      "fields": [
780        {{
781          "name": "created_at",
782          "type": "DateTime",
783          "nullable": false,
784          "editable": false
785        }},
786        {{
787          "name": "email",
788          "type": "String",
789          "nullable": false,
790          "editable": true
791        }},
792        {{
793          "name": "id",
794          "type": "i64",
795          "nullable": false,
796          "editable": false
797        }},
798        {{
799          "name": "is_active",
800          "type": "bool",
801          "nullable": false,
802          "editable": true
803        }},
804        {{
805          "name": "password_hash",
806          "type": "String",
807          "nullable": false,
808          "editable": false
809        }},
810        {{
811          "name": "role",
812          "type": "String",
813          "nullable": false,
814          "editable": true
815        }}
816      ],
817      "relations": [],
818      "core": true
819    }}
820  ]
821}}
822"#,
823            rv = env!("CARGO_PKG_VERSION"),
824            sv = SCHEMA_VERSION,
825        );
826
827        assert_eq!(actual, expected);
828    }
829
830    #[test]
831    fn validate_accepts_clean_schema() {
832        let schema = Schema::from_admin(&Admin::new().model::<Post>().model::<Book>());
833        assert_eq!(schema.validate(), Ok(()));
834    }
835
836    #[test]
837    fn validate_rejects_version_mismatch() {
838        let mut schema = Schema::from_admin(&Admin::new().model::<Post>());
839        schema.version = 999;
840        assert_eq!(
841            schema.validate(),
842            Err(SchemaError::VersionMismatch {
843                found: 999,
844                expected: SCHEMA_VERSION
845            })
846        );
847    }
848
849    #[test]
850    fn validate_rejects_duplicate_models() {
851        let mut schema = Schema::from_admin(&Admin::new().model::<Post>());
852        let post = find(&schema, "Post").clone();
853        schema.models.push(post);
854        assert_eq!(
855            schema.validate(),
856            Err(SchemaError::DuplicateModel("Post".to_string()))
857        );
858    }
859
860    #[test]
861    fn validate_rejects_duplicate_fields() {
862        let mut schema = Schema::from_admin(&Admin::new().model::<Post>());
863        let post_idx = schema.models.iter().position(|m| m.name == "Post").unwrap();
864        let dup = schema.models[post_idx].fields[0].clone();
865        schema.models[post_idx].fields.push(dup);
866        assert_eq!(
867            schema.validate(),
868            Err(SchemaError::DuplicateField {
869                model: "Post".to_string(),
870                field: "id".to_string(),
871            })
872        );
873    }
874
875    #[test]
876    fn validate_rejects_unknown_type() {
877        let mut schema = Schema::from_admin(&Admin::new().model::<Post>());
878        let post_idx = schema.models.iter().position(|m| m.name == "Post").unwrap();
879        schema.models[post_idx].fields[0].ty = "HyperFloat128".to_string();
880        assert_eq!(
881            schema.validate(),
882            Err(SchemaError::InvalidType {
883                model: "Post".to_string(),
884                field: "id".to_string(),
885                ty: "HyperFloat128".to_string(),
886            })
887        );
888    }
889
890    #[test]
891    fn validate_rejects_dangling_relation() {
892        let mut schema = Schema::from_admin(&Admin::new().model::<Post>());
893        let post_idx = schema.models.iter().position(|m| m.name == "Post").unwrap();
894        schema.models[post_idx].relations.push(SchemaRelation {
895            kind: "belongs_to".to_string(),
896            to: "Ghost".to_string(),
897            via: "ghost_id".to_string(),
898        });
899        assert_eq!(
900            schema.validate(),
901            Err(SchemaError::UnknownRelationTarget {
902                from: "Post".to_string(),
903                to: "Ghost".to_string(),
904            })
905        );
906    }
907
908    #[test]
909    fn validate_accepts_self_referencing_relation() {
910        // A model may reference itself — common for tree-shaped data
911        // (parent/child). Reject only *dangling* targets, not recursion.
912        let mut schema = Schema::from_admin(&Admin::new().model::<Post>());
913        let post_idx = schema.models.iter().position(|m| m.name == "Post").unwrap();
914        schema.models[post_idx].relations.push(SchemaRelation {
915            kind: "belongs_to".to_string(),
916            to: "Post".to_string(),
917            via: "parent_id".to_string(),
918        });
919        assert_eq!(schema.validate(), Ok(()));
920    }
921
922    #[test]
923    fn parse_rejects_unknown_top_level_field() {
924        let bad = r#"{
925            "version": 1,
926            "rustio_version": "0.4.0",
927            "models": [],
928            "something_extra": true
929        }"#;
930        let result = Schema::parse(bad);
931        assert!(
932            matches!(result, Err(SchemaError::Parse(_))),
933            "unknown fields must be rejected, got: {:?}",
934            result
935        );
936    }
937
938    #[test]
939    fn parse_rejects_missing_required_field() {
940        // `rustio_version` is required; dropping it must fail.
941        let bad = r#"{
942            "version": 1,
943            "models": []
944        }"#;
945        let result = Schema::parse(bad);
946        assert!(
947            matches!(result, Err(SchemaError::Parse(_))),
948            "missing fields must be rejected"
949        );
950    }
951
952    #[test]
953    fn parse_rejects_version_mismatch() {
954        let bad = r#"{
955            "version": 999,
956            "rustio_version": "0.4.0",
957            "models": []
958        }"#;
959        let err = Schema::parse(bad).unwrap_err();
960        assert!(matches!(err, SchemaError::VersionMismatch { .. }));
961    }
962
963    #[test]
964    fn write_to_is_atomic_no_tmp_left_behind() {
965        let tmp_dir = std::env::temp_dir().join(format!(
966            "rustio-schema-write-{}-{}",
967            std::process::id(),
968            std::time::SystemTime::now()
969                .duration_since(std::time::UNIX_EPOCH)
970                .unwrap()
971                .as_nanos()
972        ));
973        std::fs::create_dir_all(&tmp_dir).unwrap();
974        let target = tmp_dir.join("rustio.schema.json");
975
976        let schema = Schema::from_admin(&Admin::new().model::<Post>());
977        schema.write_to(&target).unwrap();
978
979        // File exists and parses back identically.
980        let bytes = std::fs::read_to_string(&target).unwrap();
981        let parsed = Schema::parse(&bytes).unwrap();
982        assert_eq!(parsed, schema);
983
984        // No leftover temp file — the `.json.tmp` sibling should not
985        // exist after a successful rename.
986        assert!(!tmp_dir.join("rustio.schema.tmp").exists());
987        assert!(!tmp_dir.join("rustio.schema.json.tmp").exists());
988
989        std::fs::remove_dir_all(&tmp_dir).ok();
990    }
991}