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        //
915        // Patient IDs (healthcare) must be opaque strings — sequential
916        // integers leak enrolment order. Account numbers (banking) must
917        // be String (international formats overflow i32). Monetary
918        // amounts (banking) are stored as i64 minor units.
919        let industry = ctx.industry.as_deref();
920        let is_healthcare = industry.is_some_and(|i| i.eq_ignore_ascii_case("healthcare"));
921        let is_banking = industry.is_some_and(|i| i.eq_ignore_ascii_case("banking"));
922        if is_healthcare
923            && (n == "patient_id"
924                || n == "patient"
925                || n.ends_with("_patient_id")
926                || n == "medical_record_number"
927                || n == "mrn")
928        {
929            return Ok(("String".to_string(), false));
930        }
931        if is_banking && (n == "account_number" || n == "iban" || n == "bic") {
932            return Ok(("String".to_string(), false));
933        }
934        if is_banking
935            && (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    // 3. Identifier heuristics
945    let n = name.to_lowercase();
946    let nullable = phrase_is_optional(phrase);
947    if n.ends_with("_at")
948        || n.ends_with("_on")
949        || n.ends_with("_date")
950        || n == "created_at"
951        || n == "updated_at"
952        || n == "deleted_at"
953        || n.ends_with("_time")
954        || n == "timestamp"
955    {
956        return Ok(("DateTime".to_string(), nullable));
957    }
958    if n.starts_with("is_")
959        || n.starts_with("has_")
960        || n == "active"
961        || n == "enabled"
962        || n == "archived"
963    {
964        return Ok(("bool".to_string(), nullable));
965    }
966    if n == "priority"
967        || n == "count"
968        || n == "score"
969        || n == "rank"
970        || n == "quantity"
971        || n == "age"
972        || n.ends_with("_count")
973        || n.ends_with("_id")
974    {
975        return Ok(("i32".to_string(), nullable));
976    }
977    // Monetary names resolve to `i64` — we store amounts in minor
978    // units (öre / cents) where `i32` can overflow for anything
979    // above ~21 million units. This rule runs whether or not the
980    // banking industry context is active; the banking context arm
981    // already short-circuits for balance/amount above, so this is
982    // the generic fallback for `annual_income`, `total_price`, etc.
983    if n == "price"
984        || n == "balance"
985        || n == "amount"
986        || n.ends_with("_income")
987        || n.ends_with("_amount")
988        || n.ends_with("_total")
989        || n.ends_with("_price")
990    {
991        return Ok(("i64".to_string(), nullable));
992    }
993    // Fallback
994    Ok(("String".to_string(), nullable))
995}
996
997/// Map a user-facing type word onto one of [`VALID_TYPE_NAMES`].
998fn normalise_type_hint(raw: &str) -> Result<String, PlanError> {
999    let r = raw.trim().trim_matches('`').to_lowercase();
1000    let r = r.trim_start_matches("type ").trim();
1001    match r {
1002        "i32" | "int" | "integer" | "number" | "int32" => Ok("i32".to_string()),
1003        "i64" | "long" | "bigint" | "int64" => Ok("i64".to_string()),
1004        "string" | "text" | "str" | "varchar" => Ok("String".to_string()),
1005        "bool" | "boolean" | "flag" => Ok("bool".to_string()),
1006        "datetime" | "timestamp" | "date" | "time" | "datetime<utc>" => Ok("DateTime".to_string()),
1007        _ => Err(PlanError::UnknownType(raw.to_string())),
1008    }
1009}
1010
1011/// Find the *original-case* substring after `prefix` so we preserve
1012/// user casing on identifiers. Works because `prefix` was matched
1013/// case-insensitively and starts at byte 0 of `raw`.
1014fn slice_original<'a>(raw: &'a str, prefix_lower: &str) -> Option<&'a str> {
1015    if raw.len() < prefix_lower.len() {
1016        return None;
1017    }
1018    let head = &raw[..prefix_lower.len()];
1019    if head.to_lowercase() != prefix_lower {
1020        return None;
1021    }
1022    Some(&raw[prefix_lower.len()..])
1023}
1024
1025/// 0.9.0 — parse the trailing option tokens after the target model in a
1026/// relation phrase. Accepts any whitespace-separated combination of:
1027///
1028///   - `required`    — non-nullable FK (error surfaces at executor / retrofit)
1029///   - `optional`    — explicit nullable, same as omitting (here for clarity)
1030///   - `on_delete:<restrict|cascade|set_null>`
1031///
1032/// Returns (cleaned_target, required, on_delete). Unknown tokens fail
1033/// the parse — silent ignoring would be exactly the "close enough"
1034/// behaviour the AI-boundary rule forbids.
1035fn parse_relation_options(
1036    to_hint: &str,
1037    original_prompt: &str,
1038) -> Result<(String, bool, super::OnDelete), PlanError> {
1039    let mut parts = to_hint.split_whitespace();
1040    let target = parts.next().ok_or_else(|| {
1041        PlanError::InvalidIntent(format!(
1042            "relation prompt missing a target model: {original_prompt:?}"
1043        ))
1044    })?;
1045
1046    let mut required = false;
1047    let mut on_delete = super::OnDelete::Restrict;
1048    for token in parts {
1049        match token.to_ascii_lowercase().as_str() {
1050            "required" => required = true,
1051            "optional" => required = false,
1052            t if t.starts_with("on_delete:") => {
1053                on_delete = match &t["on_delete:".len()..] {
1054                    "restrict" => super::OnDelete::Restrict,
1055                    "cascade" => super::OnDelete::Cascade,
1056                    "set_null" | "setnull" => super::OnDelete::SetNull,
1057                    other => {
1058                        return Err(PlanError::InvalidIntent(format!(
1059                            "unknown on_delete policy `{other}` (want restrict|cascade|set_null): {original_prompt:?}"
1060                        )));
1061                    }
1062                };
1063            }
1064            other => {
1065                return Err(PlanError::InvalidIntent(format!(
1066                    "unknown relation option `{other}` (want required|optional|on_delete:<policy>): {original_prompt:?}"
1067                )));
1068            }
1069        }
1070    }
1071
1072    Ok((target.to_string(), required, on_delete))
1073}
1074
1075/// Case-insensitive split on the first occurrence of any of the given
1076/// keywords. Returns the left/right halves in the **original** casing.
1077fn split_on_keyword<'a>(raw: &'a str, keywords: &[&str]) -> Option<(&'a str, &'a str)> {
1078    let lower = raw.to_lowercase();
1079    let mut best: Option<(usize, usize)> = None;
1080    for kw in keywords {
1081        if let Some(idx) = lower.find(kw) {
1082            match best {
1083                Some((best_idx, _)) if best_idx <= idx => {}
1084                _ => best = Some((idx, kw.len())),
1085            }
1086        }
1087    }
1088    let (idx, kw_len) = best?;
1089    let left = raw[..idx].trim();
1090    let right = raw[idx + kw_len..].trim();
1091    Some((left, right))
1092}
1093
1094fn sanitise_identifier(raw: &str) -> String {
1095    raw.trim()
1096        .trim_matches(|c: char| c == '`' || c == '"' || c == '\'' || c == '.' || c == ',')
1097        .to_string()
1098}
1099
1100fn pluralise(name: &str) -> String {
1101    if name.ends_with('s') {
1102        return name.to_string();
1103    }
1104    if name.ends_with('y') && name.len() > 1 {
1105        let mut out = String::from(&name[..name.len() - 1]);
1106        out.push_str("ies");
1107        return out;
1108    }
1109    format!("{name}s")
1110}
1111
1112fn depluralise(name: &str) -> String {
1113    if let Some(stripped) = name.strip_suffix("ies") {
1114        let mut out = String::from(stripped);
1115        out.push('y');
1116        return out;
1117    }
1118    if let Some(stripped) = name.strip_suffix('s') {
1119        return stripped.to_string();
1120    }
1121    name.to_string()
1122}
1123
1124/// Convert a model hint to PascalCase, e.g. "invoice_line" → `InvoiceLine`.
1125fn pascalise(raw: &str) -> String {
1126    let mut out = String::new();
1127    let mut next_upper = true;
1128    for ch in raw.chars() {
1129        if ch == '_' || ch == '-' || ch.is_whitespace() {
1130            next_upper = true;
1131            continue;
1132        }
1133        if !ch.is_alphanumeric() {
1134            continue;
1135        }
1136        if next_upper {
1137            out.extend(ch.to_uppercase());
1138            next_upper = false;
1139        } else {
1140            out.extend(ch.to_lowercase());
1141        }
1142    }
1143    out
1144}
1145
1146fn explain_add_field(
1147    model: &str,
1148    field: &str,
1149    ty: &str,
1150    nullable: bool,
1151    context: Option<&ContextConfig>,
1152) -> String {
1153    let opt = if nullable { ", nullable" } else { "" };
1154    let head = format!("Adds field `{field}` ({ty}{opt}) to model `{model}`.");
1155    let rationale = match (ty, field) {
1156        ("DateTime", _) => {
1157            " Stored as ISO-8601 UTC; the admin renders it as a datetime-local input."
1158        }
1159        ("bool", _) => " Rendered as a checkbox in the admin and a pill on list pages.",
1160        ("i32", f) if f == "priority" || f == "score" || f == "rank" => {
1161            " Useful for sorting and filtering records by importance."
1162        }
1163        ("i32", _) => " Numeric — the list view shows it with tabular numerics.",
1164        ("String", "status") => " Status values get coloured pills in list views.",
1165        _ => "",
1166    };
1167    let mut tail = String::new();
1168    if let Some(ctx) = context {
1169        // Country-specific annotations. Matches any of the SE personal-
1170        // id aliases the planner maps to `String` so the explanation
1171        // lines up with what was actually inferred.
1172        if matches!(ctx.country.as_deref(), Some(cc) if cc.eq_ignore_ascii_case("SE"))
1173            && matches!(
1174                field,
1175                "personnummer" | "personal_id" | "personal_number" | "pnr"
1176            )
1177        {
1178            tail.push_str(
1179                " Swedish personnummer is stored as a 13-character string (YYYYMMDD-XXXX).",
1180            );
1181        }
1182        // Industry-specific annotations.
1183        match ctx.industry.as_deref() {
1184            Some(i)
1185                if i.eq_ignore_ascii_case("healthcare")
1186                    && (field == "patient_id"
1187                        || field == "mrn"
1188                        || field == "medical_record_number") =>
1189            {
1190                tail.push_str(
1191                    " Patient identifiers are opaque strings (UUID or hash); sequential integers would leak enrolment order.",
1192                );
1193            }
1194            Some(i)
1195                if i.eq_ignore_ascii_case("banking")
1196                    && (field == "balance" || field == "amount" || field.ends_with("_amount")) =>
1197            {
1198                tail.push_str(
1199                    " Monetary values are stored as integer minor units (öre, cents). Never use floats.",
1200                );
1201            }
1202            _ => {}
1203        }
1204        // GDPR guardrail.
1205        if ctx.requires_gdpr() && is_generic_pii_field(field) {
1206            tail.push_str(
1207                " Under GDPR this field is personal data — retention and right-to-erasure rules apply.",
1208            );
1209        }
1210    }
1211    format!("{head}{rationale}{tail}")
1212}
1213
1214fn is_generic_pii_field(name: &str) -> bool {
1215    matches!(
1216        name,
1217        "email" | "phone" | "address" | "date_of_birth" | "ssn" | "personnummer" | "fodselsnummer"
1218    )
1219}
1220
1221fn supported_forms_message(raw: &str) -> String {
1222    format!(
1223        "could not interpret prompt {raw:?}. Supported forms:\n  \
1224         - add <field> to <model>\n  \
1225         - add <field> as <type> to <model>\n  \
1226         - add optional <field> to <model>\n  \
1227         - rename <field> to <new> in <model>\n  \
1228         - rename model <from> to <to>\n  \
1229         - remove <field> from <model>\n  \
1230         - change <field> in <model> to <type>\n  \
1231         - make <field> in <model> optional|required"
1232    )
1233}
1234
1235// ---------------------------------------------------------------------------
1236// CLI-facing JSON rendering
1237// ---------------------------------------------------------------------------
1238
1239/// Render a plan as the strict JSON shape documented for
1240/// `rustio ai plan`: `[{ "op": "AddField", "model": "Task", "field":
1241/// "priority", "type": "i32", "nullable": false }, …]`.
1242///
1243/// This is **not** the internal `Plan` serde shape (which is tagged
1244/// `op` = snake_case and uses `name` for the field); the CLI wants a
1245/// PascalCase, flat-field shape that's explicitly stable across the
1246/// planner vocabulary. Keeping the renderer here means the internal
1247/// representation can evolve without breaking the documented output.
1248pub fn render_plan_json(plan: &Plan, explanation: &str) -> String {
1249    let steps: Vec<serde_json::Value> = plan.steps.iter().map(primitive_to_cli_json).collect();
1250    let out = serde_json::json!({
1251        "plan": steps,
1252        "explanation": explanation,
1253    });
1254    serde_json::to_string_pretty(&out).unwrap_or_else(|_| "{}".to_string())
1255}
1256
1257/// Render a plan as a compact, Django-ish "Plan: …" summary for terminal
1258/// display alongside the JSON.
1259pub fn render_plan_human(plan: &Plan, explanation: &str) -> String {
1260    let mut out = String::from("Plan:\n");
1261    if plan.steps.is_empty() {
1262        out.push_str("  (no changes)\n");
1263    }
1264    for step in &plan.steps {
1265        out.push_str("  - ");
1266        out.push_str(&summarise_primitive(step));
1267        out.push('\n');
1268    }
1269    out.push_str("\nExplanation:\n");
1270    out.push_str(explanation);
1271    if !explanation.ends_with('\n') {
1272        out.push('\n');
1273    }
1274    out
1275}
1276
1277fn primitive_to_cli_json(p: &Primitive) -> serde_json::Value {
1278    use serde_json::json;
1279    match p {
1280        Primitive::AddField(a) => json!({
1281            "op": "AddField",
1282            "model": a.model,
1283            "field": a.field.name,
1284            "type": a.field.ty,
1285            "nullable": a.field.nullable,
1286        }),
1287        Primitive::RemoveField(r) => json!({
1288            "op": "RemoveField",
1289            "model": r.model,
1290            "field": r.field,
1291        }),
1292        Primitive::RenameField(r) => json!({
1293            "op": "RenameField",
1294            "model": r.model,
1295            "from": r.from,
1296            "to": r.to,
1297        }),
1298        Primitive::RenameModel(r) => json!({
1299            "op": "RenameModel",
1300            "from": r.from,
1301            "to": r.to,
1302        }),
1303        Primitive::ChangeFieldType(c) => json!({
1304            "op": "ChangeFieldType",
1305            "model": c.model,
1306            "field": c.field,
1307            "type": c.new_type,
1308        }),
1309        Primitive::ChangeFieldNullability(c) => json!({
1310            "op": "ChangeFieldNullability",
1311            "model": c.model,
1312            "field": c.field,
1313            "nullable": c.nullable,
1314        }),
1315        Primitive::AddModel(m) => json!({
1316            "op": "AddModel",
1317            "name": m.name,
1318            "table": m.table,
1319            "fields": m.fields.iter().map(|f| json!({
1320                "name": f.name,
1321                "type": f.ty,
1322                "nullable": f.nullable,
1323            })).collect::<Vec<_>>(),
1324        }),
1325        Primitive::RemoveModel(m) => json!({
1326            "op": "RemoveModel",
1327            "name": m.name,
1328        }),
1329        Primitive::AddRelation(r) => json!({
1330            "op": "AddRelation",
1331            "from": r.from,
1332            "kind": format!("{:?}", r.kind).to_lowercase(),
1333            "to": r.to,
1334            "via": r.via,
1335        }),
1336        Primitive::RemoveRelation(r) => json!({
1337            "op": "RemoveRelation",
1338            "from": r.from,
1339            "via": r.via,
1340        }),
1341        Primitive::UpdateAdmin(u) => json!({
1342            "op": "UpdateAdmin",
1343            "model": u.model,
1344            "field": u.field,
1345            "attr": u.attr,
1346            "value": u.value,
1347        }),
1348        Primitive::CreateMigration(_) => {
1349            // Developer-only; Plan::validate rejects it before we get
1350            // here, but render the shape for symmetry.
1351            json!({"op": "CreateMigration", "note": "developer-only"})
1352        }
1353    }
1354}
1355
1356fn summarise_primitive(p: &Primitive) -> String {
1357    match p {
1358        Primitive::AddField(a) => format!(
1359            "Add field \"{}\" ({}{}) to model \"{}\"",
1360            a.field.name,
1361            a.field.ty,
1362            if a.field.nullable { ", nullable" } else { "" },
1363            a.model,
1364        ),
1365        Primitive::RemoveField(r) => {
1366            format!("Remove field \"{}\" from model \"{}\"", r.field, r.model)
1367        }
1368        Primitive::RenameField(r) => {
1369            format!("Rename field \"{}.{}\" to \"{}\"", r.model, r.from, r.to)
1370        }
1371        Primitive::RenameModel(r) => {
1372            format!("Rename model \"{}\" to \"{}\"", r.from, r.to)
1373        }
1374        Primitive::ChangeFieldType(c) => format!(
1375            "Change type of \"{}.{}\" to {}",
1376            c.model, c.field, c.new_type
1377        ),
1378        Primitive::ChangeFieldNullability(c) => format!(
1379            "Mark \"{}.{}\" as {}",
1380            c.model,
1381            c.field,
1382            if c.nullable { "nullable" } else { "required" },
1383        ),
1384        Primitive::AddModel(m) => format!(
1385            "Add model \"{}\" with {} field{}",
1386            m.name,
1387            m.fields.len(),
1388            if m.fields.len() == 1 { "" } else { "s" }
1389        ),
1390        Primitive::RemoveModel(m) => format!("Remove model \"{}\"", m.name),
1391        Primitive::AddRelation(r) => {
1392            format!("Add relation {:?}: {}.{} → {}", r.kind, r.from, r.via, r.to)
1393        }
1394        Primitive::RemoveRelation(r) => format!("Remove relation {}.{}", r.from, r.via),
1395        Primitive::UpdateAdmin(u) => {
1396            format!("Update admin attr \"{}.{}\".{}", u.model, u.field, u.attr)
1397        }
1398        Primitive::CreateMigration(m) => format!("[dev-only] create_migration \"{}\"", m.name),
1399    }
1400}