Skip to main content

rustio_core/ai/
planner.rs

1//! Rule-based AI planner — the *brain* of the 0.5.0 intelligence layer.
2//!
3//! Reads a natural-language prompt, a project schema, and an optional
4//! context file. Emits a structured [`Plan`] of [`Primitive`] operations
5//! plus a human-readable explanation. **Never executes.**
6//!
7//! ## Boundaries
8//!
9//! - No filesystem writes. No database. No SQL. No network.
10//! - No `CreateMigration` in emitted plans — that primitive is
11//!   developer-only and [`Plan::validate`] rejects it.
12//! - Returns [`PlanError`] for every case the planner cannot confidently
13//!   resolve; the caller decides whether to retry with a clearer prompt.
14//!
15//! ## Inference strategy
16//!
17//! Pure rule-based pattern matching. No model calls. The grammar covers:
18//!
19//! - `add <field> to <model>`
20//! - `add <field> as <type> to <model>`
21//! - `add optional <field> to <model>`
22//! - `rename <field> to <new> in <model>`
23//! - `rename model <old> to <new>` / `rename <old> to <new>`
24//! - `remove <field> from <model>` / `drop <field> from <model>`
25//! - `change <field> in <model> to <type>`
26//! - `make <field> in <model> optional|nullable|required`
27//!
28//! Anything outside this grammar returns [`PlanError::InvalidIntent`]
29//! with a list of supported forms — never a guessed plan.
30//!
31//! ## Why rule-based
32//!
33//! The planner is a *safety surface*. A statistical model can hallucinate
34//! a field the schema doesn't have; a rule can't. The output of this
35//! module is the single input the 0.5.x executor will see, so every
36//! ambiguity that lives here would live in production. We keep it
37//! deterministic and auditable.
38
39use serde::{Deserialize, Serialize};
40
41use super::{
42    validate_primitive, AddField, ChangeFieldNullability, ChangeFieldType, FieldSpec, Plan,
43    Primitive, PrimitiveError, RemoveField, RenameField, RenameModel,
44};
45use crate::schema::{Schema, SchemaModel};
46
47/// Optional per-project context loaded from `rustio.context.json`.
48///
49/// The 0.6.0 shape covers four axes:
50///
51/// - `country` — ISO-3166-1 alpha-2 (`"SE"`, `"NO"`, …). Drives
52///   locale-aware naming (a Swedish project gets `personnummer` for
53///   "personal id", not an `i32`).
54/// - `region` — supra-national grouping (`"EU"`). Mostly inferred from
55///   `country`; explicit setting is an override.
56/// - `industry` — `"housing"`, `"healthcare"`, `"banking"`. Picked up
57///   by the planner / review / executor so "patient id" under
58///   `healthcare` becomes a `String` rather than an `i32`, and removing
59///   a convention field raises a warning.
60/// - `compliance` — explicit list (`["GDPR", "HIPAA"]`). Empty by
61///   default; the helpers below treat `region=EU` as implying `GDPR`
62///   even when the list is empty.
63///
64/// `#[serde(default, deny_unknown_fields)]` keeps the wire contract
65/// tight: a typo is a loud error, not a silent miss.
66///
67/// **Breaking change vs 0.5.x:** the old `domain` key is gone. If your
68/// `rustio.context.json` still reads `{"domain": "housing"}`, rename
69/// the key to `industry`.
70#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
71#[serde(default, deny_unknown_fields)]
72pub struct ContextConfig {
73    pub country: Option<String>,
74    pub region: Option<String>,
75    pub industry: Option<String>,
76    #[serde(default)]
77    pub compliance: Vec<String>,
78}
79
80impl ContextConfig {
81    pub fn parse(json: &str) -> Result<Self, PlanError> {
82        serde_json::from_str::<ContextConfig>(json)
83            .map_err(|e| PlanError::ContextParse(e.to_string()))
84    }
85
86    /// Either the explicit `region`, or a best-effort inference from
87    /// `country`. Today we only know the EU list; other regions
88    /// (ASEAN, LATAM, MENA, …) fall through to `None` until projects
89    /// ask for them.
90    pub fn effective_region(&self) -> Option<String> {
91        if let Some(r) = &self.region {
92            if !r.trim().is_empty() {
93                return Some(r.clone());
94            }
95        }
96        const EU_MEMBER_STATES: &[&str] = &[
97            "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE",
98            "IT", "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE",
99        ];
100        match self.country.as_deref() {
101            Some(cc) if EU_MEMBER_STATES.iter().any(|m| m.eq_ignore_ascii_case(cc)) => {
102                Some("EU".into())
103            }
104            _ => None,
105        }
106    }
107
108    /// `true` if the project operates under the GDPR. Detected by
109    /// either (a) `compliance` listing `"GDPR"` explicitly, or
110    /// (b) the resolved region being `"EU"`.
111    pub fn requires_gdpr(&self) -> bool {
112        if self
113            .compliance
114            .iter()
115            .any(|c| c.trim().eq_ignore_ascii_case("GDPR"))
116        {
117            return true;
118        }
119        matches!(self.effective_region().as_deref(), Some("EU"))
120    }
121
122    /// Look up the industry convention bundle for the selected industry
123    /// (case-insensitive). `None` if the project didn't set one or the
124    /// name isn't in the registry.
125    pub fn industry_schema(&self) -> Option<super::industry::IndustrySchema> {
126        self.industry
127            .as_deref()
128            .and_then(super::industry::industry_schema_for)
129    }
130
131    /// Field names considered personally-identifying under the current
132    /// context. Returns a stable, deduplicated list. Used by the review
133    /// layer to escalate risk on destructive primitives and by the
134    /// executor to refuse them outright with
135    /// `ExecutionError::PolicyViolation`.
136    ///
137    /// Conservative by design: the list grows only as each rule is
138    /// justified. A project needing stricter enforcement can still
139    /// layer its own checks on top.
140    pub fn pii_fields(&self) -> Vec<&'static str> {
141        let mut out: Vec<&'static str> = Vec::new();
142        match self.country.as_deref() {
143            Some(cc) if cc.eq_ignore_ascii_case("SE") => {
144                out.push("personnummer");
145            }
146            Some(cc) if cc.eq_ignore_ascii_case("NO") => {
147                out.push("fodselsnummer");
148            }
149            Some(cc) if cc.eq_ignore_ascii_case("US") => {
150                out.push("ssn");
151            }
152            _ => {}
153        }
154        if self.requires_gdpr() {
155            // Generic PII under GDPR. The list is deliberately short —
156            // contact details that a reasonable reviewer would want to
157            // flag. Wider lists (device IDs, IP addresses) need their
158            // own review pass.
159            for f in ["email", "phone", "address", "date_of_birth"] {
160                if !out.contains(&f) {
161                    out.push(f);
162                }
163            }
164        }
165        out
166    }
167
168    /// `true` when the context carries at least one useful signal. The
169    /// CLI uses this to decide whether `rustio context show` has
170    /// something to print.
171    pub fn is_empty(&self) -> bool {
172        self.country.is_none()
173            && self.region.is_none()
174            && self.industry.is_none()
175            && self.compliance.is_empty()
176    }
177}
178
179/// A natural-language planning request.
180#[derive(Debug, Clone)]
181pub struct PlanRequest {
182    pub prompt: String,
183}
184
185impl PlanRequest {
186    pub fn new<S: Into<String>>(prompt: S) -> Self {
187        Self {
188            prompt: prompt.into(),
189        }
190    }
191}
192
193/// Output of [`generate_plan`]: the structured steps plus a one-
194/// paragraph rationale the CLI can display.
195#[derive(Debug, Clone)]
196pub struct PlanResult {
197    pub plan: Plan,
198    pub explanation: String,
199}
200
201/// Every reason the planner can refuse. Named variants so downstream
202/// tooling can branch on kind instead of parsing strings.
203#[non_exhaustive]
204#[derive(Debug, Clone, PartialEq)]
205pub enum PlanError {
206    /// Prompt was empty or whitespace only.
207    EmptyPrompt,
208    /// Prompt didn't match any supported grammar.
209    InvalidIntent(String),
210    /// Prompt referenced a model the schema doesn't know about.
211    UnknownModel { hint: String },
212    /// Prompt referenced a model name that matched more than one
213    /// registered model (e.g. under both struct name and singular form).
214    /// The candidates are surfaced so the caller can re-prompt with a
215    /// specific name.
216    AmbiguousModel {
217        hint: String,
218        candidates: Vec<String>,
219    },
220    /// `add_field` would collide with an existing field.
221    FieldAlreadyExists { model: String, field: String },
222    /// `remove_field` / `rename_field` / `change_*` referenced a field
223    /// that doesn't exist on the resolved model.
224    FieldDoesNotExist { model: String, field: String },
225    /// User asked for something only a developer may do (e.g. raw
226    /// SQL, `create_migration`). The planner refuses up front.
227    DeveloperOnlyRequested(&'static str),
228    /// Planner-proposed an operation that would modify a `core: true`
229    /// model (e.g. `User`). Refused.
230    CoreModelProtected(String),
231    /// Unknown type hint the user supplied (`as foobar`).
232    UnknownType(String),
233    /// The composed plan failed [`Plan::validate`] — the schema-aware
234    /// simulation disagreed with the proposed primitive. Wrapped error
235    /// carries the step index + reason.
236    Validation(PrimitiveError),
237    /// `rustio.context.json` existed but failed to parse.
238    ContextParse(String),
239}
240
241impl std::fmt::Display for PlanError {
242    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243        match self {
244            Self::EmptyPrompt => write!(f, "empty prompt"),
245            Self::InvalidIntent(msg) => write!(f, "invalid intent: {msg}"),
246            Self::UnknownModel { hint } => write!(f, "unknown model `{hint}`"),
247            Self::AmbiguousModel { hint, candidates } => write!(
248                f,
249                "ambiguous model `{hint}` (candidates: {})",
250                candidates.join(", ")
251            ),
252            Self::FieldAlreadyExists { model, field } => {
253                write!(f, "field `{model}.{field}` already exists")
254            }
255            Self::FieldDoesNotExist { model, field } => {
256                write!(f, "field `{model}.{field}` does not exist")
257            }
258            Self::DeveloperOnlyRequested(op) => write!(
259                f,
260                "`{op}` is developer-only and the AI planner cannot emit it"
261            ),
262            Self::CoreModelProtected(name) => write!(
263                f,
264                "model `{name}` is a core model and cannot be modified by the AI planner"
265            ),
266            Self::UnknownType(t) => write!(
267                f,
268                "unknown type `{t}` (valid: i32, i64, String, bool, DateTime)"
269            ),
270            Self::Validation(e) => write!(f, "plan validation failed: {e}"),
271            Self::ContextParse(msg) => write!(f, "rustio.context.json parse error: {msg}"),
272        }
273    }
274}
275
276impl std::error::Error for PlanError {}
277
278/// Entry point. Interprets `request.prompt` against `schema` (with
279/// optional `context`) and returns a validated [`Plan`] or a specific
280/// [`PlanError`].
281///
282/// The function performs no I/O. The caller owns schema/context
283/// loading, so this module stays trivially testable.
284pub fn generate_plan(
285    schema: &Schema,
286    context: Option<&ContextConfig>,
287    request: PlanRequest,
288) -> Result<PlanResult, PlanError> {
289    let raw = request.prompt.trim();
290    if raw.is_empty() {
291        return Err(PlanError::EmptyPrompt);
292    }
293    let lower = raw.to_lowercase();
294
295    // Refuse anything that smells like a developer-only request before
296    // we try to pattern-match it as a structured op.
297    if lower.contains("create migration")
298        || lower.contains("raw sql")
299        || lower.contains("run sql")
300        || lower.contains("execute sql")
301        || lower.contains("add sql")
302    {
303        return Err(PlanError::DeveloperOnlyRequested("create_migration"));
304    }
305
306    // Order matters: more specific patterns first so a `rename model …`
307    // isn't mistaken for a `rename <field> …` with a weird model hint.
308    for parser in PARSERS {
309        if let Some(result) = parser(raw, &lower, schema, context)? {
310            // Every returned plan is validated against the schema; this
311            // is the final safety gate the caller can rely on.
312            result
313                .plan
314                .validate(schema)
315                .map_err(PlanError::Validation)?;
316            return Ok(result);
317        }
318    }
319
320    Err(PlanError::InvalidIntent(supported_forms_message(raw)))
321}
322
323// ---------------------------------------------------------------------------
324// Pattern parsers
325// ---------------------------------------------------------------------------
326
327type Parser = fn(
328    raw: &str,
329    lower: &str,
330    schema: &Schema,
331    ctx: Option<&ContextConfig>,
332) -> Result<Option<PlanResult>, PlanError>;
333
334const PARSERS: &[Parser] = &[
335    try_rename_model,
336    try_rename_field,
337    try_remove_field,
338    try_change_type,
339    try_change_nullability,
340    // Relation parsers have more specific prefixes than `add `,
341    // so they run before `try_add_field` to avoid swallowing
342    // "add relation from …" as a generic add-field.
343    try_add_relation,
344    try_add_field,
345];
346
347/// 0.8.0 — parse "add relation from <model> to <target>",
348/// "link <model> to <target>", or "connect <model> to <target>".
349/// The owning column name is inferred as `<target_admin_name>_id`
350/// (singularised). Refuses when either model is unknown, when the
351/// field already exists, or when a relation with the same `via`
352/// is already recorded on the model.
353fn try_add_relation(
354    raw: &str,
355    lower: &str,
356    schema: &Schema,
357    _context: Option<&ContextConfig>,
358) -> Result<Option<PlanResult>, PlanError> {
359    // Accept three prefixes. The longest wins — `add relation from`
360    // must be tried before `add `, which belongs to `try_add_field`.
361    let after = if let Some(rest) = lower.strip_prefix("add relation from ") {
362        slice_original(raw, "add relation from ").unwrap_or(rest)
363    } else if let Some(rest) = lower.strip_prefix("link ") {
364        slice_original(raw, "link ").unwrap_or(rest)
365    } else if let Some(rest) = lower.strip_prefix("connect ") {
366        slice_original(raw, "connect ").unwrap_or(rest)
367    } else {
368        return Ok(None);
369    };
370
371    let Some((from_hint, to_hint)) = split_on_keyword(after, &[" to "]) else {
372        return Err(PlanError::InvalidIntent(format!(
373            "relation prompt expects `<model> to <target>`: got {raw:?}"
374        )));
375    };
376    // 0.9.0 — optional trailing tokens let the user override the FK defaults:
377    //   "link A to B required"
378    //   "link A to B on_delete:cascade"
379    //   "link A to B required on_delete:set_null"
380    // Everything after the first whitespace of `to_hint` is options.
381    let (to_hint, required, on_delete) = parse_relation_options(to_hint, raw)?;
382    let from = resolve_model(schema, from_hint)?;
383    let to = resolve_model(schema, to_hint.as_str())?;
384
385    // Singularised admin slug of the target, suffixed with `_id`.
386    // `applicants` → `applicant_id`; `posts` → `post_id`.
387    let via = format!("{}_id", depluralise(&to.admin_name.to_lowercase()));
388
389    // Refuse if the owning model already has this column (avoid
390    // double-FK rows). The executor's idempotency gate catches this
391    // later too, but catching it at plan time gives a clearer error.
392    if from.fields.iter().any(|f| f.name == via) {
393        return Err(PlanError::FieldAlreadyExists {
394            model: from.name.clone(),
395            field: via,
396        });
397    }
398
399    // Refuse core models on either side — the AI boundary already
400    // protects them against schema-shape changes.
401    if from.core {
402        return Err(PlanError::CoreModelProtected(from.name.clone()));
403    }
404    if to.core {
405        return Err(PlanError::CoreModelProtected(to.name.clone()));
406    }
407
408    let primitive = Primitive::AddRelation(super::AddRelation {
409        from: from.name.clone(),
410        kind: crate::schema::RelationKind::BelongsTo,
411        to: to.name.clone(),
412        via: via.clone(),
413        required,
414        on_delete,
415    });
416    validate_primitive(&primitive).map_err(PlanError::Validation)?;
417
418    let nullability = if required { "required" } else { "nullable" };
419    let explanation = format!(
420        "Adds a `belongs_to` relation from `{}` to `{}` via column `{}` \
421         (i64, {nullability}, on_delete:{}). The executor emits a SQL \
422         FOREIGN KEY.",
423        from.name,
424        to.name,
425        via,
426        on_delete.as_str(),
427    );
428    Ok(Some(PlanResult {
429        plan: Plan::new(vec![primitive]),
430        explanation,
431    }))
432}
433
434fn try_add_field(
435    raw: &str,
436    lower: &str,
437    schema: &Schema,
438    context: Option<&ContextConfig>,
439) -> Result<Option<PlanResult>, PlanError> {
440    let Some(rest) = lower.strip_prefix("add ") else {
441        return Ok(None);
442    };
443    // Reject things we already route elsewhere:
444    if rest.starts_with("model ") {
445        return Err(PlanError::InvalidIntent(
446            "`add model …` is not supported yet by the planner (requires spec of every field). \
447             Write the model by hand and the AI layer will read it from the schema."
448                .to_string(),
449        ));
450    }
451    let after = slice_original(raw, "add ").unwrap_or(raw);
452    let Some((left, right)) = split_on_keyword(after, &[" to ", " on "]) else {
453        return Err(PlanError::InvalidIntent(format!(
454            "`add` requires `… to <model>`: got {raw:?}"
455        )));
456    };
457    let model = resolve_model(schema, right)?;
458    if model.core {
459        return Err(PlanError::CoreModelProtected(model.name.clone()));
460    }
461    let (field_name, modifiers) = parse_field_phrase(left);
462    if field_name.is_empty() {
463        return Err(PlanError::InvalidIntent(
464            "missing field name in `add` clause".to_string(),
465        ));
466    }
467    if model.fields.iter().any(|f| f.name == field_name) {
468        return Err(PlanError::FieldAlreadyExists {
469            model: model.name.clone(),
470            field: field_name,
471        });
472    }
473    let (ty, nullable) = infer_field_type(&field_name, &modifiers, context)?;
474    let nullable = nullable || phrase_is_optional(&modifiers);
475
476    let primitive = Primitive::AddField(AddField {
477        model: model.name.clone(),
478        field: FieldSpec {
479            name: field_name.clone(),
480            ty: ty.clone(),
481            nullable,
482            editable: true,
483        },
484    });
485    validate_primitive(&primitive).map_err(PlanError::Validation)?;
486    let explanation = explain_add_field(&model.name, &field_name, &ty, nullable, context);
487    Ok(Some(PlanResult {
488        plan: Plan::new(vec![primitive]),
489        explanation,
490    }))
491}
492
493fn try_rename_field(
494    raw: &str,
495    lower: &str,
496    schema: &Schema,
497    _context: Option<&ContextConfig>,
498) -> Result<Option<PlanResult>, PlanError> {
499    let Some(rest) = lower.strip_prefix("rename ") else {
500        return Ok(None);
501    };
502    // `rename model …` and `rename <X> to <Y>` (no "in <model>") fall through
503    if rest.starts_with("model ") {
504        return Ok(None);
505    }
506    if !rest.contains(" in ") {
507        return Ok(None);
508    }
509    let after = slice_original(raw, "rename ").unwrap_or(raw);
510    let Some((lhs, model_hint)) = split_on_keyword(after, &[" in "]) else {
511        return Ok(None);
512    };
513    let Some((from, to)) = split_on_keyword(lhs, &[" to ", " -> "]) else {
514        return Err(PlanError::InvalidIntent(format!(
515            "`rename <field> to <new> in <model>` expected: got {raw:?}"
516        )));
517    };
518    let model = resolve_model(schema, model_hint)?;
519    if model.core {
520        return Err(PlanError::CoreModelProtected(model.name.clone()));
521    }
522    let from_name = sanitise_identifier(from);
523    let to_name = sanitise_identifier(to);
524    if from_name.is_empty() || to_name.is_empty() {
525        return Err(PlanError::InvalidIntent(
526            "rename clause is missing a name on one side".to_string(),
527        ));
528    }
529    if !model.fields.iter().any(|f| f.name == from_name) {
530        return Err(PlanError::FieldDoesNotExist {
531            model: model.name.clone(),
532            field: from_name,
533        });
534    }
535    if model.fields.iter().any(|f| f.name == to_name) {
536        return Err(PlanError::FieldAlreadyExists {
537            model: model.name.clone(),
538            field: to_name,
539        });
540    }
541    let primitive = Primitive::RenameField(RenameField {
542        model: model.name.clone(),
543        from: from_name.clone(),
544        to: to_name.clone(),
545    });
546    validate_primitive(&primitive).map_err(PlanError::Validation)?;
547    let explanation = format!(
548        "Renames field `{from_name}` to `{to_name}` on model `{model}` \
549         (data-preserving — the underlying column is renamed, not dropped).",
550        model = model.name,
551    );
552    Ok(Some(PlanResult {
553        plan: Plan::new(vec![primitive]),
554        explanation,
555    }))
556}
557
558fn try_rename_model(
559    raw: &str,
560    lower: &str,
561    schema: &Schema,
562    _context: Option<&ContextConfig>,
563) -> Result<Option<PlanResult>, PlanError> {
564    let prefix = if let Some(r) = lower.strip_prefix("rename model ") {
565        r
566    } else {
567        return Ok(None);
568    };
569    let after = slice_original(raw, "rename model ").unwrap_or(prefix);
570    let Some((from, to)) = split_on_keyword(after, &[" to ", " -> "]) else {
571        return Err(PlanError::InvalidIntent(format!(
572            "`rename model <from> to <to>` expected: got {raw:?}"
573        )));
574    };
575    let model = resolve_model(schema, from)?;
576    if model.core {
577        return Err(PlanError::CoreModelProtected(model.name.clone()));
578    }
579    let to_name = pascalise(to.trim());
580    if to_name.is_empty() {
581        return Err(PlanError::InvalidIntent(
582            "new model name is empty".to_string(),
583        ));
584    }
585    if schema.models.iter().any(|m| m.name == to_name) {
586        return Err(PlanError::InvalidIntent(format!(
587            "a model named `{to_name}` already exists"
588        )));
589    }
590    let from_name = model.name.clone();
591    let primitive = Primitive::RenameModel(RenameModel {
592        from: from_name.clone(),
593        to: to_name.clone(),
594    });
595    validate_primitive(&primitive).map_err(PlanError::Validation)?;
596    let explanation = format!(
597        "Renames model `{from_name}` to `{to_name}`. Table is renamed in \
598         place — existing rows are preserved."
599    );
600    Ok(Some(PlanResult {
601        plan: Plan::new(vec![primitive]),
602        explanation,
603    }))
604}
605
606fn try_remove_field(
607    raw: &str,
608    lower: &str,
609    schema: &Schema,
610    _context: Option<&ContextConfig>,
611) -> Result<Option<PlanResult>, PlanError> {
612    let (prefix, original_prefix) = if lower.starts_with("remove ") {
613        ("remove ", "remove ")
614    } else if lower.starts_with("drop ") {
615        ("drop ", "drop ")
616    } else if lower.starts_with("delete ") {
617        ("delete ", "delete ")
618    } else {
619        return Ok(None);
620    };
621    // `remove model …` is never AI-safe in 0.5.0 — refuse loudly.
622    let body = &lower[prefix.len()..];
623    if body.starts_with("model ") {
624        return Err(PlanError::DeveloperOnlyRequested("remove_model"));
625    }
626    let after = slice_original(raw, original_prefix).unwrap_or(raw);
627    let Some((field_phrase, model_hint)) = split_on_keyword(after, &[" from ", " on "]) else {
628        return Err(PlanError::InvalidIntent(format!(
629            "`{prefix}<field> from <model>` expected: got {raw:?}",
630            prefix = prefix.trim_end(),
631        )));
632    };
633    let model = resolve_model(schema, model_hint)?;
634    if model.core {
635        return Err(PlanError::CoreModelProtected(model.name.clone()));
636    }
637    let (field_name, _) = parse_field_phrase(field_phrase);
638    if !model.fields.iter().any(|f| f.name == field_name) {
639        return Err(PlanError::FieldDoesNotExist {
640            model: model.name.clone(),
641            field: field_name,
642        });
643    }
644    let primitive = Primitive::RemoveField(RemoveField {
645        model: model.name.clone(),
646        field: field_name.clone(),
647    });
648    validate_primitive(&primitive).map_err(PlanError::Validation)?;
649    let explanation = format!(
650        "Removes field `{field_name}` from model `{model}`. The underlying column \
651         is dropped; review data before applying.",
652        model = model.name,
653    );
654    Ok(Some(PlanResult {
655        plan: Plan::new(vec![primitive]),
656        explanation,
657    }))
658}
659
660fn try_change_type(
661    raw: &str,
662    lower: &str,
663    schema: &Schema,
664    _context: Option<&ContextConfig>,
665) -> Result<Option<PlanResult>, PlanError> {
666    let Some(rest) = lower.strip_prefix("change ") else {
667        return Ok(None);
668    };
669    let after = slice_original(raw, "change ").unwrap_or(rest);
670    // "change <field> in <model> to <type>"
671    let Some((lhs, new_type_hint)) = split_on_keyword(after, &[" to "]) else {
672        return Ok(None);
673    };
674    let Some((field_phrase, model_hint)) = split_on_keyword(lhs, &[" in ", " on "]) else {
675        return Ok(None);
676    };
677    let model = resolve_model(schema, model_hint)?;
678    if model.core {
679        return Err(PlanError::CoreModelProtected(model.name.clone()));
680    }
681    let (field_name, _) = parse_field_phrase(field_phrase);
682    if !model.fields.iter().any(|f| f.name == field_name) {
683        return Err(PlanError::FieldDoesNotExist {
684            model: model.name.clone(),
685            field: field_name,
686        });
687    }
688    let ty = normalise_type_hint(new_type_hint.trim())?;
689    let primitive = Primitive::ChangeFieldType(ChangeFieldType {
690        model: model.name.clone(),
691        field: field_name.clone(),
692        new_type: ty.clone(),
693    });
694    validate_primitive(&primitive).map_err(PlanError::Validation)?;
695    let explanation = format!(
696        "Changes type of `{model}.{field_name}` to `{ty}`. The executor (0.5.x) \
697         will refuse lossy conversions at apply time.",
698        model = model.name,
699    );
700    Ok(Some(PlanResult {
701        plan: Plan::new(vec![primitive]),
702        explanation,
703    }))
704}
705
706fn try_change_nullability(
707    raw: &str,
708    lower: &str,
709    schema: &Schema,
710    _context: Option<&ContextConfig>,
711) -> Result<Option<PlanResult>, PlanError> {
712    let Some(rest) = lower.strip_prefix("make ") else {
713        return Ok(None);
714    };
715    let after = slice_original(raw, "make ").unwrap_or(rest);
716    let Some((field_phrase, rest_phrase)) = split_on_keyword(after, &[" in ", " on "]) else {
717        return Ok(None);
718    };
719    let rest_lower = rest_phrase.to_lowercase();
720    let mut nullable_hint: Option<bool> = None;
721    for (needle, target) in [
722        (" optional", true),
723        (" nullable", true),
724        (" required", false),
725        (" not null", false),
726        (" non-null", false),
727    ] {
728        if rest_lower.contains(needle) {
729            nullable_hint = Some(target);
730            break;
731        }
732    }
733    // Trailing word like "optional" with no " in " — skip, falls back
734    // elsewhere. We only proceed when we saw the directive.
735    let Some(nullable) = nullable_hint else {
736        return Ok(None);
737    };
738    let model_hint = strip_known_modifiers(rest_phrase);
739    let model = resolve_model(schema, &model_hint)?;
740    if model.core {
741        return Err(PlanError::CoreModelProtected(model.name.clone()));
742    }
743    let (field_name, _) = parse_field_phrase(field_phrase);
744    if !model.fields.iter().any(|f| f.name == field_name) {
745        return Err(PlanError::FieldDoesNotExist {
746            model: model.name.clone(),
747            field: field_name,
748        });
749    }
750    let primitive = Primitive::ChangeFieldNullability(ChangeFieldNullability {
751        model: model.name.clone(),
752        field: field_name.clone(),
753        nullable,
754    });
755    validate_primitive(&primitive).map_err(PlanError::Validation)?;
756    let explanation = format!(
757        "Marks `{model}.{field_name}` as {state}.",
758        model = model.name,
759        state = if nullable { "nullable" } else { "required" },
760    );
761    Ok(Some(PlanResult {
762        plan: Plan::new(vec![primitive]),
763        explanation,
764    }))
765}
766
767// ---------------------------------------------------------------------------
768// Helpers
769// ---------------------------------------------------------------------------
770
771/// Case-insensitive model lookup. Matches against every user-visible
772/// identifier on the model (`name`, `table`, `admin_name`,
773/// `singular_name`) plus a tiny pluralise/depluralise pair so users
774/// can say "tasks" or "task" interchangeably.
775fn resolve_model<'a>(schema: &'a Schema, hint: &str) -> Result<&'a SchemaModel, PlanError> {
776    let h = sanitise_identifier(hint).to_lowercase();
777    if h.is_empty() {
778        return Err(PlanError::UnknownModel {
779            hint: hint.trim().to_string(),
780        });
781    }
782    let h_singular = depluralise(&h);
783    let h_plural = pluralise(&h);
784    let mut matches: Vec<&SchemaModel> = schema
785        .models
786        .iter()
787        .filter(|m| {
788            let forms = [
789                m.name.to_lowercase(),
790                m.table.to_lowercase(),
791                m.admin_name.to_lowercase(),
792                m.singular_name.to_lowercase(),
793            ];
794            forms
795                .iter()
796                .any(|f| f == &h || f == &h_singular || f == &h_plural)
797        })
798        .collect();
799    // Deduplicate in case the same model matched multiple aliases.
800    matches.dedup_by(|a, b| a.name == b.name);
801    match matches.len() {
802        0 => Err(PlanError::UnknownModel {
803            hint: hint.trim().to_string(),
804        }),
805        1 => Ok(matches[0]),
806        _ => Err(PlanError::AmbiguousModel {
807            hint: hint.trim().to_string(),
808            candidates: matches.iter().map(|m| m.name.clone()).collect(),
809        }),
810    }
811}
812
813/// Extract a field name from a phrase like "a priority", "due date",
814/// or "optional phone (as String)". Returns `(field_name, remainder)`
815/// where the remainder is the original phrase — the caller can inspect
816/// it for modifier words like "optional" or a trailing `as <type>`.
817fn parse_field_phrase(phrase: &str) -> (String, String) {
818    let remainder = phrase.to_string();
819    let stripped = strip_known_modifiers(phrase);
820    let no_as = match split_on_keyword(&stripped, &[" as ", ":"]) {
821        Some((left, _)) => left.to_string(),
822        None => stripped,
823    };
824    // "due date" → "due_date"; reject non-ident chars.
825    let name = no_as
826        .split_whitespace()
827        .map(|w| {
828            w.trim_matches(|c: char| !c.is_alphanumeric() && c != '_')
829                .to_lowercase()
830        })
831        .filter(|w| !w.is_empty())
832        .collect::<Vec<_>>()
833        .join("_");
834    (name, remainder)
835}
836
837/// Drop articles and common modifier words that can't be part of an
838/// identifier.
839fn strip_known_modifiers(phrase: &str) -> String {
840    let tokens = phrase.split_whitespace().filter(|t| {
841        !matches!(
842            t.to_lowercase().as_str(),
843            "a" | "an"
844                | "the"
845                | "optional"
846                | "nullable"
847                | "required"
848                | "new"
849                | "field"
850                | "column"
851                | "to"
852                | "add"
853        )
854    });
855    tokens.collect::<Vec<_>>().join(" ")
856}
857
858fn phrase_is_optional(phrase: &str) -> bool {
859    let l = phrase.to_lowercase();
860    l.split_whitespace()
861        .any(|w| w == "optional" || w == "nullable")
862}
863
864/// Infer `(type, nullable)` from a field name + surrounding phrase.
865///
866/// Resolution order:
867///   1. Explicit `as <type>` in the phrase wins.
868///   2. Context-aware names (Swedish personnummer under country=SE).
869///   3. Heuristics from the identifier (prefix/suffix).
870///   4. Fallback: `String`, non-nullable.
871fn infer_field_type(
872    name: &str,
873    phrase: &str,
874    context: Option<&ContextConfig>,
875) -> Result<(String, bool), PlanError> {
876    let lower = phrase.to_lowercase();
877    // 1. Explicit type
878    if let Some((_, after)) = split_on_keyword(phrase, &[" as ", ":"]) {
879        let ty = normalise_type_hint(after.trim_end_matches('.').trim())?;
880        return Ok((ty, phrase_is_optional(phrase)));
881    }
882    // Words like "datetime" or "number" anywhere in the phrase.
883    for (needle, mapped) in [
884        ("datetime", "DateTime"),
885        ("timestamp", "DateTime"),
886        ("boolean", "bool"),
887        ("integer", "i32"),
888        ("number", "i32"),
889        ("string", "String"),
890        ("text", "String"),
891    ] {
892        if lower.split_whitespace().any(|w| w == needle) {
893            return Ok((mapped.to_string(), phrase_is_optional(phrase)));
894        }
895    }
896
897    // 2. Context — country and industry rules. The earlier an arm
898    // matches, the higher its priority. Order: country → industry →
899    // fallbacks.
900    if let Some(ctx) = context {
901        let n = name.to_lowercase();
902        // Country rules.
903        if matches!(ctx.country.as_deref(), Some(cc) if cc.eq_ignore_ascii_case("SE"))
904            && (n == "personnummer" || n == "personal_number" || n == "personal_id" || n == "pnr")
905        {
906            return Ok(("String".to_string(), false));
907        }
908        if matches!(ctx.country.as_deref(), Some(cc) if cc.eq_ignore_ascii_case("NO"))
909            && (n == "fodselsnummer" || n == "personal_number" || n == "personal_id")
910        {
911            return Ok(("String".to_string(), false));
912        }
913        // Industry rules.
914        match ctx.industry.as_deref() {
915            Some(i) if i.eq_ignore_ascii_case("healthcare") => {
916                // Patient IDs must be opaque strings — sequential
917                // integers leak enrolment order and are refused by
918                // the planner under this industry.
919                if n == "patient_id"
920                    || n == "patient"
921                    || n.ends_with("_patient_id")
922                    || n == "medical_record_number"
923                    || n == "mrn"
924                {
925                    return Ok(("String".to_string(), false));
926                }
927            }
928            Some(i) if i.eq_ignore_ascii_case("banking") => {
929                // Account numbers must be String (international formats
930                // overflow i32). Monetary amounts are stored as i64
931                // minor units.
932                if n == "account_number" || n == "iban" || n == "bic" {
933                    return Ok(("String".to_string(), false));
934                }
935                if n == "balance"
936                    || n == "amount"
937                    || n.ends_with("_amount")
938                    || n.ends_with("_balance")
939                {
940                    return Ok(("i64".to_string(), phrase_is_optional(phrase)));
941                }
942            }
943            _ => {}
944        }
945    }
946
947    // 3. Identifier heuristics
948    let n = name.to_lowercase();
949    let nullable = phrase_is_optional(phrase);
950    if n.ends_with("_at")
951        || n.ends_with("_on")
952        || n.ends_with("_date")
953        || n == "created_at"
954        || n == "updated_at"
955        || n == "deleted_at"
956        || n.ends_with("_time")
957        || n == "timestamp"
958    {
959        return Ok(("DateTime".to_string(), nullable));
960    }
961    if n.starts_with("is_")
962        || n.starts_with("has_")
963        || n == "active"
964        || n == "enabled"
965        || n == "archived"
966    {
967        return Ok(("bool".to_string(), nullable));
968    }
969    if n == "priority"
970        || n == "count"
971        || n == "score"
972        || n == "rank"
973        || n == "quantity"
974        || n == "age"
975        || n.ends_with("_count")
976        || n.ends_with("_id")
977    {
978        return Ok(("i32".to_string(), nullable));
979    }
980    // Monetary names resolve to `i64` — we store amounts in minor
981    // units (öre / cents) where `i32` can overflow for anything
982    // above ~21 million units. This rule runs whether or not the
983    // banking industry context is active; the banking context arm
984    // already short-circuits for balance/amount above, so this is
985    // the generic fallback for `annual_income`, `total_price`, etc.
986    if n == "price"
987        || n == "balance"
988        || n == "amount"
989        || n.ends_with("_income")
990        || n.ends_with("_amount")
991        || n.ends_with("_total")
992        || n.ends_with("_price")
993    {
994        return Ok(("i64".to_string(), nullable));
995    }
996    // Fallback
997    Ok(("String".to_string(), nullable))
998}
999
1000/// Map a user-facing type word onto one of [`VALID_TYPE_NAMES`].
1001fn normalise_type_hint(raw: &str) -> Result<String, PlanError> {
1002    let r = raw.trim().trim_matches('`').to_lowercase();
1003    let r = r.trim_start_matches("type ").trim();
1004    match r {
1005        "i32" | "int" | "integer" | "number" | "int32" => Ok("i32".to_string()),
1006        "i64" | "long" | "bigint" | "int64" => Ok("i64".to_string()),
1007        "string" | "text" | "str" | "varchar" => Ok("String".to_string()),
1008        "bool" | "boolean" | "flag" => Ok("bool".to_string()),
1009        "datetime" | "timestamp" | "date" | "time" | "datetime<utc>" => Ok("DateTime".to_string()),
1010        _ => Err(PlanError::UnknownType(raw.to_string())),
1011    }
1012}
1013
1014/// Find the *original-case* substring after `prefix` so we preserve
1015/// user casing on identifiers. Works because `prefix` was matched
1016/// case-insensitively and starts at byte 0 of `raw`.
1017fn slice_original<'a>(raw: &'a str, prefix_lower: &str) -> Option<&'a str> {
1018    if raw.len() < prefix_lower.len() {
1019        return None;
1020    }
1021    let head = &raw[..prefix_lower.len()];
1022    if head.to_lowercase() != prefix_lower {
1023        return None;
1024    }
1025    Some(&raw[prefix_lower.len()..])
1026}
1027
1028/// 0.9.0 — parse the trailing option tokens after the target model in a
1029/// relation phrase. Accepts any whitespace-separated combination of:
1030///
1031///   - `required`    — non-nullable FK (error surfaces at executor / retrofit)
1032///   - `optional`    — explicit nullable, same as omitting (here for clarity)
1033///   - `on_delete:<restrict|cascade|set_null>`
1034///
1035/// Returns (cleaned_target, required, on_delete). Unknown tokens fail
1036/// the parse — silent ignoring would be exactly the "close enough"
1037/// behaviour the AI-boundary rule forbids.
1038fn parse_relation_options(
1039    to_hint: &str,
1040    original_prompt: &str,
1041) -> Result<(String, bool, super::OnDelete), PlanError> {
1042    let mut parts = to_hint.split_whitespace();
1043    let target = parts.next().ok_or_else(|| {
1044        PlanError::InvalidIntent(format!(
1045            "relation prompt missing a target model: {original_prompt:?}"
1046        ))
1047    })?;
1048
1049    let mut required = false;
1050    let mut on_delete = super::OnDelete::Restrict;
1051    for token in parts {
1052        match token.to_ascii_lowercase().as_str() {
1053            "required" => required = true,
1054            "optional" => required = false,
1055            t if t.starts_with("on_delete:") => {
1056                on_delete = match &t["on_delete:".len()..] {
1057                    "restrict" => super::OnDelete::Restrict,
1058                    "cascade" => super::OnDelete::Cascade,
1059                    "set_null" | "setnull" => super::OnDelete::SetNull,
1060                    other => {
1061                        return Err(PlanError::InvalidIntent(format!(
1062                            "unknown on_delete policy `{other}` (want restrict|cascade|set_null): {original_prompt:?}"
1063                        )));
1064                    }
1065                };
1066            }
1067            other => {
1068                return Err(PlanError::InvalidIntent(format!(
1069                    "unknown relation option `{other}` (want required|optional|on_delete:<policy>): {original_prompt:?}"
1070                )));
1071            }
1072        }
1073    }
1074
1075    Ok((target.to_string(), required, on_delete))
1076}
1077
1078/// Case-insensitive split on the first occurrence of any of the given
1079/// keywords. Returns the left/right halves in the **original** casing.
1080fn split_on_keyword<'a>(raw: &'a str, keywords: &[&str]) -> Option<(&'a str, &'a str)> {
1081    let lower = raw.to_lowercase();
1082    let mut best: Option<(usize, usize)> = None;
1083    for kw in keywords {
1084        if let Some(idx) = lower.find(kw) {
1085            match best {
1086                Some((best_idx, _)) if best_idx <= idx => {}
1087                _ => best = Some((idx, kw.len())),
1088            }
1089        }
1090    }
1091    let (idx, kw_len) = best?;
1092    let left = raw[..idx].trim();
1093    let right = raw[idx + kw_len..].trim();
1094    Some((left, right))
1095}
1096
1097fn sanitise_identifier(raw: &str) -> String {
1098    raw.trim()
1099        .trim_matches(|c: char| c == '`' || c == '"' || c == '\'' || c == '.' || c == ',')
1100        .to_string()
1101}
1102
1103fn pluralise(name: &str) -> String {
1104    if name.ends_with('s') {
1105        return name.to_string();
1106    }
1107    if name.ends_with('y') && name.len() > 1 {
1108        let mut out = String::from(&name[..name.len() - 1]);
1109        out.push_str("ies");
1110        return out;
1111    }
1112    format!("{name}s")
1113}
1114
1115fn depluralise(name: &str) -> String {
1116    if let Some(stripped) = name.strip_suffix("ies") {
1117        let mut out = String::from(stripped);
1118        out.push('y');
1119        return out;
1120    }
1121    if let Some(stripped) = name.strip_suffix('s') {
1122        return stripped.to_string();
1123    }
1124    name.to_string()
1125}
1126
1127/// Convert a model hint to PascalCase, e.g. "invoice_line" → `InvoiceLine`.
1128fn pascalise(raw: &str) -> String {
1129    let mut out = String::new();
1130    let mut next_upper = true;
1131    for ch in raw.chars() {
1132        if ch == '_' || ch == '-' || ch.is_whitespace() {
1133            next_upper = true;
1134            continue;
1135        }
1136        if !ch.is_alphanumeric() {
1137            continue;
1138        }
1139        if next_upper {
1140            out.extend(ch.to_uppercase());
1141            next_upper = false;
1142        } else {
1143            out.extend(ch.to_lowercase());
1144        }
1145    }
1146    out
1147}
1148
1149fn explain_add_field(
1150    model: &str,
1151    field: &str,
1152    ty: &str,
1153    nullable: bool,
1154    context: Option<&ContextConfig>,
1155) -> String {
1156    let opt = if nullable { ", nullable" } else { "" };
1157    let head = format!("Adds field `{field}` ({ty}{opt}) to model `{model}`.");
1158    let rationale = match (ty, field) {
1159        ("DateTime", _) => {
1160            " Stored as ISO-8601 UTC; the admin renders it as a datetime-local input."
1161        }
1162        ("bool", _) => " Rendered as a checkbox in the admin and a pill on list pages.",
1163        ("i32", f) if f == "priority" || f == "score" || f == "rank" => {
1164            " Useful for sorting and filtering records by importance."
1165        }
1166        ("i32", _) => " Numeric — the list view shows it with tabular numerics.",
1167        ("String", "status") => " Status values get coloured pills in list views.",
1168        _ => "",
1169    };
1170    let mut tail = String::new();
1171    if let Some(ctx) = context {
1172        // Country-specific annotations. Matches any of the SE personal-
1173        // id aliases the planner maps to `String` so the explanation
1174        // lines up with what was actually inferred.
1175        if matches!(ctx.country.as_deref(), Some(cc) if cc.eq_ignore_ascii_case("SE"))
1176            && matches!(
1177                field,
1178                "personnummer" | "personal_id" | "personal_number" | "pnr"
1179            )
1180        {
1181            tail.push_str(
1182                " Swedish personnummer is stored as a 13-character string (YYYYMMDD-XXXX).",
1183            );
1184        }
1185        // Industry-specific annotations.
1186        match ctx.industry.as_deref() {
1187            Some(i)
1188                if i.eq_ignore_ascii_case("healthcare")
1189                    && (field == "patient_id"
1190                        || field == "mrn"
1191                        || field == "medical_record_number") =>
1192            {
1193                tail.push_str(
1194                    " Patient identifiers are opaque strings (UUID or hash); sequential integers would leak enrolment order.",
1195                );
1196            }
1197            Some(i)
1198                if i.eq_ignore_ascii_case("banking")
1199                    && (field == "balance" || field == "amount" || field.ends_with("_amount")) =>
1200            {
1201                tail.push_str(
1202                    " Monetary values are stored as integer minor units (öre, cents). Never use floats.",
1203                );
1204            }
1205            _ => {}
1206        }
1207        // GDPR guardrail.
1208        if ctx.requires_gdpr() && is_generic_pii_field(field) {
1209            tail.push_str(
1210                " Under GDPR this field is personal data — retention and right-to-erasure rules apply.",
1211            );
1212        }
1213    }
1214    format!("{head}{rationale}{tail}")
1215}
1216
1217fn is_generic_pii_field(name: &str) -> bool {
1218    matches!(
1219        name,
1220        "email" | "phone" | "address" | "date_of_birth" | "ssn" | "personnummer" | "fodselsnummer"
1221    )
1222}
1223
1224fn supported_forms_message(raw: &str) -> String {
1225    format!(
1226        "could not interpret prompt {raw:?}. Supported forms:\n  \
1227         - add <field> to <model>\n  \
1228         - add <field> as <type> to <model>\n  \
1229         - add optional <field> to <model>\n  \
1230         - rename <field> to <new> in <model>\n  \
1231         - rename model <from> to <to>\n  \
1232         - remove <field> from <model>\n  \
1233         - change <field> in <model> to <type>\n  \
1234         - make <field> in <model> optional|required"
1235    )
1236}
1237
1238// ---------------------------------------------------------------------------
1239// CLI-facing JSON rendering
1240// ---------------------------------------------------------------------------
1241
1242/// Render a plan as the strict JSON shape documented for
1243/// `rustio ai plan`: `[{ "op": "AddField", "model": "Task", "field":
1244/// "priority", "type": "i32", "nullable": false }, …]`.
1245///
1246/// This is **not** the internal `Plan` serde shape (which is tagged
1247/// `op` = snake_case and uses `name` for the field); the CLI wants a
1248/// PascalCase, flat-field shape that's explicitly stable across the
1249/// planner vocabulary. Keeping the renderer here means the internal
1250/// representation can evolve without breaking the documented output.
1251pub fn render_plan_json(plan: &Plan, explanation: &str) -> String {
1252    let steps: Vec<serde_json::Value> = plan.steps.iter().map(primitive_to_cli_json).collect();
1253    let out = serde_json::json!({
1254        "plan": steps,
1255        "explanation": explanation,
1256    });
1257    serde_json::to_string_pretty(&out).unwrap_or_else(|_| "{}".to_string())
1258}
1259
1260/// Render a plan as a compact, Django-ish "Plan: …" summary for terminal
1261/// display alongside the JSON.
1262pub fn render_plan_human(plan: &Plan, explanation: &str) -> String {
1263    let mut out = String::from("Plan:\n");
1264    if plan.steps.is_empty() {
1265        out.push_str("  (no changes)\n");
1266    }
1267    for step in &plan.steps {
1268        out.push_str("  - ");
1269        out.push_str(&summarise_primitive(step));
1270        out.push('\n');
1271    }
1272    out.push_str("\nExplanation:\n");
1273    out.push_str(explanation);
1274    if !explanation.ends_with('\n') {
1275        out.push('\n');
1276    }
1277    out
1278}
1279
1280fn primitive_to_cli_json(p: &Primitive) -> serde_json::Value {
1281    use serde_json::json;
1282    match p {
1283        Primitive::AddField(a) => json!({
1284            "op": "AddField",
1285            "model": a.model,
1286            "field": a.field.name,
1287            "type": a.field.ty,
1288            "nullable": a.field.nullable,
1289        }),
1290        Primitive::RemoveField(r) => json!({
1291            "op": "RemoveField",
1292            "model": r.model,
1293            "field": r.field,
1294        }),
1295        Primitive::RenameField(r) => json!({
1296            "op": "RenameField",
1297            "model": r.model,
1298            "from": r.from,
1299            "to": r.to,
1300        }),
1301        Primitive::RenameModel(r) => json!({
1302            "op": "RenameModel",
1303            "from": r.from,
1304            "to": r.to,
1305        }),
1306        Primitive::ChangeFieldType(c) => json!({
1307            "op": "ChangeFieldType",
1308            "model": c.model,
1309            "field": c.field,
1310            "type": c.new_type,
1311        }),
1312        Primitive::ChangeFieldNullability(c) => json!({
1313            "op": "ChangeFieldNullability",
1314            "model": c.model,
1315            "field": c.field,
1316            "nullable": c.nullable,
1317        }),
1318        Primitive::AddModel(m) => json!({
1319            "op": "AddModel",
1320            "name": m.name,
1321            "table": m.table,
1322            "fields": m.fields.iter().map(|f| json!({
1323                "name": f.name,
1324                "type": f.ty,
1325                "nullable": f.nullable,
1326            })).collect::<Vec<_>>(),
1327        }),
1328        Primitive::RemoveModel(m) => json!({
1329            "op": "RemoveModel",
1330            "name": m.name,
1331        }),
1332        Primitive::AddRelation(r) => json!({
1333            "op": "AddRelation",
1334            "from": r.from,
1335            "kind": format!("{:?}", r.kind).to_lowercase(),
1336            "to": r.to,
1337            "via": r.via,
1338        }),
1339        Primitive::RemoveRelation(r) => json!({
1340            "op": "RemoveRelation",
1341            "from": r.from,
1342            "via": r.via,
1343        }),
1344        Primitive::UpdateAdmin(u) => json!({
1345            "op": "UpdateAdmin",
1346            "model": u.model,
1347            "field": u.field,
1348            "attr": u.attr,
1349            "value": u.value,
1350        }),
1351        Primitive::CreateMigration(_) => {
1352            // Developer-only; Plan::validate rejects it before we get
1353            // here, but render the shape for symmetry.
1354            json!({"op": "CreateMigration", "note": "developer-only"})
1355        }
1356    }
1357}
1358
1359fn summarise_primitive(p: &Primitive) -> String {
1360    match p {
1361        Primitive::AddField(a) => format!(
1362            "Add field \"{}\" ({}{}) to model \"{}\"",
1363            a.field.name,
1364            a.field.ty,
1365            if a.field.nullable { ", nullable" } else { "" },
1366            a.model,
1367        ),
1368        Primitive::RemoveField(r) => {
1369            format!("Remove field \"{}\" from model \"{}\"", r.field, r.model)
1370        }
1371        Primitive::RenameField(r) => {
1372            format!("Rename field \"{}.{}\" to \"{}\"", r.model, r.from, r.to)
1373        }
1374        Primitive::RenameModel(r) => {
1375            format!("Rename model \"{}\" to \"{}\"", r.from, r.to)
1376        }
1377        Primitive::ChangeFieldType(c) => format!(
1378            "Change type of \"{}.{}\" to {}",
1379            c.model, c.field, c.new_type
1380        ),
1381        Primitive::ChangeFieldNullability(c) => format!(
1382            "Mark \"{}.{}\" as {}",
1383            c.model,
1384            c.field,
1385            if c.nullable { "nullable" } else { "required" },
1386        ),
1387        Primitive::AddModel(m) => format!(
1388            "Add model \"{}\" with {} field{}",
1389            m.name,
1390            m.fields.len(),
1391            if m.fields.len() == 1 { "" } else { "s" }
1392        ),
1393        Primitive::RemoveModel(m) => format!("Remove model \"{}\"", m.name),
1394        Primitive::AddRelation(r) => {
1395            format!("Add relation {:?}: {}.{} → {}", r.kind, r.from, r.via, r.to)
1396        }
1397        Primitive::RemoveRelation(r) => format!("Remove relation {}.{}", r.from, r.via),
1398        Primitive::UpdateAdmin(u) => {
1399            format!("Update admin attr \"{}.{}\".{}", u.model, u.field, u.attr)
1400        }
1401        Primitive::CreateMigration(m) => format!("[dev-only] create_migration \"{}\"", m.name),
1402    }
1403}