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. NEW's
454        // `AdminRelation` is slimmer than OLD's — only `target_model`
455        // and `display_field`. The relation kind is implicitly
456        // `BelongsTo` (NEW doesn't model HasMany at the field level).
457        let relation = f.relation.as_ref().map(|r| Relation {
458            model: r.target_model.to_string(),
459            field: "id".to_string(),
460            kind: RelationKind::BelongsTo,
461            display_field: r.display_field.map(|s| s.to_string()),
462            // 0.9.0 fields — only populated from primitives / retrofits.
463            // `Schema::from_admin` cannot see the FK metadata yet, so
464            // leave them as `None` (treated as "nullable + restrict").
465            required: None,
466            on_delete: None,
467        });
468        Self {
469            name: f.name.to_string(),
470            // NEW's `AdminField.field_type` is a `FieldType` enum; OLD's
471            // had a separate `nullable` bool. NEW encodes nullability in
472            // `OptionalI64` / `OptionalString` variants, so the bool
473            // comes back via the enum's `nullable()` accessor.
474            ty: field_type_name(f.field_type).to_string(),
475            nullable: f.field_type.nullable(),
476            editable: f.editable,
477            relation,
478        }
479    }
480}
481
482/// Stable string identifier for each [`FieldType`] variant. Used in the
483/// exported schema and as the primary key external tools key off of.
484/// **Changing a mapping here is a breaking change** — bump
485/// [`SCHEMA_VERSION`] if you ever have to.
486///
487/// We deliberately do NOT include a wildcard arm. `FieldType` is
488/// `#[non_exhaustive]` only to downstream crates; inside rustio-core any
489/// added variant must be mapped here or the build breaks — exactly the
490/// signal we want when extending the type system.
491pub(crate) fn field_type_name(ty: FieldType) -> &'static str {
492    // Nullability is captured separately in `SchemaField.nullable`, so
493    // the optional variants map to their non-optional name. The two
494    // shapes round-trip through `(ty, nullable)` faithfully.
495    match ty {
496        FieldType::I32 => "i32",
497        FieldType::I64 => "i64",
498        FieldType::String => "String",
499        FieldType::Bool => "bool",
500        FieldType::DateTime | FieldType::OptionalDateTime => "DateTime",
501        FieldType::OptionalI64 => "i64",
502        FieldType::OptionalString => "String",
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use crate::admin::{Admin, AdminField, AdminModel, FieldType};
510    use crate::http::FormData;
511    use crate::error::Error;
512    use crate::orm::{Model, Row, Value};
513
514    struct Post;
515
516    impl Model for Post {
517        const TABLE: &'static str = "posts";
518        const COLUMNS: &'static [&'static str] = &["id", "title", "published_at"];
519        const INSERT_COLUMNS: &'static [&'static str] = &["title", "published_at"];
520        fn id(&self) -> i64 {
521            0
522        }
523        fn from_row(_: Row<'_>) -> Result<Self, Error> {
524            unimplemented!()
525        }
526        fn insert_values(&self) -> Vec<Value> {
527            Vec::new()
528        }
529    }
530
531    // Adapted to NEW's AdminModel surface: SINGULAR_NAME is a const,
532    // not a method; AdminField has `field_type` (no separate `nullable`
533    // — encoded in OptionalI64 / OptionalString / OptionalDateTime
534    // variants); `from_form` takes one arg and returns Vec<String>;
535    // `display_values`, `object_label`, `id`, `values_to_update` are
536    // mandatory methods.
537    impl AdminModel for Post {
538        const ADMIN_NAME: &'static str = "posts";
539        const DISPLAY_NAME: &'static str = "Posts";
540        const SINGULAR_NAME: &'static str = "Post";
541        const FIELDS: &'static [AdminField] = &[
542            AdminField {
543                name: "id",
544                label: "id",
545                field_type: FieldType::I64,
546                editable: false,
547                relation: None,
548                choices: None,
549            },
550            AdminField {
551                name: "title",
552                label: "title",
553                field_type: FieldType::String,
554                editable: true,
555                relation: None,
556                choices: None,
557            },
558            AdminField {
559                name: "published_at",
560                label: "published_at",
561                field_type: FieldType::OptionalDateTime,
562                editable: true,
563                relation: None,
564                choices: None,
565            },
566        ];
567        fn display_values(&self) -> Vec<(String, String)> {
568            Vec::new()
569        }
570        fn from_form(_: &FormData) -> std::result::Result<Self, Vec<String>> {
571            unimplemented!()
572        }
573        fn object_label(&self) -> String {
574            "Post".into()
575        }
576        fn id(&self) -> i64 {
577            0
578        }
579        fn values_to_update(&self) -> Vec<(&'static str, Value)> {
580            Vec::new()
581        }
582    }
583
584    // A second non-core model for tests that need two entries side by
585    // side. Not called `User` because the built-in core `User` entry is
586    // already seeded by `Admin::new()`.
587    struct Book;
588
589    impl Model for Book {
590        const TABLE: &'static str = "books";
591        const COLUMNS: &'static [&'static str] = &["id", "title"];
592        const INSERT_COLUMNS: &'static [&'static str] = &["title"];
593        fn id(&self) -> i64 {
594            0
595        }
596        fn from_row(_: Row<'_>) -> Result<Self, Error> {
597            unimplemented!()
598        }
599        fn insert_values(&self) -> Vec<Value> {
600            Vec::new()
601        }
602    }
603
604    impl AdminModel for Book {
605        const ADMIN_NAME: &'static str = "books";
606        const DISPLAY_NAME: &'static str = "Books";
607        const SINGULAR_NAME: &'static str = "Book";
608        const FIELDS: &'static [AdminField] = &[
609            AdminField {
610                name: "id",
611                label: "id",
612                field_type: FieldType::I64,
613                editable: false,
614                relation: None,
615                choices: None,
616            },
617            AdminField {
618                name: "title",
619                label: "title",
620                field_type: FieldType::String,
621                editable: true,
622                relation: None,
623                choices: None,
624            },
625        ];
626        fn display_values(&self) -> Vec<(String, String)> {
627            Vec::new()
628        }
629        fn from_form(_: &FormData) -> std::result::Result<Self, Vec<String>> {
630            unimplemented!()
631        }
632        fn object_label(&self) -> String {
633            "Book".into()
634        }
635        fn id(&self) -> i64 {
636            0
637        }
638        fn values_to_update(&self) -> Vec<(&'static str, Value)> {
639            Vec::new()
640        }
641    }
642
643    /// Find a model by name. Used through the tests because `Admin::new()`
644    /// seeds the built-in core `User`, so `schema.models[0]` isn't a
645    /// stable reference to "the first user-registered model".
646    fn find<'a>(schema: &'a Schema, name: &str) -> &'a SchemaModel {
647        schema
648            .models
649            .iter()
650            .find(|m| m.name == name)
651            .unwrap_or_else(|| panic!("no model named `{name}` in schema"))
652    }
653
654    #[test]
655    fn schema_reflects_admin_registry() {
656        let admin = Admin::new().model::<Post>();
657        let schema = Schema::from_admin(&admin);
658
659        assert_eq!(schema.version, SCHEMA_VERSION);
660        // Core `User` + registered `Post`.
661        assert_eq!(schema.models.len(), 2);
662
663        let m = find(&schema, "Post");
664        assert_eq!(m.table, "posts");
665        assert_eq!(m.admin_name, "posts");
666        assert_eq!(m.display_name, "Posts");
667        assert_eq!(m.singular_name, "Post");
668        assert_eq!(m.fields.len(), 3);
669        assert!(m.relations.is_empty());
670        assert!(!m.core, "user models must not be marked core");
671
672        let title = m.fields.iter().find(|f| f.name == "title").unwrap();
673        assert_eq!(title.ty, "String");
674        assert!(!title.nullable);
675        assert!(title.editable);
676
677        let pub_at = m.fields.iter().find(|f| f.name == "published_at").unwrap();
678        assert_eq!(pub_at.ty, "DateTime");
679        assert!(pub_at.nullable);
680        assert!(pub_at.editable);
681    }
682
683    #[test]
684    fn core_user_model_is_always_present() {
685        // The spec requires User in every project's schema. This is the
686        // test that fails if someone accidentally removes the seeding
687        // from `Admin::new()`.
688        let schema = Schema::from_admin(&Admin::new());
689        let user = find(&schema, "User");
690        assert!(user.core, "User must be flagged as a core model");
691        assert_eq!(user.table, "rustio_users");
692        let pw = user
693            .fields
694            .iter()
695            .find(|f| f.name == "password_hash")
696            .unwrap();
697        assert!(
698            !pw.editable,
699            "password_hash must never be exposed as editable via admin"
700        );
701        // created_at mirrors the real DB column — guards against the
702        // schema under-describing the actual table shape.
703        let created_at = user.fields.iter().find(|f| f.name == "created_at").unwrap();
704        assert_eq!(created_at.ty, "DateTime");
705        assert!(!created_at.editable);
706    }
707
708    #[test]
709    fn schema_fields_are_sorted_by_name() {
710        // Admin declares id, title, published_at in that order. The
711        // schema must re-emit them alphabetically so the file is a
712        // diffable source-of-truth.
713        let schema = Schema::from_admin(&Admin::new().model::<Post>());
714        let post = find(&schema, "Post");
715        let names: Vec<&str> = post.fields.iter().map(|f| f.name.as_str()).collect();
716        assert_eq!(names, vec!["id", "published_at", "title"]);
717    }
718
719    #[test]
720    fn schema_models_are_sorted_by_name() {
721        // Register Post + Book (not User — that name collides with the
722        // core model). Expect alphabetical output: Book, Post, User.
723        let schema = Schema::from_admin(&Admin::new().model::<Post>().model::<Book>());
724        let names: Vec<&str> = schema.models.iter().map(|m| m.name.as_str()).collect();
725        assert_eq!(names, vec!["Book", "Post", "User"]);
726    }
727
728    #[test]
729    fn to_pretty_json_round_trips() {
730        let schema = Schema::from_admin(&Admin::new().model::<Post>());
731        let json = schema.to_pretty_json().unwrap();
732        let parsed = Schema::parse(&json).unwrap();
733        assert_eq!(parsed, schema);
734    }
735
736    #[test]
737    fn to_pretty_json_ends_with_newline() {
738        let schema = Schema::from_admin(&Admin::new().model::<Post>());
739        let json = schema.to_pretty_json().unwrap();
740        assert!(json.ends_with('\n'), "schema JSON must end with newline");
741    }
742
743    #[test]
744    fn same_registry_produces_identical_bytes() {
745        // The determinism contract: identical inputs → identical bytes.
746        // If this ever fails, someone added a clock, hash, or env read
747        // to the serialisation path.
748        let a = Schema::from_admin(&Admin::new().model::<Post>().model::<Book>())
749            .to_pretty_json()
750            .unwrap();
751        let b = Schema::from_admin(&Admin::new().model::<Post>().model::<Book>())
752            .to_pretty_json()
753            .unwrap();
754        assert_eq!(a, b);
755    }
756
757    /// Byte-for-byte snapshot.
758    ///
759    /// Locks the wire format of `rustio.schema.json`. Any diff in field
760    /// ordering, type-name mapping, or surrounding JSON punctuation
761    /// fails this test. If an intentional shape change is landing, bump
762    /// [`SCHEMA_VERSION`] and update both the expected string and every
763    /// consumer in the same PR.
764    #[test]
765    fn schema_snapshot_is_byte_for_byte_stable() {
766        // Register only `Post`; the core `User` is seeded automatically
767        // by `Admin::new()`. The expected JSON below is the *complete*
768        // wire format: locking both the test model and the core User
769        // fields in place.
770        let schema = Schema::from_admin(&Admin::new().model::<Post>());
771        let actual = schema.to_pretty_json().unwrap();
772
773        let expected = format!(
774            r#"{{
775  "version": {sv},
776  "rustio_version": "{rv}",
777  "models": [
778    {{
779      "name": "Post",
780      "table": "posts",
781      "admin_name": "posts",
782      "display_name": "Posts",
783      "singular_name": "Post",
784      "fields": [
785        {{
786          "name": "id",
787          "type": "i64",
788          "nullable": false,
789          "editable": false
790        }},
791        {{
792          "name": "published_at",
793          "type": "DateTime",
794          "nullable": true,
795          "editable": true
796        }},
797        {{
798          "name": "title",
799          "type": "String",
800          "nullable": false,
801          "editable": true
802        }}
803      ],
804      "relations": [],
805      "core": false
806    }},
807    {{
808      "name": "User",
809      "table": "rustio_users",
810      "admin_name": "users",
811      "display_name": "Users",
812      "singular_name": "User",
813      "fields": [
814        {{
815          "name": "created_at",
816          "type": "DateTime",
817          "nullable": false,
818          "editable": false
819        }},
820        {{
821          "name": "email",
822          "type": "String",
823          "nullable": false,
824          "editable": true
825        }},
826        {{
827          "name": "id",
828          "type": "i64",
829          "nullable": false,
830          "editable": false
831        }},
832        {{
833          "name": "is_active",
834          "type": "bool",
835          "nullable": false,
836          "editable": true
837        }},
838        {{
839          "name": "password_hash",
840          "type": "String",
841          "nullable": false,
842          "editable": false
843        }},
844        {{
845          "name": "role",
846          "type": "String",
847          "nullable": false,
848          "editable": true
849        }}
850      ],
851      "relations": [],
852      "core": true
853    }}
854  ]
855}}
856"#,
857            rv = env!("CARGO_PKG_VERSION"),
858            sv = SCHEMA_VERSION,
859        );
860
861        assert_eq!(actual, expected);
862    }
863
864    #[test]
865    fn validate_accepts_clean_schema() {
866        let schema = Schema::from_admin(&Admin::new().model::<Post>().model::<Book>());
867        assert_eq!(schema.validate(), Ok(()));
868    }
869
870    #[test]
871    fn validate_rejects_version_mismatch() {
872        let mut schema = Schema::from_admin(&Admin::new().model::<Post>());
873        schema.version = 999;
874        assert_eq!(
875            schema.validate(),
876            Err(SchemaError::VersionMismatch {
877                found: 999,
878                expected: SCHEMA_VERSION
879            })
880        );
881    }
882
883    #[test]
884    fn validate_rejects_duplicate_models() {
885        let mut schema = Schema::from_admin(&Admin::new().model::<Post>());
886        let post = find(&schema, "Post").clone();
887        schema.models.push(post);
888        assert_eq!(
889            schema.validate(),
890            Err(SchemaError::DuplicateModel("Post".to_string()))
891        );
892    }
893
894    #[test]
895    fn validate_rejects_duplicate_fields() {
896        let mut schema = Schema::from_admin(&Admin::new().model::<Post>());
897        let post_idx = schema.models.iter().position(|m| m.name == "Post").unwrap();
898        let dup = schema.models[post_idx].fields[0].clone();
899        schema.models[post_idx].fields.push(dup);
900        assert_eq!(
901            schema.validate(),
902            Err(SchemaError::DuplicateField {
903                model: "Post".to_string(),
904                field: "id".to_string(),
905            })
906        );
907    }
908
909    #[test]
910    fn validate_rejects_unknown_type() {
911        let mut schema = Schema::from_admin(&Admin::new().model::<Post>());
912        let post_idx = schema.models.iter().position(|m| m.name == "Post").unwrap();
913        schema.models[post_idx].fields[0].ty = "HyperFloat128".to_string();
914        assert_eq!(
915            schema.validate(),
916            Err(SchemaError::InvalidType {
917                model: "Post".to_string(),
918                field: "id".to_string(),
919                ty: "HyperFloat128".to_string(),
920            })
921        );
922    }
923
924    #[test]
925    fn validate_rejects_dangling_relation() {
926        let mut schema = Schema::from_admin(&Admin::new().model::<Post>());
927        let post_idx = schema.models.iter().position(|m| m.name == "Post").unwrap();
928        schema.models[post_idx].relations.push(SchemaRelation {
929            kind: "belongs_to".to_string(),
930            to: "Ghost".to_string(),
931            via: "ghost_id".to_string(),
932        });
933        assert_eq!(
934            schema.validate(),
935            Err(SchemaError::UnknownRelationTarget {
936                from: "Post".to_string(),
937                to: "Ghost".to_string(),
938            })
939        );
940    }
941
942    #[test]
943    fn validate_accepts_self_referencing_relation() {
944        // A model may reference itself — common for tree-shaped data
945        // (parent/child). Reject only *dangling* targets, not recursion.
946        let mut schema = Schema::from_admin(&Admin::new().model::<Post>());
947        let post_idx = schema.models.iter().position(|m| m.name == "Post").unwrap();
948        schema.models[post_idx].relations.push(SchemaRelation {
949            kind: "belongs_to".to_string(),
950            to: "Post".to_string(),
951            via: "parent_id".to_string(),
952        });
953        assert_eq!(schema.validate(), Ok(()));
954    }
955
956    #[test]
957    fn parse_rejects_unknown_top_level_field() {
958        let bad = r#"{
959            "version": 1,
960            "rustio_version": "0.4.0",
961            "models": [],
962            "something_extra": true
963        }"#;
964        let result = Schema::parse(bad);
965        assert!(
966            matches!(result, Err(SchemaError::Parse(_))),
967            "unknown fields must be rejected, got: {:?}",
968            result
969        );
970    }
971
972    #[test]
973    fn parse_rejects_missing_required_field() {
974        // `rustio_version` is required; dropping it must fail.
975        let bad = r#"{
976            "version": 1,
977            "models": []
978        }"#;
979        let result = Schema::parse(bad);
980        assert!(
981            matches!(result, Err(SchemaError::Parse(_))),
982            "missing fields must be rejected"
983        );
984    }
985
986    #[test]
987    fn parse_rejects_version_mismatch() {
988        let bad = r#"{
989            "version": 999,
990            "rustio_version": "0.4.0",
991            "models": []
992        }"#;
993        let err = Schema::parse(bad).unwrap_err();
994        assert!(matches!(err, SchemaError::VersionMismatch { .. }));
995    }
996
997    #[test]
998    fn write_to_is_atomic_no_tmp_left_behind() {
999        let tmp_dir = std::env::temp_dir().join(format!(
1000            "rustio-schema-write-{}-{}",
1001            std::process::id(),
1002            std::time::SystemTime::now()
1003                .duration_since(std::time::UNIX_EPOCH)
1004                .unwrap()
1005                .as_nanos()
1006        ));
1007        std::fs::create_dir_all(&tmp_dir).unwrap();
1008        let target = tmp_dir.join("rustio.schema.json");
1009
1010        let schema = Schema::from_admin(&Admin::new().model::<Post>());
1011        schema.write_to(&target).unwrap();
1012
1013        // File exists and parses back identically.
1014        let bytes = std::fs::read_to_string(&target).unwrap();
1015        let parsed = Schema::parse(&bytes).unwrap();
1016        assert_eq!(parsed, schema);
1017
1018        // No leftover temp file — the `.json.tmp` sibling should not
1019        // exist after a successful rename.
1020        assert!(!tmp_dir.join("rustio.schema.tmp").exists());
1021        assert!(!tmp_dir.join("rustio.schema.json.tmp").exists());
1022
1023        std::fs::remove_dir_all(&tmp_dir).ok();
1024    }
1025}