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 planner;
34pub mod review;
35
36#[cfg(test)]
37mod context_tests;
38#[cfg(test)]
39mod executor_pg_tests;
40#[cfg(test)]
41mod executor_tests;
42#[cfg(test)]
43mod executor_tests_advanced;
44#[cfg(test)]
45mod planner_tests;
46#[cfg(test)]
47mod review_tests;
48
49pub use executor::{
50    execute_plan_document, plan_execution, plan_retrofit_foreign_keys, render_preview_human,
51    ExecuteOptions, ExecutionError, ExecutionPreview, ExecutionResult, FileChangeKind,
52    ParsedModelsFile, PlannedFileChange, ProjectView, RetrofitReport,
53};
54pub use industry::{industry_schema_for, IndustrySchema};
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};
870    use crate::http::FormData;
871    use crate::error::Error as CoreError;
872    use crate::orm::{Model, Row, Value};
873
874    // Reuse a simple Post model for the "schema exists" fixtures.
875    struct Post;
876    impl Model for Post {
877        const TABLE: &'static str = "posts";
878        const COLUMNS: &'static [&'static str] = &["id", "title"];
879        const INSERT_COLUMNS: &'static [&'static str] = &["title"];
880        fn id(&self) -> i64 {
881            0
882        }
883        fn from_row(_: Row<'_>) -> Result<Self, CoreError> {
884            unimplemented!()
885        }
886        fn insert_values(&self) -> Vec<Value> {
887            Vec::new()
888        }
889    }
890    impl AdminModel for Post {
891        const ADMIN_NAME: &'static str = "posts";
892        const DISPLAY_NAME: &'static str = "Posts";
893        const SINGULAR_NAME: &'static str = "Post";
894        const FIELDS: &'static [AdminField] = &[
895            AdminField {
896                name: "id",
897                label: "id",
898                field_type: FieldType::I64,
899                editable: false,
900                relation: None,
901                choices: None,
902            },
903            AdminField {
904                name: "title",
905                label: "title",
906                field_type: FieldType::String,
907                editable: true,
908                relation: None,
909                choices: None,
910            },
911        ];
912        fn display_values(&self) -> Vec<(String, String)> {
913            Vec::new()
914        }
915        fn from_form(_: &FormData) -> std::result::Result<Self, Vec<String>> {
916            unimplemented!()
917        }
918        fn object_label(&self) -> String {
919            "Post".into()
920        }
921        fn id(&self) -> i64 {
922            0
923        }
924        fn values_to_update(&self) -> Vec<(&'static str, Value)> {
925            Vec::new()
926        }
927    }
928
929    fn schema() -> Schema {
930        Schema::from_admin(&Admin::new().model::<Post>())
931    }
932
933    // ---- structural validation --------------------------------------------
934
935    #[test]
936    fn add_field_round_trips_through_json() {
937        let p = Primitive::AddField(AddField {
938            model: "Post".to_string(),
939            field: FieldSpec {
940                name: "published".to_string(),
941                ty: "bool".to_string(),
942                nullable: false,
943                editable: true,
944            },
945        });
946        let json = serde_json::to_string(&p).unwrap();
947        assert!(json.contains(r#""op":"add_field""#));
948        assert!(json.contains(r#""name":"published""#));
949
950        let back: Primitive = serde_json::from_str(&json).unwrap();
951        match back {
952            Primitive::AddField(af) => {
953                assert_eq!(af.model, "Post");
954                assert_eq!(af.field.name, "published");
955                assert_eq!(af.field.ty, "bool");
956            }
957            _ => panic!("expected AddField"),
958        }
959    }
960
961    #[test]
962    fn unknown_op_is_rejected_not_swallowed() {
963        let bad = r#"{"op":"rewrite_universe","world":"goodbye"}"#;
964        let parsed: Result<Primitive, _> = serde_json::from_str(bad);
965        assert!(parsed.is_err(), "unknown op must not parse");
966    }
967
968    #[test]
969    fn unknown_field_on_known_op_is_rejected() {
970        // `add_model` payload with a typo'd field must fail rather than
971        // being silently dropped.
972        let bad = r#"{"op":"add_model","name":"X","table":"xs","fields":[],"extra":true}"#;
973        let parsed: Result<Primitive, _> = serde_json::from_str(bad);
974        assert!(
975            parsed.is_err(),
976            "unknown keys on known ops must be rejected"
977        );
978    }
979
980    #[test]
981    fn missing_required_field_is_rejected() {
982        // `add_model` requires `table`; dropping it must fail.
983        let bad = r#"{"op":"add_model","name":"X","fields":[]}"#;
984        let parsed: Result<Primitive, _> = serde_json::from_str(bad);
985        assert!(parsed.is_err(), "missing required fields must be rejected");
986    }
987
988    #[test]
989    fn add_relation_with_belongs_to_serialises_snake_case() {
990        let p = Primitive::AddRelation(AddRelation {
991            from: "Post".to_string(),
992            kind: RelationKind::BelongsTo,
993            to: "User".to_string(),
994            via: "user_id".to_string(),
995            required: false,
996            on_delete: OnDelete::Restrict,
997        });
998        let json = serde_json::to_string(&p).unwrap();
999        assert!(json.contains(r#""kind":"belongs_to""#));
1000    }
1001
1002    #[test]
1003    fn validate_primitive_rejects_unknown_type() {
1004        let p = Primitive::AddField(AddField {
1005            model: "Post".to_string(),
1006            field: FieldSpec {
1007                name: "flux".to_string(),
1008                ty: "HyperFloat128".to_string(),
1009                nullable: false,
1010                editable: true,
1011            },
1012        });
1013        assert!(matches!(
1014            validate_primitive(&p),
1015            Err(PrimitiveError::UnknownType { .. })
1016        ));
1017    }
1018
1019    #[test]
1020    fn validate_primitive_rejects_empty_names() {
1021        let p = Primitive::AddField(AddField {
1022            model: "".to_string(),
1023            field: FieldSpec {
1024                name: "x".to_string(),
1025                ty: "i64".to_string(),
1026                nullable: false,
1027                editable: true,
1028            },
1029        });
1030        assert_eq!(
1031            validate_primitive(&p),
1032            Err(PrimitiveError::EmptyIdentifier("model name"))
1033        );
1034    }
1035
1036    #[test]
1037    fn validate_primitive_rejects_duplicate_fields_in_add_model() {
1038        let p = Primitive::AddModel(AddModel {
1039            name: "Book".to_string(),
1040            table: "books".to_string(),
1041            fields: vec![
1042                FieldSpec {
1043                    name: "title".to_string(),
1044                    ty: "String".to_string(),
1045                    nullable: false,
1046                    editable: true,
1047                },
1048                FieldSpec {
1049                    name: "title".to_string(),
1050                    ty: "String".to_string(),
1051                    nullable: false,
1052                    editable: true,
1053                },
1054            ],
1055        });
1056        assert!(matches!(
1057            validate_primitive(&p),
1058            Err(PrimitiveError::DuplicateFieldInAddModel { .. })
1059        ));
1060    }
1061
1062    #[test]
1063    fn update_admin_rejects_unknown_attribute() {
1064        let p = Primitive::UpdateAdmin(UpdateAdmin {
1065            model: "Post".to_string(),
1066            field: "title".to_string(),
1067            attr: "telepathy".to_string(),
1068            value: serde_json::Value::Bool(true),
1069        });
1070        assert!(matches!(
1071            validate_primitive(&p),
1072            Err(PrimitiveError::UnknownAdminAttribute { .. })
1073        ));
1074    }
1075
1076    // ---- semantic validation ----------------------------------------------
1077
1078    #[test]
1079    fn validate_against_rejects_remove_of_nonexistent_model() {
1080        let p = Primitive::RemoveModel(RemoveModel {
1081            name: "Ghost".to_string(),
1082        });
1083        let err = validate_against(&p, &schema()).unwrap_err();
1084        assert!(matches!(
1085            err,
1086            PrimitiveError::NotFound { what: "model", .. }
1087        ));
1088    }
1089
1090    #[test]
1091    fn validate_against_rejects_add_field_to_missing_model() {
1092        let p = Primitive::AddField(AddField {
1093            model: "Ghost".to_string(),
1094            field: FieldSpec {
1095                name: "age".to_string(),
1096                ty: "i32".to_string(),
1097                nullable: false,
1098                editable: true,
1099            },
1100        });
1101        let err = validate_against(&p, &schema()).unwrap_err();
1102        assert!(matches!(
1103            err,
1104            PrimitiveError::NotFound { what: "model", .. }
1105        ));
1106    }
1107
1108    #[test]
1109    fn validate_against_rejects_duplicate_field_add() {
1110        let p = Primitive::AddField(AddField {
1111            model: "Post".to_string(),
1112            field: FieldSpec {
1113                name: "title".to_string(),
1114                ty: "String".to_string(),
1115                nullable: false,
1116                editable: true,
1117            },
1118        });
1119        let err = validate_against(&p, &schema()).unwrap_err();
1120        assert!(matches!(
1121            err,
1122            PrimitiveError::AlreadyExists { what: "field", .. }
1123        ));
1124    }
1125
1126    #[test]
1127    fn validate_against_rejects_relation_to_missing_model() {
1128        let p = Primitive::AddRelation(AddRelation {
1129            from: "Post".to_string(),
1130            kind: RelationKind::BelongsTo,
1131            to: "Ghost".to_string(),
1132            via: "ghost_id".to_string(),
1133            required: false,
1134            on_delete: OnDelete::Restrict,
1135        });
1136        let err = validate_against(&p, &schema()).unwrap_err();
1137        assert!(matches!(err, PrimitiveError::UnknownRelationTarget { .. }));
1138    }
1139
1140    // ---- plan-level simulation --------------------------------------------
1141
1142    #[test]
1143    fn plan_validates_sequential_additions() {
1144        let plan = Plan::new(vec![
1145            Primitive::AddModel(AddModel {
1146                name: "Book".to_string(),
1147                table: "books".to_string(),
1148                fields: vec![FieldSpec {
1149                    name: "title".to_string(),
1150                    ty: "String".to_string(),
1151                    nullable: false,
1152                    editable: true,
1153                }],
1154            }),
1155            // Plan-aware: this add_field is against the model the
1156            // previous step *just added* — the simulator must see it.
1157            Primitive::AddField(AddField {
1158                model: "Book".to_string(),
1159                field: FieldSpec {
1160                    name: "published".to_string(),
1161                    ty: "bool".to_string(),
1162                    nullable: false,
1163                    editable: true,
1164                },
1165            }),
1166        ]);
1167        assert_eq!(plan.validate(&schema()), Ok(()));
1168    }
1169
1170    #[test]
1171    fn plan_rejects_second_add_of_same_model() {
1172        let add_book = Primitive::AddModel(AddModel {
1173            name: "Book".to_string(),
1174            table: "books".to_string(),
1175            fields: Vec::new(),
1176        });
1177        let plan = Plan::new(vec![add_book.clone(), add_book]);
1178        let err = plan.validate(&schema()).unwrap_err();
1179        assert!(
1180            matches!(
1181                &err,
1182                PrimitiveError::InStep { step: 1, inner } if matches!(**inner, PrimitiveError::AlreadyExists { what: "model", .. })
1183            ),
1184            "got: {err:?}"
1185        );
1186    }
1187
1188    #[test]
1189    fn plan_rejects_field_add_after_model_removed() {
1190        let plan = Plan::new(vec![
1191            Primitive::RemoveModel(RemoveModel {
1192                name: "Post".to_string(),
1193            }),
1194            Primitive::AddField(AddField {
1195                model: "Post".to_string(),
1196                field: FieldSpec {
1197                    name: "subtitle".to_string(),
1198                    ty: "String".to_string(),
1199                    nullable: true,
1200                    editable: true,
1201                },
1202            }),
1203        ]);
1204        let err = plan.validate(&schema()).unwrap_err();
1205        assert!(
1206            matches!(
1207                err,
1208                PrimitiveError::InStep { step: 1, inner } if matches!(*inner, PrimitiveError::NotFound { what: "model", .. })
1209            ),
1210            "plan must fail on the second step, not the first"
1211        );
1212    }
1213
1214    #[test]
1215    fn empty_plan_is_always_valid() {
1216        assert_eq!(Plan::new(Vec::new()).validate(&schema()), Ok(()));
1217    }
1218
1219    #[test]
1220    fn create_migration_is_developer_only() {
1221        let m = Primitive::CreateMigration(CreateMigration {
1222            name: "add_books".to_string(),
1223            sql: "CREATE TABLE books (id INTEGER);".to_string(),
1224        });
1225        assert!(m.is_developer_only());
1226        assert!(!Primitive::RemoveModel(RemoveModel {
1227            name: "X".to_string()
1228        })
1229        .is_developer_only());
1230    }
1231
1232    #[test]
1233    fn validate_primitive_still_accepts_create_migration_for_direct_use() {
1234        // The developer-only gate lives on `Plan::validate`, not on
1235        // `validate_primitive`. Tooling code calling the latter
1236        // directly must still accept CreateMigration.
1237        let m = Primitive::CreateMigration(CreateMigration {
1238            name: "add_books".to_string(),
1239            sql: "CREATE TABLE books (id INTEGER);".to_string(),
1240        });
1241        assert_eq!(validate_primitive(&m), Ok(()));
1242    }
1243
1244    #[test]
1245    fn plan_rejects_create_migration_even_when_structurally_valid() {
1246        let plan = Plan::new(vec![Primitive::CreateMigration(CreateMigration {
1247            name: "add_books".to_string(),
1248            sql: "CREATE TABLE books (id INTEGER);".to_string(),
1249        })]);
1250        let err = plan.validate(&schema()).unwrap_err();
1251        assert!(
1252            matches!(
1253                &err,
1254                PrimitiveError::InStep { step: 0, inner }
1255                    if matches!(
1256                        **inner,
1257                        PrimitiveError::DeveloperOnlyNotAllowedInPlan { op: "create_migration" },
1258                    )
1259            ),
1260            "got: {err:?}"
1261        );
1262    }
1263
1264    #[test]
1265    fn plan_rejects_create_migration_at_the_offending_step() {
1266        let plan = Plan::new(vec![
1267            Primitive::RemoveModel(RemoveModel {
1268                name: "Post".to_string(),
1269            }),
1270            Primitive::CreateMigration(CreateMigration {
1271                name: "tidy".to_string(),
1272                sql: "DROP TABLE posts;".to_string(),
1273            }),
1274        ]);
1275        let err = plan.validate(&schema()).unwrap_err();
1276        assert!(
1277            matches!(
1278                err,
1279                PrimitiveError::InStep { step: 1, inner }
1280                    if matches!(*inner, PrimitiveError::DeveloperOnlyNotAllowedInPlan { .. })
1281            ),
1282            "developer-only check must locate the offending step index"
1283        );
1284    }
1285
1286    // --- RenameModel / RenameField / ChangeFieldType / ChangeFieldNullability
1287
1288    fn rename_model(from: &str, to: &str) -> Primitive {
1289        Primitive::RenameModel(RenameModel {
1290            from: from.to_string(),
1291            to: to.to_string(),
1292        })
1293    }
1294
1295    fn rename_field(model: &str, from: &str, to: &str) -> Primitive {
1296        Primitive::RenameField(RenameField {
1297            model: model.to_string(),
1298            from: from.to_string(),
1299            to: to.to_string(),
1300        })
1301    }
1302
1303    fn change_type(model: &str, field: &str, new_type: &str) -> Primitive {
1304        Primitive::ChangeFieldType(ChangeFieldType {
1305            model: model.to_string(),
1306            field: field.to_string(),
1307            new_type: new_type.to_string(),
1308        })
1309    }
1310
1311    fn change_nullable(model: &str, field: &str, nullable: bool) -> Primitive {
1312        Primitive::ChangeFieldNullability(ChangeFieldNullability {
1313            model: model.to_string(),
1314            field: field.to_string(),
1315            nullable,
1316        })
1317    }
1318
1319    #[test]
1320    fn rename_primitives_round_trip_through_json() {
1321        for p in [
1322            rename_model("Post", "Article"),
1323            rename_field("Post", "title", "heading"),
1324            change_type("Post", "priority", "i64"),
1325            change_nullable("Post", "title", true),
1326        ] {
1327            let json = serde_json::to_string(&p).unwrap();
1328            let back: Primitive = serde_json::from_str(&json).unwrap();
1329            assert_eq!(back.op_name(), p.op_name());
1330        }
1331    }
1332
1333    #[test]
1334    fn rename_model_rejects_noop() {
1335        let p = rename_model("Post", "Post");
1336        assert!(matches!(
1337            validate_primitive(&p),
1338            Err(PrimitiveError::NoOpRename { what: "model", .. })
1339        ));
1340    }
1341
1342    #[test]
1343    fn rename_field_rejects_noop() {
1344        let p = rename_field("Post", "title", "title");
1345        assert!(matches!(
1346            validate_primitive(&p),
1347            Err(PrimitiveError::NoOpRename { what: "field", .. })
1348        ));
1349    }
1350
1351    #[test]
1352    fn rename_model_rejects_empty_names() {
1353        let p = rename_model("", "X");
1354        assert!(matches!(
1355            validate_primitive(&p),
1356            Err(PrimitiveError::EmptyIdentifier(_))
1357        ));
1358    }
1359
1360    #[test]
1361    fn change_field_type_rejects_unknown_type() {
1362        let p = change_type("Post", "priority", "HyperFloat128");
1363        assert!(matches!(
1364            validate_primitive(&p),
1365            Err(PrimitiveError::UnknownType { .. })
1366        ));
1367    }
1368
1369    #[test]
1370    fn validate_against_rejects_rename_of_missing_model() {
1371        let err = validate_against(&rename_model("Ghost", "Wraith"), &schema()).unwrap_err();
1372        assert!(matches!(
1373            err,
1374            PrimitiveError::NotFound { what: "model", .. }
1375        ));
1376    }
1377
1378    #[test]
1379    fn validate_against_rejects_rename_to_existing_model() {
1380        // schema() has one model "Post". Renaming something TO "Post"
1381        // must collide (here we have to synthesize a second model so
1382        // there's something to rename; use an AddModel step first in
1383        // a plan).
1384        let plan = Plan::new(vec![
1385            Primitive::AddModel(AddModel {
1386                name: "Draft".to_string(),
1387                table: "drafts".to_string(),
1388                fields: Vec::new(),
1389            }),
1390            rename_model("Draft", "Post"),
1391        ]);
1392        let err = plan.validate(&schema()).unwrap_err();
1393        assert!(
1394            matches!(
1395                err,
1396                PrimitiveError::InStep { step: 1, inner }
1397                    if matches!(*inner, PrimitiveError::AlreadyExists { what: "model", .. })
1398            ),
1399            "must reject rename-over-existing-name"
1400        );
1401    }
1402
1403    #[test]
1404    fn validate_against_rejects_rename_field_to_existing_name() {
1405        // schema() has Post with fields [id, title]. Renaming id → title collides.
1406        let err = validate_against(&rename_field("Post", "id", "title"), &schema()).unwrap_err();
1407        assert!(matches!(
1408            err,
1409            PrimitiveError::AlreadyExists { what: "field", .. }
1410        ));
1411    }
1412
1413    #[test]
1414    fn validate_against_rejects_change_type_on_missing_field() {
1415        let err = validate_against(&change_type("Post", "ghost", "i32"), &schema()).unwrap_err();
1416        assert!(matches!(
1417            err,
1418            PrimitiveError::NotFound { what: "field", .. }
1419        ));
1420    }
1421
1422    #[test]
1423    fn validate_against_rejects_change_nullability_on_missing_field() {
1424        let err = validate_against(&change_nullable("Post", "ghost", true), &schema()).unwrap_err();
1425        assert!(matches!(
1426            err,
1427            PrimitiveError::NotFound { what: "field", .. }
1428        ));
1429    }
1430
1431    #[test]
1432    fn plan_chains_rename_then_change_type_correctly() {
1433        // After renaming a model, subsequent steps must reference the
1434        // new name — proves that rename_model's apply_shadow actually
1435        // updates the schema copy.
1436        let plan = Plan::new(vec![
1437            rename_model("Post", "Article"),
1438            change_type("Article", "title", "String"),
1439        ]);
1440        assert_eq!(plan.validate(&schema()), Ok(()));
1441    }
1442
1443    #[test]
1444    fn plan_chains_rename_field_then_change_nullability() {
1445        let plan = Plan::new(vec![
1446            rename_field("Post", "title", "heading"),
1447            change_nullable("Post", "heading", true),
1448        ]);
1449        assert_eq!(plan.validate(&schema()), Ok(()));
1450    }
1451
1452    #[test]
1453    fn plan_json_round_trip() {
1454        let plan = Plan::new(vec![Primitive::CreateMigration(CreateMigration {
1455            name: "add_books".to_string(),
1456            sql: "CREATE TABLE books (id INTEGER);".to_string(),
1457        })]);
1458        let json = serde_json::to_string(&plan).unwrap();
1459        let back: Plan = serde_json::from_str(&json).unwrap();
1460        assert_eq!(back.steps.len(), 1);
1461    }
1462}