Skip to main content

rustio_core/
ai.rs

1//! The AI boundary: a fixed vocabulary of primitives that the Phase 2
2//! intelligence layer is allowed to emit.
3//!
4//! Shipped in 0.4.0 as **definitions + validation**. There is no runtime
5//! executor — nothing in this module touches the filesystem or runs
6//! migrations. What it does do:
7//!
8//! 1. Define the complete set of operations the AI layer can propose
9//!    ([`Primitive`]).
10//! 2. Enforce strict serde shape: unknown ops, unknown keys, and missing
11//!    fields all fail to parse (`deny_unknown_fields` everywhere).
12//! 3. Provide structural validation ([`validate_primitive`]) and
13//!    plan-level simulation ([`Plan::validate`]) so a proposed change
14//!    set is checked end-to-end before any hypothetical executor sees
15//!    it.
16//!
17//! **Core rule enforced at the boundary (0.5.0):** if a change cannot be
18//! expressed as one of these primitives, it is **rejected** — no
19//! free-form code generation, no partial writes, no "close enough"
20//! fallback. A project whose shape cannot be described in this vocabulary
21//! is a project the AI layer will refuse to touch.
22
23use std::collections::BTreeSet;
24
25use serde::{Deserialize, Serialize};
26
27use crate::schema::{
28    Schema, SchemaField, SchemaModel, SchemaRelation, SCHEMA_VERSION, VALID_TYPE_NAMES,
29};
30
31pub mod executor;
32pub mod industry;
33pub mod intake;
34pub mod planner;
35pub mod review;
36
37#[cfg(test)]
38mod context_tests;
39#[cfg(test)]
40mod executor_tests;
41#[cfg(test)]
42mod executor_tests_advanced;
43#[cfg(test)]
44mod planner_tests;
45#[cfg(test)]
46mod review_tests;
47
48pub use executor::{
49    execute_plan_document, plan_execution, plan_retrofit_foreign_keys, render_preview_human,
50    ExecuteOptions, ExecutionError, ExecutionPreview, ExecutionResult, FileChangeKind,
51    ParsedModelsFile, PlannedFileChange, ProjectView, RetrofitReport,
52};
53pub use industry::{industry_schema_for, IndustrySchema};
54pub use intake::{sketch, FieldSketch, ModelSketch, ProjectSketch};
55pub use planner::{generate_plan, ContextConfig, PlanError, PlanRequest, PlanResult};
56pub use review::{
57    build_plan_document, build_plan_document_with_timestamp, classify_risk, compute_impact,
58    load_plan, render_plan_document_json, render_review_human, review_plan, warnings_for,
59    LoadedPlan, PlanDocument, PlanImpact, PlanReview, ReviewError, RiskLevel, ValidationOutcome,
60    PLAN_DOCUMENT_VERSION,
61};
62
63/// The complete set of operations the AI layer is allowed to perform on
64/// a RustIO project.
65///
66/// Marked `#[non_exhaustive]` so new primitives can land in a minor
67/// release without breaking external matchers. Consumers must include a
68/// wildcard arm and treat unknown variants as "refuse" rather than
69/// guess.
70#[non_exhaustive]
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
73pub enum Primitive {
74    AddModel(AddModel),
75    RemoveModel(RemoveModel),
76    RenameModel(RenameModel),
77    AddField(AddField),
78    RemoveField(RemoveField),
79    RenameField(RenameField),
80    ChangeFieldType(ChangeFieldType),
81    ChangeFieldNullability(ChangeFieldNullability),
82    AddRelation(AddRelation),
83    RemoveRelation(RemoveRelation),
84    UpdateAdmin(UpdateAdmin),
85    /// Attach a raw SQL migration. **Developer-only** — this primitive
86    /// bypasses the AI boundary's "no free-form code" rule and is
87    /// rejected by [`Plan::validate`]. Project maintainers can still
88    /// emit migrations through this type directly; the AI executor
89    /// must not.
90    CreateMigration(CreateMigration),
91}
92
93impl Primitive {
94    /// `true` if this primitive is permitted only from developer /
95    /// tooling code, not from any AI-emitted [`Plan`].
96    ///
97    /// Today, only [`Primitive::CreateMigration`] qualifies: it
98    /// accepts arbitrary SQL, which violates the AI boundary rule
99    /// that every change must be expressible as a structured
100    /// primitive. [`Plan::validate`] rejects any step for which this
101    /// returns `true`.
102    ///
103    /// Kept as a method (not a `const`) so future variants can opt
104    /// in explicitly.
105    pub fn is_developer_only(&self) -> bool {
106        matches!(self, Primitive::CreateMigration(_))
107    }
108
109    /// Stable short name of this variant, suitable for error
110    /// messages. Matches the serde tag so callers can cross-reference
111    /// the wire format.
112    pub fn op_name(&self) -> &'static str {
113        match self {
114            Primitive::AddModel(_) => "add_model",
115            Primitive::RemoveModel(_) => "remove_model",
116            Primitive::RenameModel(_) => "rename_model",
117            Primitive::AddField(_) => "add_field",
118            Primitive::RemoveField(_) => "remove_field",
119            Primitive::RenameField(_) => "rename_field",
120            Primitive::ChangeFieldType(_) => "change_field_type",
121            Primitive::ChangeFieldNullability(_) => "change_field_nullability",
122            Primitive::AddRelation(_) => "add_relation",
123            Primitive::RemoveRelation(_) => "remove_relation",
124            Primitive::UpdateAdmin(_) => "update_admin",
125            Primitive::CreateMigration(_) => "create_migration",
126        }
127    }
128}
129
130/// A single field on an `add_model` / `add_field` primitive.
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132#[serde(deny_unknown_fields)]
133pub struct FieldSpec {
134    pub name: String,
135    /// Stable type name from `rustio.schema.json` (`i32`, `i64`,
136    /// `String`, `bool`, `DateTime`). Any value not in that set must be
137    /// rejected by the executor.
138    #[serde(rename = "type")]
139    pub ty: String,
140    #[serde(default)]
141    pub nullable: bool,
142    #[serde(default = "default_editable")]
143    pub editable: bool,
144}
145
146fn default_editable() -> bool {
147    true
148}
149
150#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
151#[serde(deny_unknown_fields)]
152pub struct AddModel {
153    /// Struct name in Rust (PascalCase), e.g. `Post`.
154    pub name: String,
155    /// Table name in SQLite (snake_case, pluralised), e.g. `posts`.
156    pub table: String,
157    pub fields: Vec<FieldSpec>,
158}
159
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
161#[serde(deny_unknown_fields)]
162pub struct RemoveModel {
163    pub name: String,
164}
165
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
167#[serde(deny_unknown_fields)]
168pub struct AddField {
169    pub model: String,
170    #[serde(flatten)]
171    pub field: FieldSpec,
172}
173
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175#[serde(deny_unknown_fields)]
176pub struct RemoveField {
177    pub model: String,
178    pub field: String,
179}
180
181/// Rename a model (schema-level). Data-preserving: the AI executor
182/// must translate this into a table rename, not a drop+recreate.
183#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
184#[serde(deny_unknown_fields)]
185pub struct RenameModel {
186    pub from: String,
187    pub to: String,
188}
189
190/// Rename a single field of a model (schema-level). Data-preserving.
191#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
192#[serde(deny_unknown_fields)]
193pub struct RenameField {
194    pub model: String,
195    pub from: String,
196    pub to: String,
197}
198
199/// Change a field's Rust type. The executor is responsible for
200/// translating the change into a migration (and refusing lossy
201/// conversions); this primitive only records the intent at the
202/// schema layer.
203#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
204#[serde(deny_unknown_fields)]
205pub struct ChangeFieldType {
206    pub model: String,
207    pub field: String,
208    /// Target type name from [`VALID_TYPE_NAMES`]. Anything else is
209    /// rejected by [`validate_primitive`].
210    pub new_type: String,
211}
212
213/// Flip a field's nullability (`Option<T>` ↔ `T`).
214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
215#[serde(deny_unknown_fields)]
216pub struct ChangeFieldNullability {
217    pub model: String,
218    pub field: String,
219    pub nullable: bool,
220}
221
222/// The kind of relation an `AddRelation` primitive describes.
223///
224/// 0.8.0: shared with [`crate::schema::RelationKind`] so schema-level
225/// `SchemaField::relation.kind` and planner-emitted
226/// `AddRelation.kind` are exactly the same type. Re-export keeps
227/// existing `use crate::ai::RelationKind;` imports working.
228pub use crate::schema::RelationKind;
229
230#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
231#[serde(deny_unknown_fields)]
232pub struct AddRelation {
233    pub from: String,
234    pub kind: RelationKind,
235    pub to: String,
236    /// Column or accessor name. For `belongs_to`, the FK column
237    /// (e.g. `user_id`). For `has_many`, the reverse accessor name on
238    /// the parent side (e.g. `posts`).
239    pub via: String,
240    /// `true` → `NOT NULL` FK column, requires a parent row to exist
241    /// before the child row can be inserted. `false` (default) → nullable,
242    /// safe to add to a table with existing rows.
243    ///
244    /// 0.9.0 default is `false` because upgrading a 0.8.0 project must
245    /// not break on pre-existing rows that pointed at id `0`.
246    #[serde(default)]
247    pub required: bool,
248    /// The SQL `ON DELETE` action when the referenced parent row is
249    /// removed. Default is `Restrict`: the parent row cannot be deleted
250    /// while any child references it. `Cascade` deletes the children.
251    /// `SetNull` nulls the FK on children (only valid when the column
252    /// is nullable — the executor will reject a `SetNull` + `required`
253    /// combination).
254    #[serde(default)]
255    pub on_delete: OnDelete,
256}
257
258/// Referential-integrity action triggered when a referenced row is
259/// deleted. 0.9.0 introduces this on [`AddRelation`].
260#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
261#[serde(rename_all = "snake_case")]
262pub enum OnDelete {
263    /// Default. Refuse to delete a parent row while children exist.
264    #[default]
265    Restrict,
266    /// Delete every child row whose FK points at the parent being
267    /// deleted. Dangerous — requires explicit opt-in.
268    Cascade,
269    /// Null out the FK on every child row. Valid only when the FK
270    /// column is nullable.
271    SetNull,
272}
273
274impl OnDelete {
275    /// SQLite clause fragment, e.g. `ON DELETE RESTRICT`. Case-folded
276    /// upper for readability in generated migrations.
277    pub fn sql(self) -> &'static str {
278        match self {
279            OnDelete::Restrict => "ON DELETE RESTRICT",
280            OnDelete::Cascade => "ON DELETE CASCADE",
281            OnDelete::SetNull => "ON DELETE SET NULL",
282        }
283    }
284
285    /// `snake_case` serialised form, mirrors the serde rename.
286    pub fn as_str(self) -> &'static str {
287        match self {
288            OnDelete::Restrict => "restrict",
289            OnDelete::Cascade => "cascade",
290            OnDelete::SetNull => "set_null",
291        }
292    }
293}
294
295#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
296#[serde(deny_unknown_fields)]
297pub struct RemoveRelation {
298    pub from: String,
299    pub via: String,
300}
301
302/// Mutate one admin-facing attribute of a field without changing its
303/// type — for example flipping `searchable` on or off.
304///
305/// The attribute vocabulary is intentionally narrow; fields outside it
306/// must be rejected at the 0.5.0 executor rather than silently ignored.
307#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
308#[serde(deny_unknown_fields)]
309pub struct UpdateAdmin {
310    pub model: String,
311    pub field: String,
312    pub attr: String,
313    pub value: serde_json::Value,
314}
315
316/// Attach a raw SQL migration alongside a schema-level change.
317///
318/// The 0.5.0 executor will require every primitive that alters persisted
319/// shape (`add_model`, `add_field`, `add_relation`) to be accompanied by
320/// a `CreateMigration` whose SQL matches the change. Primitives that
321/// only touch admin metadata do not need one.
322#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
323#[serde(deny_unknown_fields)]
324pub struct CreateMigration {
325    pub name: String,
326    pub sql: String,
327}
328
329/// Reasons a primitive (or a plan composed of primitives) can be
330/// rejected. The AI boundary converts these into a blunt refusal — the
331/// executor never silently "fixes" a primitive or applies a partial plan.
332#[non_exhaustive]
333#[derive(Debug, Clone, PartialEq)]
334pub enum PrimitiveError {
335    /// A required identifier is empty (`name`, `model`, `field`, …).
336    EmptyIdentifier(&'static str),
337    /// A field's declared type isn't in [`VALID_TYPE_NAMES`].
338    UnknownType {
339        model: String,
340        field: String,
341        ty: String,
342    },
343    /// Two fields with the same name inside an `add_model` payload.
344    DuplicateFieldInAddModel { model: String, field: String },
345    /// Target of an `add_*` already exists in the schema.
346    AlreadyExists { what: &'static str, name: String },
347    /// Target of a `remove_*` / `update_admin` doesn't exist.
348    NotFound { what: &'static str, name: String },
349    /// Relation target model doesn't exist in the (shadow-applied) schema.
350    UnknownRelationTarget { from: String, to: String },
351    /// `UpdateAdmin` referenced an attribute outside the accepted vocabulary.
352    UnknownAdminAttribute { attr: String },
353    /// A rename primitive was given identical `from` and `to`.
354    /// Rejecting no-ops early keeps plans honest and diff-reviewable.
355    NoOpRename { what: &'static str, name: String },
356    /// A developer-only primitive appeared inside a [`Plan`]. Plans
357    /// represent the AI boundary; anything with
358    /// [`Primitive::is_developer_only`] set must be rejected before
359    /// an executor touches it.
360    DeveloperOnlyNotAllowedInPlan { op: &'static str },
361    /// `validate_plan` annotates inner errors with the step index so a
362    /// caller can point the user at "step 3 failed because …".
363    InStep {
364        step: usize,
365        inner: Box<PrimitiveError>,
366    },
367}
368
369impl std::fmt::Display for PrimitiveError {
370    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
371        match self {
372            Self::EmptyIdentifier(which) => write!(f, "empty {which}"),
373            Self::UnknownType { model, field, ty } => write!(
374                f,
375                "field `{model}.{field}` has unknown type `{ty}` (valid: {valid})",
376                valid = VALID_TYPE_NAMES.join(", "),
377            ),
378            Self::DuplicateFieldInAddModel { model, field } => write!(
379                f,
380                "add_model `{model}` lists field `{field}` more than once",
381            ),
382            Self::AlreadyExists { what, name } => write!(f, "{what} `{name}` already exists"),
383            Self::NotFound { what, name } => write!(f, "{what} `{name}` does not exist"),
384            Self::UnknownRelationTarget { from, to } => {
385                write!(f, "relation from `{from}` targets unknown model `{to}`")
386            }
387            Self::UnknownAdminAttribute { attr } => {
388                write!(f, "unknown admin attribute `{attr}`")
389            }
390            Self::NoOpRename { what, name } => {
391                write!(f, "rename of {what} `{name}` is a no-op (from == to)")
392            }
393            Self::DeveloperOnlyNotAllowedInPlan { op } => write!(
394                f,
395                "`{op}` is developer-only and cannot appear in an AI plan"
396            ),
397            Self::InStep { step, inner } => write!(f, "step {step}: {inner}"),
398        }
399    }
400}
401
402impl std::error::Error for PrimitiveError {}
403
404/// Admin attributes that `UpdateAdmin` is allowed to touch in 0.4.0.
405/// Anything outside this set is rejected; extending requires a CHANGELOG
406/// entry and a matching executor.
407const ALLOWED_ADMIN_ATTRS: &[&str] = &["searchable", "editable", "nullable"];
408
409/// Structural check: validates one primitive in isolation, without
410/// comparing against a surrounding schema. Catches empty names, bad
411/// types, and internally inconsistent payloads.
412pub fn validate_primitive(p: &Primitive) -> Result<(), PrimitiveError> {
413    match p {
414        Primitive::AddModel(m) => {
415            require_nonempty(&m.name, "model name")?;
416            require_nonempty(&m.table, "table name")?;
417            let mut seen: BTreeSet<&str> = BTreeSet::new();
418            for field in &m.fields {
419                validate_field_spec(&m.name, field)?;
420                if !seen.insert(field.name.as_str()) {
421                    return Err(PrimitiveError::DuplicateFieldInAddModel {
422                        model: m.name.clone(),
423                        field: field.name.clone(),
424                    });
425                }
426            }
427            Ok(())
428        }
429        Primitive::RemoveModel(m) => {
430            require_nonempty(&m.name, "model name")?;
431            Ok(())
432        }
433        Primitive::AddField(af) => {
434            require_nonempty(&af.model, "model name")?;
435            validate_field_spec(&af.model, &af.field)
436        }
437        Primitive::RemoveField(rf) => {
438            require_nonempty(&rf.model, "model name")?;
439            require_nonempty(&rf.field, "field name")?;
440            Ok(())
441        }
442        Primitive::RenameModel(rm) => {
443            require_nonempty(&rm.from, "from")?;
444            require_nonempty(&rm.to, "to")?;
445            if rm.from == rm.to {
446                return Err(PrimitiveError::NoOpRename {
447                    what: "model",
448                    name: rm.from.clone(),
449                });
450            }
451            Ok(())
452        }
453        Primitive::RenameField(rf) => {
454            require_nonempty(&rf.model, "model name")?;
455            require_nonempty(&rf.from, "from")?;
456            require_nonempty(&rf.to, "to")?;
457            if rf.from == rf.to {
458                return Err(PrimitiveError::NoOpRename {
459                    what: "field",
460                    name: format!("{}.{}", rf.model, rf.from),
461                });
462            }
463            Ok(())
464        }
465        Primitive::ChangeFieldType(c) => {
466            require_nonempty(&c.model, "model name")?;
467            require_nonempty(&c.field, "field name")?;
468            if !VALID_TYPE_NAMES.contains(&c.new_type.as_str()) {
469                return Err(PrimitiveError::UnknownType {
470                    model: c.model.clone(),
471                    field: c.field.clone(),
472                    ty: c.new_type.clone(),
473                });
474            }
475            Ok(())
476        }
477        Primitive::ChangeFieldNullability(c) => {
478            require_nonempty(&c.model, "model name")?;
479            require_nonempty(&c.field, "field name")?;
480            Ok(())
481        }
482        Primitive::AddRelation(r) => {
483            require_nonempty(&r.from, "from")?;
484            require_nonempty(&r.to, "to")?;
485            require_nonempty(&r.via, "via")?;
486            // RelationKind is a typed enum; no further check needed.
487            Ok(())
488        }
489        Primitive::RemoveRelation(r) => {
490            require_nonempty(&r.from, "from")?;
491            require_nonempty(&r.via, "via")?;
492            Ok(())
493        }
494        Primitive::UpdateAdmin(u) => {
495            require_nonempty(&u.model, "model name")?;
496            require_nonempty(&u.field, "field name")?;
497            require_nonempty(&u.attr, "attr")?;
498            if !ALLOWED_ADMIN_ATTRS.contains(&u.attr.as_str()) {
499                return Err(PrimitiveError::UnknownAdminAttribute {
500                    attr: u.attr.clone(),
501                });
502            }
503            Ok(())
504        }
505        Primitive::CreateMigration(m) => {
506            require_nonempty(&m.name, "migration name")?;
507            require_nonempty(&m.sql, "migration sql")?;
508            Ok(())
509        }
510    }
511}
512
513fn require_nonempty(s: &str, which: &'static str) -> Result<(), PrimitiveError> {
514    if s.trim().is_empty() {
515        Err(PrimitiveError::EmptyIdentifier(which))
516    } else {
517        Ok(())
518    }
519}
520
521fn validate_field_spec(model: &str, f: &FieldSpec) -> Result<(), PrimitiveError> {
522    require_nonempty(&f.name, "field name")?;
523    if !VALID_TYPE_NAMES.contains(&f.ty.as_str()) {
524        return Err(PrimitiveError::UnknownType {
525            model: model.to_string(),
526            field: f.name.clone(),
527            ty: f.ty.clone(),
528        });
529    }
530    Ok(())
531}
532
533/// A proposed set of primitives to apply in order.
534///
535/// The plan is the *unit of validation* for the AI boundary. Individual
536/// primitives can look sensible in isolation but fail as a sequence
537/// (e.g. `add_field` twice, or `remove_model` followed by `add_field`
538/// against the now-gone model). [`Plan::validate`] simulates the full
539/// sequence against a shadow copy of the target schema and fails fast.
540///
541/// The struct is intentionally tiny. 0.4.0 does not execute plans; it
542/// just defines the contract every 0.5.0 executor is built to.
543#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
544#[serde(deny_unknown_fields)]
545pub struct Plan {
546    pub steps: Vec<Primitive>,
547}
548
549impl Plan {
550    pub fn new(steps: Vec<Primitive>) -> Self {
551        Self { steps }
552    }
553
554    pub fn is_empty(&self) -> bool {
555        self.steps.is_empty()
556    }
557
558    pub fn len(&self) -> usize {
559        self.steps.len()
560    }
561
562    /// Validate the entire plan against an initial schema state. Every
563    /// step is first checked structurally, then checked against the
564    /// shadow-applied schema, then applied to the shadow before the
565    /// next step is considered.
566    ///
567    /// The shadow is pure in-memory data — no filesystem, no DB. This
568    /// stays consistent with the 0.4.0 boundary rule: **no execution**.
569    ///
570    /// Additionally, any step whose primitive returns `true` from
571    /// [`Primitive::is_developer_only`] is rejected up front. Plans
572    /// represent the AI boundary; developer-only primitives
573    /// (currently `CreateMigration`) are reserved for direct tooling
574    /// use and must never appear in an AI-emitted plan.
575    pub fn validate(&self, initial: &Schema) -> Result<(), PrimitiveError> {
576        let mut state = initial.clone();
577        for (idx, step) in self.steps.iter().enumerate() {
578            if step.is_developer_only() {
579                return Err(PrimitiveError::InStep {
580                    step: idx,
581                    inner: Box::new(PrimitiveError::DeveloperOnlyNotAllowedInPlan {
582                        op: step.op_name(),
583                    }),
584                });
585            }
586            if let Err(inner) = validate_primitive(step) {
587                return Err(PrimitiveError::InStep {
588                    step: idx,
589                    inner: Box::new(inner),
590                });
591            }
592            if let Err(inner) = validate_against(step, &state) {
593                return Err(PrimitiveError::InStep {
594                    step: idx,
595                    inner: Box::new(inner),
596                });
597            }
598            apply_shadow(step, &mut state);
599        }
600        Ok(())
601    }
602}
603
604/// Semantic check: a primitive is valid *against a given schema*.
605/// Used both standalone and as the per-step check inside
606/// [`Plan::validate`].
607pub fn validate_against(p: &Primitive, schema: &Schema) -> Result<(), PrimitiveError> {
608    match p {
609        Primitive::AddModel(m) => {
610            if schema.models.iter().any(|x| x.name == m.name) {
611                return Err(PrimitiveError::AlreadyExists {
612                    what: "model",
613                    name: m.name.clone(),
614                });
615            }
616            Ok(())
617        }
618        Primitive::RemoveModel(m) => {
619            if !schema.models.iter().any(|x| x.name == m.name) {
620                return Err(PrimitiveError::NotFound {
621                    what: "model",
622                    name: m.name.clone(),
623                });
624            }
625            Ok(())
626        }
627        Primitive::AddField(af) => {
628            let model = find_model(schema, &af.model)?;
629            if model.fields.iter().any(|f| f.name == af.field.name) {
630                return Err(PrimitiveError::AlreadyExists {
631                    what: "field",
632                    name: format!("{}.{}", af.model, af.field.name),
633                });
634            }
635            Ok(())
636        }
637        Primitive::RemoveField(rf) => {
638            let model = find_model(schema, &rf.model)?;
639            if !model.fields.iter().any(|f| f.name == rf.field) {
640                return Err(PrimitiveError::NotFound {
641                    what: "field",
642                    name: format!("{}.{}", rf.model, rf.field),
643                });
644            }
645            Ok(())
646        }
647        Primitive::RenameModel(rm) => {
648            let _ = find_model(schema, &rm.from)?;
649            if schema.models.iter().any(|m| m.name == rm.to) {
650                return Err(PrimitiveError::AlreadyExists {
651                    what: "model",
652                    name: rm.to.clone(),
653                });
654            }
655            Ok(())
656        }
657        Primitive::RenameField(rf) => {
658            let model = find_model(schema, &rf.model)?;
659            if !model.fields.iter().any(|f| f.name == rf.from) {
660                return Err(PrimitiveError::NotFound {
661                    what: "field",
662                    name: format!("{}.{}", rf.model, rf.from),
663                });
664            }
665            if model.fields.iter().any(|f| f.name == rf.to) {
666                return Err(PrimitiveError::AlreadyExists {
667                    what: "field",
668                    name: format!("{}.{}", rf.model, rf.to),
669                });
670            }
671            Ok(())
672        }
673        Primitive::ChangeFieldType(c) => {
674            let model = find_model(schema, &c.model)?;
675            if !model.fields.iter().any(|f| f.name == c.field) {
676                return Err(PrimitiveError::NotFound {
677                    what: "field",
678                    name: format!("{}.{}", c.model, c.field),
679                });
680            }
681            Ok(())
682        }
683        Primitive::ChangeFieldNullability(c) => {
684            let model = find_model(schema, &c.model)?;
685            if !model.fields.iter().any(|f| f.name == c.field) {
686                return Err(PrimitiveError::NotFound {
687                    what: "field",
688                    name: format!("{}.{}", c.model, c.field),
689                });
690            }
691            Ok(())
692        }
693        Primitive::AddRelation(r) => {
694            let from = find_model(schema, &r.from)?;
695            if !schema.models.iter().any(|m| m.name == r.to) {
696                return Err(PrimitiveError::UnknownRelationTarget {
697                    from: r.from.clone(),
698                    to: r.to.clone(),
699                });
700            }
701            if from.relations.iter().any(|rel| rel.via == r.via) {
702                return Err(PrimitiveError::AlreadyExists {
703                    what: "relation",
704                    name: format!("{}.{}", r.from, r.via),
705                });
706            }
707            Ok(())
708        }
709        Primitive::RemoveRelation(r) => {
710            let from = find_model(schema, &r.from)?;
711            if !from.relations.iter().any(|rel| rel.via == r.via) {
712                return Err(PrimitiveError::NotFound {
713                    what: "relation",
714                    name: format!("{}.{}", r.from, r.via),
715                });
716            }
717            Ok(())
718        }
719        Primitive::UpdateAdmin(u) => {
720            let model = find_model(schema, &u.model)?;
721            if !model.fields.iter().any(|f| f.name == u.field) {
722                return Err(PrimitiveError::NotFound {
723                    what: "field",
724                    name: format!("{}.{}", u.model, u.field),
725                });
726            }
727            Ok(())
728        }
729        // A raw migration doesn't need a schema target; the structural
730        // check already ensures name + sql are non-empty.
731        Primitive::CreateMigration(_) => Ok(()),
732    }
733}
734
735fn find_model<'a>(schema: &'a Schema, name: &str) -> Result<&'a SchemaModel, PrimitiveError> {
736    schema
737        .models
738        .iter()
739        .find(|m| m.name == name)
740        .ok_or_else(|| PrimitiveError::NotFound {
741            what: "model",
742            name: name.to_string(),
743        })
744}
745
746/// Apply a primitive to an in-memory schema *copy*. Used for plan
747/// simulation only — never touches the filesystem or DB, by design.
748///
749/// Callers must invoke [`validate_against`] first; `apply_shadow` assumes
750/// the step is legal and panics on contradiction rather than silently
751/// diverging.
752fn apply_shadow(p: &Primitive, schema: &mut Schema) {
753    match p {
754        Primitive::AddModel(m) => {
755            let mut fields: Vec<SchemaField> = m
756                .fields
757                .iter()
758                .map(|f| SchemaField {
759                    name: f.name.clone(),
760                    ty: f.ty.clone(),
761                    nullable: f.nullable,
762                    editable: f.editable,
763                    relation: None,
764                })
765                .collect();
766            fields.sort_by(|a, b| a.name.cmp(&b.name));
767            schema.models.push(SchemaModel {
768                name: m.name.clone(),
769                table: m.table.clone(),
770                admin_name: m.table.clone(),
771                display_name: m.name.clone(),
772                singular_name: m.name.clone(),
773                fields,
774                relations: Vec::new(),
775                // New models added via AI primitives are never core —
776                // core-ness is a property of built-in infrastructure
777                // (the `User` entry seeded by `Admin::new()`), not
778                // something the AI layer can mint.
779                core: false,
780            });
781            schema.models.sort_by(|a, b| a.name.cmp(&b.name));
782        }
783        Primitive::RemoveModel(m) => {
784            schema.models.retain(|x| x.name != m.name);
785        }
786        Primitive::AddField(af) => {
787            if let Some(model) = schema.models.iter_mut().find(|m| m.name == af.model) {
788                model.fields.push(SchemaField {
789                    name: af.field.name.clone(),
790                    ty: af.field.ty.clone(),
791                    nullable: af.field.nullable,
792                    editable: af.field.editable,
793                    relation: None,
794                });
795                model.fields.sort_by(|a, b| a.name.cmp(&b.name));
796            }
797        }
798        Primitive::RemoveField(rf) => {
799            if let Some(model) = schema.models.iter_mut().find(|m| m.name == rf.model) {
800                model.fields.retain(|f| f.name != rf.field);
801            }
802        }
803        Primitive::RenameModel(rm) => {
804            if let Some(model) = schema.models.iter_mut().find(|m| m.name == rm.from) {
805                model.name = rm.to.clone();
806                model.singular_name = rm.to.clone();
807            }
808            schema.models.sort_by(|a, b| a.name.cmp(&b.name));
809        }
810        Primitive::RenameField(rf) => {
811            if let Some(model) = schema.models.iter_mut().find(|m| m.name == rf.model) {
812                if let Some(field) = model.fields.iter_mut().find(|f| f.name == rf.from) {
813                    field.name = rf.to.clone();
814                }
815                model.fields.sort_by(|a, b| a.name.cmp(&b.name));
816            }
817        }
818        Primitive::ChangeFieldType(c) => {
819            if let Some(model) = schema.models.iter_mut().find(|m| m.name == c.model) {
820                if let Some(field) = model.fields.iter_mut().find(|f| f.name == c.field) {
821                    field.ty = c.new_type.clone();
822                }
823            }
824        }
825        Primitive::ChangeFieldNullability(c) => {
826            if let Some(model) = schema.models.iter_mut().find(|m| m.name == c.model) {
827                if let Some(field) = model.fields.iter_mut().find(|f| f.name == c.field) {
828                    field.nullable = c.nullable;
829                }
830            }
831        }
832        Primitive::AddRelation(r) => {
833            if let Some(model) = schema.models.iter_mut().find(|m| m.name == r.from) {
834                model.relations.push(SchemaRelation {
835                    kind: r.kind.as_str().to_string(),
836                    to: r.to.clone(),
837                    via: r.via.clone(),
838                });
839            }
840        }
841        Primitive::RemoveRelation(r) => {
842            if let Some(model) = schema.models.iter_mut().find(|m| m.name == r.from) {
843                model.relations.retain(|rel| rel.via != r.via);
844            }
845        }
846        // UpdateAdmin and CreateMigration don't alter the structural
847        // shape reflected in `rustio.schema.json`; the executor will
848        // rewrite files, not mutate the schema snapshot.
849        Primitive::UpdateAdmin(_) | Primitive::CreateMigration(_) => {}
850    }
851}
852
853/// Sanity hook for callers that want to assert they're looking at a
854/// schema the current rustio-core understands before doing anything
855/// else. Exported for executor code; this module uses it in tests.
856pub fn assert_schema_version_supported(schema: &Schema) -> Result<(), PrimitiveError> {
857    if schema.version != SCHEMA_VERSION {
858        return Err(PrimitiveError::NotFound {
859            what: "schema version",
860            name: schema.version.to_string(),
861        });
862    }
863    Ok(())
864}
865
866#[cfg(test)]
867mod tests {
868    use super::*;
869    use crate::admin::{Admin, AdminField, AdminModel, FieldType, FormData};
870    use crate::error::Error as CoreError;
871    use crate::orm::{Model, Row, Value};
872
873    // Reuse a simple Post model for the "schema exists" fixtures.
874    struct Post;
875    impl Model for Post {
876        const TABLE: &'static str = "posts";
877        const COLUMNS: &'static [&'static str] = &["id", "title"];
878        const INSERT_COLUMNS: &'static [&'static str] = &["title"];
879        fn id(&self) -> i64 {
880            0
881        }
882        fn from_row(_: Row<'_>) -> Result<Self, CoreError> {
883            unimplemented!()
884        }
885        fn insert_values(&self) -> Vec<Value> {
886            Vec::new()
887        }
888    }
889    impl AdminModel for Post {
890        const ADMIN_NAME: &'static str = "posts";
891        const DISPLAY_NAME: &'static str = "Posts";
892        const FIELDS: &'static [AdminField] = &[
893            AdminField {
894                name: "id",
895                ty: FieldType::I64,
896                editable: false,
897                nullable: false,
898                relation: None,
899            },
900            AdminField {
901                name: "title",
902                ty: FieldType::String,
903                editable: true,
904                nullable: false,
905                relation: None,
906            },
907        ];
908        fn singular_name() -> &'static str {
909            "Post"
910        }
911        fn field_display(&self, _: &str) -> Option<String> {
912            None
913        }
914        fn from_form(_: &FormData, _: Option<i64>) -> Result<Self, CoreError> {
915            unimplemented!()
916        }
917    }
918
919    fn schema() -> Schema {
920        Schema::from_admin(&Admin::new().model::<Post>())
921    }
922
923    // ---- structural validation --------------------------------------------
924
925    #[test]
926    fn add_field_round_trips_through_json() {
927        let p = Primitive::AddField(AddField {
928            model: "Post".to_string(),
929            field: FieldSpec {
930                name: "published".to_string(),
931                ty: "bool".to_string(),
932                nullable: false,
933                editable: true,
934            },
935        });
936        let json = serde_json::to_string(&p).unwrap();
937        assert!(json.contains(r#""op":"add_field""#));
938        assert!(json.contains(r#""name":"published""#));
939
940        let back: Primitive = serde_json::from_str(&json).unwrap();
941        match back {
942            Primitive::AddField(af) => {
943                assert_eq!(af.model, "Post");
944                assert_eq!(af.field.name, "published");
945                assert_eq!(af.field.ty, "bool");
946            }
947            _ => panic!("expected AddField"),
948        }
949    }
950
951    #[test]
952    fn unknown_op_is_rejected_not_swallowed() {
953        let bad = r#"{"op":"rewrite_universe","world":"goodbye"}"#;
954        let parsed: Result<Primitive, _> = serde_json::from_str(bad);
955        assert!(parsed.is_err(), "unknown op must not parse");
956    }
957
958    #[test]
959    fn unknown_field_on_known_op_is_rejected() {
960        // `add_model` payload with a typo'd field must fail rather than
961        // being silently dropped.
962        let bad = r#"{"op":"add_model","name":"X","table":"xs","fields":[],"extra":true}"#;
963        let parsed: Result<Primitive, _> = serde_json::from_str(bad);
964        assert!(
965            parsed.is_err(),
966            "unknown keys on known ops must be rejected"
967        );
968    }
969
970    #[test]
971    fn missing_required_field_is_rejected() {
972        // `add_model` requires `table`; dropping it must fail.
973        let bad = r#"{"op":"add_model","name":"X","fields":[]}"#;
974        let parsed: Result<Primitive, _> = serde_json::from_str(bad);
975        assert!(parsed.is_err(), "missing required fields must be rejected");
976    }
977
978    #[test]
979    fn add_relation_with_belongs_to_serialises_snake_case() {
980        let p = Primitive::AddRelation(AddRelation {
981            from: "Post".to_string(),
982            kind: RelationKind::BelongsTo,
983            to: "User".to_string(),
984            via: "user_id".to_string(),
985            required: false,
986            on_delete: OnDelete::Restrict,
987        });
988        let json = serde_json::to_string(&p).unwrap();
989        assert!(json.contains(r#""kind":"belongs_to""#));
990    }
991
992    #[test]
993    fn validate_primitive_rejects_unknown_type() {
994        let p = Primitive::AddField(AddField {
995            model: "Post".to_string(),
996            field: FieldSpec {
997                name: "flux".to_string(),
998                ty: "HyperFloat128".to_string(),
999                nullable: false,
1000                editable: true,
1001            },
1002        });
1003        assert!(matches!(
1004            validate_primitive(&p),
1005            Err(PrimitiveError::UnknownType { .. })
1006        ));
1007    }
1008
1009    #[test]
1010    fn validate_primitive_rejects_empty_names() {
1011        let p = Primitive::AddField(AddField {
1012            model: "".to_string(),
1013            field: FieldSpec {
1014                name: "x".to_string(),
1015                ty: "i64".to_string(),
1016                nullable: false,
1017                editable: true,
1018            },
1019        });
1020        assert_eq!(
1021            validate_primitive(&p),
1022            Err(PrimitiveError::EmptyIdentifier("model name"))
1023        );
1024    }
1025
1026    #[test]
1027    fn validate_primitive_rejects_duplicate_fields_in_add_model() {
1028        let p = Primitive::AddModel(AddModel {
1029            name: "Book".to_string(),
1030            table: "books".to_string(),
1031            fields: vec![
1032                FieldSpec {
1033                    name: "title".to_string(),
1034                    ty: "String".to_string(),
1035                    nullable: false,
1036                    editable: true,
1037                },
1038                FieldSpec {
1039                    name: "title".to_string(),
1040                    ty: "String".to_string(),
1041                    nullable: false,
1042                    editable: true,
1043                },
1044            ],
1045        });
1046        assert!(matches!(
1047            validate_primitive(&p),
1048            Err(PrimitiveError::DuplicateFieldInAddModel { .. })
1049        ));
1050    }
1051
1052    #[test]
1053    fn update_admin_rejects_unknown_attribute() {
1054        let p = Primitive::UpdateAdmin(UpdateAdmin {
1055            model: "Post".to_string(),
1056            field: "title".to_string(),
1057            attr: "telepathy".to_string(),
1058            value: serde_json::Value::Bool(true),
1059        });
1060        assert!(matches!(
1061            validate_primitive(&p),
1062            Err(PrimitiveError::UnknownAdminAttribute { .. })
1063        ));
1064    }
1065
1066    // ---- semantic validation ----------------------------------------------
1067
1068    #[test]
1069    fn validate_against_rejects_remove_of_nonexistent_model() {
1070        let p = Primitive::RemoveModel(RemoveModel {
1071            name: "Ghost".to_string(),
1072        });
1073        let err = validate_against(&p, &schema()).unwrap_err();
1074        assert!(matches!(
1075            err,
1076            PrimitiveError::NotFound { what: "model", .. }
1077        ));
1078    }
1079
1080    #[test]
1081    fn validate_against_rejects_add_field_to_missing_model() {
1082        let p = Primitive::AddField(AddField {
1083            model: "Ghost".to_string(),
1084            field: FieldSpec {
1085                name: "age".to_string(),
1086                ty: "i32".to_string(),
1087                nullable: false,
1088                editable: true,
1089            },
1090        });
1091        let err = validate_against(&p, &schema()).unwrap_err();
1092        assert!(matches!(
1093            err,
1094            PrimitiveError::NotFound { what: "model", .. }
1095        ));
1096    }
1097
1098    #[test]
1099    fn validate_against_rejects_duplicate_field_add() {
1100        let p = Primitive::AddField(AddField {
1101            model: "Post".to_string(),
1102            field: FieldSpec {
1103                name: "title".to_string(),
1104                ty: "String".to_string(),
1105                nullable: false,
1106                editable: true,
1107            },
1108        });
1109        let err = validate_against(&p, &schema()).unwrap_err();
1110        assert!(matches!(
1111            err,
1112            PrimitiveError::AlreadyExists { what: "field", .. }
1113        ));
1114    }
1115
1116    #[test]
1117    fn validate_against_rejects_relation_to_missing_model() {
1118        let p = Primitive::AddRelation(AddRelation {
1119            from: "Post".to_string(),
1120            kind: RelationKind::BelongsTo,
1121            to: "Ghost".to_string(),
1122            via: "ghost_id".to_string(),
1123            required: false,
1124            on_delete: OnDelete::Restrict,
1125        });
1126        let err = validate_against(&p, &schema()).unwrap_err();
1127        assert!(matches!(err, PrimitiveError::UnknownRelationTarget { .. }));
1128    }
1129
1130    // ---- plan-level simulation --------------------------------------------
1131
1132    #[test]
1133    fn plan_validates_sequential_additions() {
1134        let plan = Plan::new(vec![
1135            Primitive::AddModel(AddModel {
1136                name: "Book".to_string(),
1137                table: "books".to_string(),
1138                fields: vec![FieldSpec {
1139                    name: "title".to_string(),
1140                    ty: "String".to_string(),
1141                    nullable: false,
1142                    editable: true,
1143                }],
1144            }),
1145            // Plan-aware: this add_field is against the model the
1146            // previous step *just added* — the simulator must see it.
1147            Primitive::AddField(AddField {
1148                model: "Book".to_string(),
1149                field: FieldSpec {
1150                    name: "published".to_string(),
1151                    ty: "bool".to_string(),
1152                    nullable: false,
1153                    editable: true,
1154                },
1155            }),
1156        ]);
1157        assert_eq!(plan.validate(&schema()), Ok(()));
1158    }
1159
1160    #[test]
1161    fn plan_rejects_second_add_of_same_model() {
1162        let add_book = Primitive::AddModel(AddModel {
1163            name: "Book".to_string(),
1164            table: "books".to_string(),
1165            fields: Vec::new(),
1166        });
1167        let plan = Plan::new(vec![add_book.clone(), add_book]);
1168        let err = plan.validate(&schema()).unwrap_err();
1169        assert!(
1170            matches!(
1171                &err,
1172                PrimitiveError::InStep { step: 1, inner } if matches!(**inner, PrimitiveError::AlreadyExists { what: "model", .. })
1173            ),
1174            "got: {err:?}"
1175        );
1176    }
1177
1178    #[test]
1179    fn plan_rejects_field_add_after_model_removed() {
1180        let plan = Plan::new(vec![
1181            Primitive::RemoveModel(RemoveModel {
1182                name: "Post".to_string(),
1183            }),
1184            Primitive::AddField(AddField {
1185                model: "Post".to_string(),
1186                field: FieldSpec {
1187                    name: "subtitle".to_string(),
1188                    ty: "String".to_string(),
1189                    nullable: true,
1190                    editable: true,
1191                },
1192            }),
1193        ]);
1194        let err = plan.validate(&schema()).unwrap_err();
1195        assert!(
1196            matches!(
1197                err,
1198                PrimitiveError::InStep { step: 1, inner } if matches!(*inner, PrimitiveError::NotFound { what: "model", .. })
1199            ),
1200            "plan must fail on the second step, not the first"
1201        );
1202    }
1203
1204    #[test]
1205    fn empty_plan_is_always_valid() {
1206        assert_eq!(Plan::new(Vec::new()).validate(&schema()), Ok(()));
1207    }
1208
1209    #[test]
1210    fn create_migration_is_developer_only() {
1211        let m = Primitive::CreateMigration(CreateMigration {
1212            name: "add_books".to_string(),
1213            sql: "CREATE TABLE books (id INTEGER);".to_string(),
1214        });
1215        assert!(m.is_developer_only());
1216        assert!(!Primitive::RemoveModel(RemoveModel {
1217            name: "X".to_string()
1218        })
1219        .is_developer_only());
1220    }
1221
1222    #[test]
1223    fn validate_primitive_still_accepts_create_migration_for_direct_use() {
1224        // The developer-only gate lives on `Plan::validate`, not on
1225        // `validate_primitive`. Tooling code calling the latter
1226        // directly must still accept CreateMigration.
1227        let m = Primitive::CreateMigration(CreateMigration {
1228            name: "add_books".to_string(),
1229            sql: "CREATE TABLE books (id INTEGER);".to_string(),
1230        });
1231        assert_eq!(validate_primitive(&m), Ok(()));
1232    }
1233
1234    #[test]
1235    fn plan_rejects_create_migration_even_when_structurally_valid() {
1236        let plan = Plan::new(vec![Primitive::CreateMigration(CreateMigration {
1237            name: "add_books".to_string(),
1238            sql: "CREATE TABLE books (id INTEGER);".to_string(),
1239        })]);
1240        let err = plan.validate(&schema()).unwrap_err();
1241        assert!(
1242            matches!(
1243                &err,
1244                PrimitiveError::InStep { step: 0, inner }
1245                    if matches!(
1246                        **inner,
1247                        PrimitiveError::DeveloperOnlyNotAllowedInPlan { op: "create_migration" },
1248                    )
1249            ),
1250            "got: {err:?}"
1251        );
1252    }
1253
1254    #[test]
1255    fn plan_rejects_create_migration_at_the_offending_step() {
1256        let plan = Plan::new(vec![
1257            Primitive::RemoveModel(RemoveModel {
1258                name: "Post".to_string(),
1259            }),
1260            Primitive::CreateMigration(CreateMigration {
1261                name: "tidy".to_string(),
1262                sql: "DROP TABLE posts;".to_string(),
1263            }),
1264        ]);
1265        let err = plan.validate(&schema()).unwrap_err();
1266        assert!(
1267            matches!(
1268                err,
1269                PrimitiveError::InStep { step: 1, inner }
1270                    if matches!(*inner, PrimitiveError::DeveloperOnlyNotAllowedInPlan { .. })
1271            ),
1272            "developer-only check must locate the offending step index"
1273        );
1274    }
1275
1276    // --- RenameModel / RenameField / ChangeFieldType / ChangeFieldNullability
1277
1278    fn rename_model(from: &str, to: &str) -> Primitive {
1279        Primitive::RenameModel(RenameModel {
1280            from: from.to_string(),
1281            to: to.to_string(),
1282        })
1283    }
1284
1285    fn rename_field(model: &str, from: &str, to: &str) -> Primitive {
1286        Primitive::RenameField(RenameField {
1287            model: model.to_string(),
1288            from: from.to_string(),
1289            to: to.to_string(),
1290        })
1291    }
1292
1293    fn change_type(model: &str, field: &str, new_type: &str) -> Primitive {
1294        Primitive::ChangeFieldType(ChangeFieldType {
1295            model: model.to_string(),
1296            field: field.to_string(),
1297            new_type: new_type.to_string(),
1298        })
1299    }
1300
1301    fn change_nullable(model: &str, field: &str, nullable: bool) -> Primitive {
1302        Primitive::ChangeFieldNullability(ChangeFieldNullability {
1303            model: model.to_string(),
1304            field: field.to_string(),
1305            nullable,
1306        })
1307    }
1308
1309    #[test]
1310    fn rename_primitives_round_trip_through_json() {
1311        for p in [
1312            rename_model("Post", "Article"),
1313            rename_field("Post", "title", "heading"),
1314            change_type("Post", "priority", "i64"),
1315            change_nullable("Post", "title", true),
1316        ] {
1317            let json = serde_json::to_string(&p).unwrap();
1318            let back: Primitive = serde_json::from_str(&json).unwrap();
1319            assert_eq!(back.op_name(), p.op_name());
1320        }
1321    }
1322
1323    #[test]
1324    fn rename_model_rejects_noop() {
1325        let p = rename_model("Post", "Post");
1326        assert!(matches!(
1327            validate_primitive(&p),
1328            Err(PrimitiveError::NoOpRename { what: "model", .. })
1329        ));
1330    }
1331
1332    #[test]
1333    fn rename_field_rejects_noop() {
1334        let p = rename_field("Post", "title", "title");
1335        assert!(matches!(
1336            validate_primitive(&p),
1337            Err(PrimitiveError::NoOpRename { what: "field", .. })
1338        ));
1339    }
1340
1341    #[test]
1342    fn rename_model_rejects_empty_names() {
1343        let p = rename_model("", "X");
1344        assert!(matches!(
1345            validate_primitive(&p),
1346            Err(PrimitiveError::EmptyIdentifier(_))
1347        ));
1348    }
1349
1350    #[test]
1351    fn change_field_type_rejects_unknown_type() {
1352        let p = change_type("Post", "priority", "HyperFloat128");
1353        assert!(matches!(
1354            validate_primitive(&p),
1355            Err(PrimitiveError::UnknownType { .. })
1356        ));
1357    }
1358
1359    #[test]
1360    fn validate_against_rejects_rename_of_missing_model() {
1361        let err = validate_against(&rename_model("Ghost", "Wraith"), &schema()).unwrap_err();
1362        assert!(matches!(
1363            err,
1364            PrimitiveError::NotFound { what: "model", .. }
1365        ));
1366    }
1367
1368    #[test]
1369    fn validate_against_rejects_rename_to_existing_model() {
1370        // schema() has one model "Post". Renaming something TO "Post"
1371        // must collide (here we have to synthesize a second model so
1372        // there's something to rename; use an AddModel step first in
1373        // a plan).
1374        let plan = Plan::new(vec![
1375            Primitive::AddModel(AddModel {
1376                name: "Draft".to_string(),
1377                table: "drafts".to_string(),
1378                fields: Vec::new(),
1379            }),
1380            rename_model("Draft", "Post"),
1381        ]);
1382        let err = plan.validate(&schema()).unwrap_err();
1383        assert!(
1384            matches!(
1385                err,
1386                PrimitiveError::InStep { step: 1, inner }
1387                    if matches!(*inner, PrimitiveError::AlreadyExists { what: "model", .. })
1388            ),
1389            "must reject rename-over-existing-name"
1390        );
1391    }
1392
1393    #[test]
1394    fn validate_against_rejects_rename_field_to_existing_name() {
1395        // schema() has Post with fields [id, title]. Renaming id → title collides.
1396        let err = validate_against(&rename_field("Post", "id", "title"), &schema()).unwrap_err();
1397        assert!(matches!(
1398            err,
1399            PrimitiveError::AlreadyExists { what: "field", .. }
1400        ));
1401    }
1402
1403    #[test]
1404    fn validate_against_rejects_change_type_on_missing_field() {
1405        let err = validate_against(&change_type("Post", "ghost", "i32"), &schema()).unwrap_err();
1406        assert!(matches!(
1407            err,
1408            PrimitiveError::NotFound { what: "field", .. }
1409        ));
1410    }
1411
1412    #[test]
1413    fn validate_against_rejects_change_nullability_on_missing_field() {
1414        let err = validate_against(&change_nullable("Post", "ghost", true), &schema()).unwrap_err();
1415        assert!(matches!(
1416            err,
1417            PrimitiveError::NotFound { what: "field", .. }
1418        ));
1419    }
1420
1421    #[test]
1422    fn plan_chains_rename_then_change_type_correctly() {
1423        // After renaming a model, subsequent steps must reference the
1424        // new name — proves that rename_model's apply_shadow actually
1425        // updates the schema copy.
1426        let plan = Plan::new(vec![
1427            rename_model("Post", "Article"),
1428            change_type("Article", "title", "String"),
1429        ]);
1430        assert_eq!(plan.validate(&schema()), Ok(()));
1431    }
1432
1433    #[test]
1434    fn plan_chains_rename_field_then_change_nullability() {
1435        let plan = Plan::new(vec![
1436            rename_field("Post", "title", "heading"),
1437            change_nullable("Post", "heading", true),
1438        ]);
1439        assert_eq!(plan.validate(&schema()), Ok(()));
1440    }
1441
1442    #[test]
1443    fn plan_json_round_trip() {
1444        let plan = Plan::new(vec![Primitive::CreateMigration(CreateMigration {
1445            name: "add_books".to_string(),
1446            sql: "CREATE TABLE books (id INTEGER);".to_string(),
1447        })]);
1448        let json = serde_json::to_string(&plan).unwrap();
1449        let back: Plan = serde_json::from_str(&json).unwrap();
1450        assert_eq!(back.steps.len(), 1);
1451    }
1452}