Skip to main content

rustio_core/ai/
executor.rs

1//! Safe Executor — 0.5.2.
2//!
3//! The layer that turns a reviewed [`PlanDocument`] into deterministic
4//! on-disk changes. It behaves like *a cautious senior engineer applying
5//! changes*: if anything is uncertain, it refuses.
6//!
7//! ## Posture
8//!
9//! - **Refusal-first.** Every primitive outside a tight safe subset is
10//!   rejected with a named [`ExecutionError`] variant. The supported set
11//!   for 0.5.2 is small by design (`add_field`, `rename_field`); the list
12//!   grows only as each primitive's edit + migration story is proven safe.
13//! - **All-or-nothing.** A partial apply is worse than no apply. The
14//!   executor builds the full change set first (dry-run), verifies every
15//!   precondition, then commits atomically — writing every target to a
16//!   sibling `.tmp` and renaming on success. A mid-flight failure rolls
17//!   back everything touched.
18//! - **Never writes arbitrary SQL.** The only SQL that can land on disk
19//!   comes from `build_migration_sql`, whose shape is entirely determined
20//!   by the primitive being applied. There is no path from an AI prompt
21//!   to a hand-written SQL statement.
22//! - **Every check runs twice.** The executor re-runs `Plan::validate`
23//!   and [`review_plan`] before touching anything, even though the
24//!   document was validated when saved. Schemas drift, and this layer
25//!   is the last place to catch that drift before a migration is written.
26//!
27//! ## What 0.5.2 supports
28//!
29//! - [`Primitive::AddField`] — adds a column via `ALTER TABLE … ADD
30//!   COLUMN …` and patches the generated `apps/<app>/models.rs`
31//!   (`struct`, `COLUMNS`, `INSERT_COLUMNS`, `from_row`, `insert_values`).
32//!   Adds `use chrono::{DateTime, Utc};` if the new field needs it and
33//!   the file doesn't already import it.
34//! - [`Primitive::RenameField`] — `ALTER TABLE … RENAME COLUMN` plus a
35//!   scoped rename inside the same models.rs.
36//!
37//! ## What 0.5.2 refuses
38//!
39//! Every other primitive returns [`ExecutionError::UnsupportedPrimitive`]
40//! with a one-line reason. These land in later pull requests, not silent
41//! "best effort" writes:
42//!
43//! - `add_model`, `remove_model`, `rename_model` — require cross-file
44//!   scaffolding (apps tree, migrations, admin + views updates).
45//! - `remove_field`, `remove_relation` — destructive; gated on a
46//!   `--force` style flag that doesn't ship in 0.5.2.
47//! - `change_field_type`, `change_field_nullability` — require SQLite
48//!   table-recreation migrations which need their own review pass.
49//! - `add_relation`, `update_admin` — out of scope for 0.5.2.
50//! - `create_migration` — developer-only; refused at the
51//!   [`ExecutionError::DeveloperOnlyForbidden`] gate before it ever
52//!   reaches the dispatch.
53//!
54//! ## Testability
55//!
56//! The core logic ([`plan_execution`]) is pure: it takes a
57//! [`ProjectView`] (in-memory snapshot of the files it cares about) and
58//! returns an [`ExecutionPreview`]. No filesystem I/O. The impure entry
59//! [`execute_plan_document`] wraps it with disk reads, the
60//! confirmation-friendly preview, and atomic writes.
61
62use std::collections::BTreeMap;
63use std::path::{Path, PathBuf};
64
65use super::planner::ContextConfig;
66use super::review::{
67    review_plan, PlanDocument, RiskLevel, ValidationOutcome, PLAN_DOCUMENT_VERSION,
68};
69use super::{
70    AddField, AddRelation, ChangeFieldNullability, ChangeFieldType, FieldSpec, Primitive,
71    RemoveField, RemoveRelation, RenameField, RenameModel,
72};
73use crate::schema::{Schema, SchemaField};
74
75// ---------------------------------------------------------------------------
76// Public types
77// ---------------------------------------------------------------------------
78
79/// Reported after a successful apply. Filenames are relative to the
80/// project root passed to [`execute_plan_document`].
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct ExecutionResult {
83    pub applied_steps: usize,
84    pub generated_files: Vec<String>,
85    pub summary: String,
86}
87
88/// Dry-run output. Produced by [`plan_execution`] before any write, and
89/// displayed by the CLI as the "Plan to apply" preview.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct ExecutionPreview {
92    pub applied_steps: usize,
93    pub file_changes: Vec<PlannedFileChange>,
94    pub summary: String,
95}
96
97/// One file the executor will write. `Create` expects the file to not
98/// exist; `Update` expects it to match `expected_current_contents` byte
99/// for byte — any mismatch means a human touched the file after the
100/// plan was reviewed, which is a [`ExecutionError::FileConflict`].
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct PlannedFileChange {
103    pub path: PathBuf,
104    pub kind: FileChangeKind,
105    pub new_contents: String,
106    pub expected_current_contents: Option<String>,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum FileChangeKind {
111    Create,
112    Update,
113}
114
115/// Knobs for `execute_plan_document`. Kept as a struct so the executor
116/// can grow flags without a breaking signature change.
117#[derive(Debug, Clone, Default, PartialEq, Eq)]
118pub struct ExecuteOptions {
119    /// 0.9.1 — set to `true` to allow destructive primitives that would
120    /// otherwise produce [`ExecutionError::DestructiveWithoutConfirmation`]:
121    /// `remove_field`, `remove_relation`.
122    ///
123    /// `remove_model` stays refused with
124    /// [`ExecutionError::UnsupportedPrimitive`] regardless of this flag
125    /// until 0.9.2 — dropping a struct + its admin registration + every
126    /// downstream FK is its own scope.
127    ///
128    /// **Never** bypasses the Critical-risk gate, the developer-only
129    /// gate, or the PII policy gate — those live one layer up in
130    /// `plan_execution` and are unaffected by this field.
131    pub allow_destructive: bool,
132}
133
134/// Parsed view of the project files the executor cares about.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct ProjectView {
137    pub root: PathBuf,
138    /// Parsed `apps/<app>/models.rs` files, keyed by app directory name.
139    pub models_files: BTreeMap<String, ParsedModelsFile>,
140    /// Filenames (not full paths) of files in `migrations/`.
141    pub existing_migrations: Vec<String>,
142    /// Contents of every migration file, keyed by filename. Populated by
143    /// [`ProjectView::from_dir`]; tests constructing a `ProjectView`
144    /// directly may leave it empty, in which case FK detection returns
145    /// `false` (no constraint known) — the caller is responsible for
146    /// seeding this map when simulating a project that has FKs.
147    pub migration_sources: BTreeMap<String, String>,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct ParsedModelsFile {
152    pub path: PathBuf,
153    pub source: String,
154    /// Every `pub struct X` declared in this file. Used to locate the
155    /// app that owns a given model name.
156    pub struct_names: Vec<String>,
157}
158
159/// Every way the executor can refuse. All variants are
160/// refusal-first: nothing has been written when one of these is
161/// returned.
162#[non_exhaustive]
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub enum ExecutionError {
165    /// Re-validation against the current schema failed. Carries the
166    /// human-readable reason so the caller can print it verbatim.
167    ValidationFailed(String),
168    /// The document's risk classifier reached `Critical`. Executing a
169    /// plan at that level requires a reviewer to regenerate the plan
170    /// under a changed posture — the executor will not do it.
171    CriticalRiskNotAllowed,
172    /// The plan contains a developer-only primitive (e.g.
173    /// `CreateMigration`). These must never flow through the AI path.
174    DeveloperOnlyForbidden,
175    /// The plan was valid at save time but the current schema has
176    /// drifted. The message names the step and the primitive error.
177    SchemaMismatch(String),
178    /// The executor was about to write a file that no longer matches
179    /// the content recorded during the dry-run (or that exists when
180    /// it shouldn't). Never silently overwrite.
181    FileConflict { path: String, reason: String },
182    /// The primitive is valid in principle but not wired up in 0.5.2.
183    UnsupportedPrimitive {
184        op: &'static str,
185        reason: &'static str,
186    },
187    /// A destructive primitive was requested without `allow_destructive`.
188    /// Reserved for 0.5.3+; 0.5.2 refuses destructive ops regardless.
189    DestructiveWithoutConfirmation { op: &'static str },
190    /// Expected project scaffolding isn't present (`apps/<x>/models.rs`
191    /// missing for a model, `migrations/` directory missing, …).
192    ProjectStructure(String),
193    /// Filesystem error during read or write. Carries the OS message
194    /// plus the offending path.
195    IoError { path: String, message: String },
196    /// The plan violates a policy derived from the project's
197    /// [`ContextConfig`] — for example, a `remove_field` targeting a
198    /// field flagged as personally-identifying under GDPR, or a
199    /// `change_field_type` on a regulated column. Refused up-front;
200    /// the operator can edit the context file or the plan and re-run.
201    PolicyViolation { reason: String },
202}
203
204impl std::fmt::Display for ExecutionError {
205    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206        match self {
207            Self::ValidationFailed(msg) => write!(f, "plan failed validation: {msg}"),
208            Self::CriticalRiskNotAllowed => write!(
209                f,
210                "plan risk is Critical — the safe executor refuses to apply it"
211            ),
212            Self::DeveloperOnlyForbidden => write!(
213                f,
214                "plan contains a developer-only primitive — the safe executor refuses to apply it"
215            ),
216            Self::SchemaMismatch(msg) => write!(f, "plan is stale against the current schema: {msg}"),
217            Self::FileConflict { path, reason } => {
218                write!(f, "refusing to write `{path}`: {reason}")
219            }
220            Self::UnsupportedPrimitive { op, reason } => write!(
221                f,
222                "primitive `{op}` is not supported by the 0.5.2 safe executor: {reason}"
223            ),
224            Self::DestructiveWithoutConfirmation { op } => write!(
225                f,
226                "primitive `{op}` is destructive — re-run `rustio ai apply` with `--force` to open the destructive gate"
227            ),
228            Self::ProjectStructure(msg) => write!(f, "project layout: {msg}"),
229            Self::IoError { path, message } => {
230                write!(f, "i/o error on `{path}`: {message}")
231            }
232            Self::PolicyViolation { reason } => {
233                write!(f, "policy violation: {reason}")
234            }
235        }
236    }
237}
238
239impl std::error::Error for ExecutionError {}
240
241// ---------------------------------------------------------------------------
242// Pure dry-run
243// ---------------------------------------------------------------------------
244
245/// Compute the exact set of file changes that executing `doc` would
246/// produce, without touching the filesystem.
247///
248/// The caller supplies a [`ProjectView`] — an in-memory snapshot of
249/// the project. This lets tests drive the whole pipeline without a
250/// tempdir, and lets the CLI do the dry-run + preview before asking
251/// the operator to confirm.
252pub fn plan_execution(
253    schema: &Schema,
254    project: &ProjectView,
255    doc: &PlanDocument,
256    options: &ExecuteOptions,
257    context: Option<&ContextConfig>,
258) -> Result<ExecutionPreview, ExecutionError> {
259    // Phase 1 — Load & Validate.
260    if doc.version != PLAN_DOCUMENT_VERSION {
261        return Err(ExecutionError::ValidationFailed(format!(
262            "document version {} is not supported (this build reads version {})",
263            doc.version, PLAN_DOCUMENT_VERSION
264        )));
265    }
266    let review = review_plan(schema, &doc.plan, context)
267        .map_err(|e| ExecutionError::ValidationFailed(e.to_string()))?;
268    match &review.validation {
269        ValidationOutcome::Valid => {}
270        ValidationOutcome::Invalid { step, reason } => {
271            return Err(ExecutionError::SchemaMismatch(format!(
272                "plan invalid at step {step}: {reason}"
273            )));
274        }
275    }
276
277    // Phase 2 — Risk gate (context-aware via review).
278    if review.risk == RiskLevel::Critical {
279        return Err(ExecutionError::CriticalRiskNotAllowed);
280    }
281
282    // Phase 2b — Developer-only gate. `review_plan` would also have
283    // flagged this, but we refuse independently so a future refactor
284    // of the review scorer can't silently accept these.
285    for step in &doc.plan.steps {
286        if step.is_developer_only() {
287            return Err(ExecutionError::DeveloperOnlyForbidden);
288        }
289    }
290
291    // Phase 2c — Context policy gate. Refuses destructive or lossy
292    // operations on fields the project context flags as personally-
293    // identifying. The review layer already escalated these to
294    // Critical (caught above); this gate is a dedicated refusal so
295    // the error surface is explicit and named.
296    if let Some(ctx) = context {
297        let pii = ctx.pii_fields();
298        for step in &doc.plan.steps {
299            if let Some(reason) = policy_violation_for(step, &pii, ctx) {
300                return Err(ExecutionError::PolicyViolation { reason });
301            }
302        }
303    }
304
305    // Phase 3 — Dry-run simulation. Build the complete file-change
306    // set in memory, carrying a mutable shadow of the project so a
307    // later step sees the in-progress edits of earlier ones (e.g. two
308    // `add_field` steps on the same file).
309    let mut shadow: BTreeMap<String, String> = project
310        .models_files
311        .iter()
312        .map(|(app, file)| (app.clone(), file.source.clone()))
313        .collect();
314    let mut migration_counter = next_migration_number(&project.existing_migrations);
315    let mut file_changes: Vec<PlannedFileChange> = Vec::new();
316    let mut summary_lines: Vec<String> = Vec::new();
317
318    // The schema shadow tracks shape mutations across steps so a later
319    // `change_field_type` sees the rename an earlier step applied.
320    let mut schema_shadow = schema.clone();
321    for step in &doc.plan.steps {
322        let (mut new_changes, one_line) = simulate_step(
323            step,
324            project,
325            &mut shadow,
326            &mut migration_counter,
327            &schema_shadow,
328            options,
329        )?;
330        file_changes.append(&mut new_changes);
331        summary_lines.push(one_line);
332        apply_schema_shadow(step, &mut schema_shadow);
333    }
334
335    // Deduplicate sequential updates to the same models.rs so only
336    // the final version is emitted.
337    file_changes = collapse_duplicate_updates(file_changes);
338
339    Ok(ExecutionPreview {
340        applied_steps: doc.plan.steps.len(),
341        file_changes,
342        summary: summary_lines.join("\n"),
343    })
344}
345
346fn simulate_step(
347    step: &Primitive,
348    project: &ProjectView,
349    shadow: &mut BTreeMap<String, String>,
350    migration_counter: &mut u32,
351    schema: &Schema,
352    opts: &ExecuteOptions,
353) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
354    match step {
355        Primitive::AddField(a) => apply_add_field(a, project, shadow, migration_counter),
356        Primitive::RenameField(r) => apply_rename_field(r, project, shadow, migration_counter),
357        Primitive::ChangeFieldType(c) => {
358            apply_change_field_type(c, schema, project, shadow, migration_counter)
359        }
360        Primitive::ChangeFieldNullability(c) => {
361            apply_change_field_nullability(c, schema, project, shadow, migration_counter)
362        }
363        Primitive::RenameModel(r) => apply_rename_model(r, project, shadow, migration_counter),
364        // Everything else: refuse explicitly so the reviewer can see
365        // which primitive stopped the apply.
366        Primitive::AddModel(_) => Err(ExecutionError::UnsupportedPrimitive {
367            op: "add_model",
368            reason:
369                "model scaffolding lives with `rustio new app`; use that then let the AI add fields",
370        }),
371        // 0.9.1 destructive gate: `remove_model` stays refused as
372        // unsupported until 0.9.2; `remove_field` and `remove_relation`
373        // honour `opts.allow_destructive` (the CLI's `--force` flag).
374        Primitive::RemoveModel(_) => Err(ExecutionError::UnsupportedPrimitive {
375            op: "remove_model",
376            reason: "dropping a model + its admin registration + downstream FKs is scheduled for 0.9.2; use `rustio new app` / manual removal for now",
377        }),
378        Primitive::RemoveField(r) => {
379            if !opts.allow_destructive {
380                return Err(ExecutionError::DestructiveWithoutConfirmation {
381                    op: "remove_field",
382                });
383            }
384            apply_remove_field(r, schema, project, shadow, migration_counter)
385        }
386        Primitive::AddRelation(r) => apply_add_relation(r, project, shadow, migration_counter),
387        Primitive::RemoveRelation(r) => {
388            if !opts.allow_destructive {
389                return Err(ExecutionError::DestructiveWithoutConfirmation {
390                    op: "remove_relation",
391                });
392            }
393            apply_remove_relation(r, schema, project, shadow, migration_counter)
394        }
395        Primitive::UpdateAdmin(_) => Err(ExecutionError::UnsupportedPrimitive {
396            op: "update_admin",
397            reason: "admin-attribute edits are out of scope for 0.5.2",
398        }),
399        Primitive::CreateMigration(_) => Err(ExecutionError::DeveloperOnlyForbidden),
400    }
401}
402
403/// Collapse multiple `Update`s to the same path into a single change
404/// holding the final contents — so two sequential `add_field` steps
405/// on the same file emit one diff, not two.
406fn collapse_duplicate_updates(changes: Vec<PlannedFileChange>) -> Vec<PlannedFileChange> {
407    let mut out: Vec<PlannedFileChange> = Vec::with_capacity(changes.len());
408    for c in changes {
409        if let Some(existing) = out.iter_mut().rev().find(|e| {
410            e.path == c.path && e.kind == FileChangeKind::Update && c.kind == FileChangeKind::Update
411        }) {
412            existing.new_contents = c.new_contents;
413            // expected_current_contents stays pinned to the initial file
414            // contents — the conflict check runs against disk at apply time.
415            continue;
416        }
417        out.push(c);
418    }
419    out
420}
421
422// ---------------------------------------------------------------------------
423// Per-primitive simulators
424// ---------------------------------------------------------------------------
425
426fn apply_add_field(
427    a: &AddField,
428    project: &ProjectView,
429    shadow: &mut BTreeMap<String, String>,
430    migration_counter: &mut u32,
431) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
432    // Locate the app and initial source of the file owning this struct.
433    let (app, initial_source) = locate_model_file(project, &a.model)?;
434    let current = shadow
435        .get(&app)
436        .cloned()
437        .unwrap_or_else(|| initial_source.clone());
438
439    // Idempotency: refuse if the field already exists in the struct.
440    let struct_bounds = find_struct_block(&current, &a.model).ok_or_else(|| {
441        ExecutionError::ProjectStructure(format!(
442            "apps/{app}/models.rs does not declare `pub struct {}`",
443            a.model
444        ))
445    })?;
446    let inside_struct = &current[struct_bounds.0..=struct_bounds.1];
447    if struct_declares_field(inside_struct, &a.field.name) {
448        return Err(ExecutionError::FileConflict {
449            path: format!("apps/{app}/models.rs"),
450            reason: format!(
451                "struct {} already declares field `{}`; the plan appears to have been applied already",
452                a.model, a.field.name,
453            ),
454        });
455    }
456
457    // Patch the file.
458    let patched = patch_models_for_add_field(&current, &a.model, &a.field).map_err(|msg| {
459        ExecutionError::FileConflict {
460            path: format!("apps/{app}/models.rs"),
461            reason: msg,
462        }
463    })?;
464    shadow.insert(app.clone(), patched.clone());
465
466    // Migration file.
467    let table = find_table_for_struct(&current, &a.model)
468        .or_else(|| fallback_table_name(&a.model))
469        .ok_or_else(|| {
470            ExecutionError::ProjectStructure(format!(
471                "could not find `const TABLE` for struct `{}`",
472                a.model
473            ))
474        })?;
475    let sql = sql_for_add_field(&table, &a.field);
476    let mig_name = format!("add_{}_to_{}", a.field.name, table);
477    let (mig_path, mig_filename) = new_migration_path(project, *migration_counter, &mig_name);
478    *migration_counter += 1;
479
480    let file_path = project.root.join("apps").join(&app).join("models.rs");
481    Ok((
482        vec![
483            PlannedFileChange {
484                path: file_path,
485                kind: FileChangeKind::Update,
486                new_contents: patched,
487                expected_current_contents: Some(initial_source),
488            },
489            PlannedFileChange {
490                path: mig_path,
491                kind: FileChangeKind::Create,
492                new_contents: sql,
493                expected_current_contents: None,
494            },
495        ],
496        format!(
497            "+ Add field \"{}\" ({}{}) to model \"{}\" (migration {})",
498            a.field.name,
499            a.field.ty,
500            if a.field.nullable { ", nullable" } else { "" },
501            a.model,
502            mig_filename,
503        ),
504    ))
505}
506
507/// 0.9.0 — materialise an `AddRelation { kind: BelongsTo, .. }` with a
508/// real SQL `FOREIGN KEY` constraint.
509///
510/// The generated migration emits
511///
512///   ALTER TABLE child ADD COLUMN via INTEGER REFERENCES parent(id)
513///       ON DELETE <policy>;
514///
515/// SQLite's `ALTER TABLE ADD COLUMN` accepts `REFERENCES` only when the
516/// column is nullable (effective default `NULL`). A `required: true`
517/// relation therefore cannot be materialised as a simple ALTER — it
518/// has to go through the recreate-table dance. That path is the same
519/// machinery the retrofit command (M3) uses, so for now the executor
520/// refuses `required: true` from `ai apply` and points callers at
521/// `rustio migrate --add-fks` which implements the dance once.
522///
523/// Referential integrity in SQLite is only enforced per-connection
524/// with `PRAGMA foreign_keys = ON`. The generated migration prepends
525/// that PRAGMA so operators running `rustio migrate apply` see
526/// constraints take effect immediately; application runtime should
527/// set the same PRAGMA on every connection (rustio's ORM already
528/// does so).
529///
530/// Kinds other than `BelongsTo` refuse — planning them is allowed so
531/// the review layer can warn, but the executor has no story for
532/// `HasMany` yet (it's a virtual accessor, not a column).
533fn apply_add_relation(
534    r: &AddRelation,
535    project: &ProjectView,
536    shadow: &mut BTreeMap<String, String>,
537    migration_counter: &mut u32,
538) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
539    use crate::ai::OnDelete;
540    use crate::schema::RelationKind;
541    match r.kind {
542        RelationKind::BelongsTo => {}
543        _ => {
544            return Err(ExecutionError::UnsupportedPrimitive {
545                op: "add_relation",
546                reason: "only `belongs_to` is materialised in 0.9.0 — `has_many` is a virtual accessor with no column change",
547            });
548        }
549    }
550
551    // Reject the NOT NULL + REFERENCES combination: SQLite's ALTER TABLE
552    // ADD COLUMN can carry a REFERENCES clause only on a nullable column
553    // (the implicit DEFAULT NULL makes existing rows satisfy the new FK
554    // without a backfill). A required FK requires recreate-table, which
555    // is the retrofit path.
556    if r.required {
557        return Err(ExecutionError::UnsupportedPrimitive {
558            op: "add_relation",
559            reason: "a required (NOT NULL) foreign key cannot be added via ALTER TABLE on SQLite; use `rustio migrate --add-fks` to retrofit via recreate-table",
560        });
561    }
562    // SET NULL is only defined when the column can actually hold NULL.
563    // With `required: false` this always passes; the check guards
564    // against future primitive-level misuse.
565    if matches!(r.on_delete, OnDelete::SetNull) && r.required {
566        return Err(ExecutionError::UnsupportedPrimitive {
567            op: "add_relation",
568            reason: "`on_delete: set_null` requires a nullable FK column",
569        });
570    }
571
572    // Patch models.rs via the same machinery as add_field — the struct
573    // change is identical (an `i64` column, plus a `#[rustio(belongs_to)]`
574    // attribute). We reuse apply_add_field for the file patch, then
575    // *replace* its generated SQL migration with one that includes the
576    // FOREIGN KEY clause.
577    let synthetic = AddField {
578        model: r.from.clone(),
579        field: FieldSpec {
580            name: r.via.clone(),
581            ty: "i64".to_string(),
582            nullable: true, // see above — must be nullable for ALTER TABLE
583            editable: true,
584        },
585    };
586    let (mut changes, _) = apply_add_field(&synthetic, project, shadow, migration_counter)?;
587
588    // Find the parent's models.rs to resolve its `const TABLE`. The
589    // child's table was already resolved by `apply_add_field` above, but
590    // we didn't capture it — redo the cheap lookup here rather than
591    // threading the result through.
592    let (child_app, _) = locate_model_file(project, &r.from)?;
593    let child_src = shadow
594        .get(&child_app)
595        .cloned()
596        .unwrap_or_else(|| project.models_files[&child_app].source.clone());
597    let child_table = find_table_for_struct(&child_src, &r.from)
598        .or_else(|| fallback_table_name(&r.from))
599        .ok_or_else(|| {
600            ExecutionError::ProjectStructure(format!(
601                "could not find `const TABLE` for child struct `{}`",
602                r.from
603            ))
604        })?;
605
606    // Parent may live in a different app, or — for plans targeting a
607    // model that isn't scaffolded locally — be absent entirely. In the
608    // missing-parent case, use the snake-plural fallback (matches what
609    // `rustio new app` produces) rather than refusing the migration.
610    let parent_table = match locate_model_file(project, &r.to) {
611        Ok((parent_app, parent_source)) => {
612            let parent_src = shadow.get(&parent_app).cloned().unwrap_or(parent_source);
613            find_table_for_struct(&parent_src, &r.to)
614                .or_else(|| fallback_table_name(&r.to))
615                .ok_or_else(|| {
616                    ExecutionError::ProjectStructure(format!(
617                        "could not find `const TABLE` for parent struct `{}`",
618                        r.to
619                    ))
620                })?
621        }
622        Err(_) => fallback_table_name(&r.to).ok_or_else(|| {
623            ExecutionError::ProjectStructure(format!(
624                "could not derive a table name for parent struct `{}`",
625                r.to
626            ))
627        })?,
628    };
629
630    let fk_sql = sql_for_add_fk_column(&child_table, &r.via, &parent_table, r.on_delete);
631    let mig_filename = {
632        let create = changes
633            .iter_mut()
634            .find(|c| c.kind == FileChangeKind::Create)
635            .expect("apply_add_field always plans a Create for the migration");
636        create.new_contents = fk_sql;
637        create
638            .path
639            .file_name()
640            .and_then(|n| n.to_str())
641            .unwrap_or("")
642            .to_string()
643    };
644
645    Ok((
646        changes,
647        format!(
648            "+ Add relation `{}` from \"{}\" to \"{}\" (belongs_to → {}, {}, migration {})",
649            r.via,
650            r.from,
651            r.to,
652            parent_table,
653            r.on_delete.as_str(),
654            mig_filename,
655        ),
656    ))
657}
658
659/// 0.9.0 — emit the SQL for adding a nullable FK column to an existing
660/// table. The `PRAGMA foreign_keys = ON` is repeated in the migration
661/// so operators who run migrations via `sqlite3` directly (not
662/// `rustio migrate apply`) still see the constraint enforced.
663fn sql_for_add_fk_column(
664    child_table: &str,
665    via: &str,
666    parent_table: &str,
667    on_delete: crate::ai::OnDelete,
668) -> String {
669    format!(
670        "-- Generated by rustio ai apply (0.9.0). DO NOT EDIT.\n\
671         PRAGMA foreign_keys = ON;\n\
672         ALTER TABLE {child} ADD COLUMN {via} INTEGER REFERENCES {parent}(id) {policy};\n",
673        child = child_table,
674        via = via,
675        parent = parent_table,
676        policy = on_delete.sql(),
677    )
678}
679
680// ---------------------------------------------------------------------------
681// 0.9.1 — remove_field / remove_relation (gated on `allow_destructive`)
682// ---------------------------------------------------------------------------
683
684/// 0.9.1 — materialise a `RemoveField` primitive.
685///
686/// SQLite's `ALTER TABLE` has supported `DROP COLUMN` since 3.35, but
687/// the clause refuses when the column participates in a foreign key or
688/// is referenced by an index — and rustio projects routinely have both.
689/// Use the same recreate-table pattern as `change_field_type`: clone
690/// the table with the field omitted, copy data across, drop, rename.
691/// The recreate preserves every remaining field's relation metadata
692/// (`column_def_with_relation_context`), so other FKs on the table
693/// survive the migration unchanged.
694///
695/// Caller has already verified `opts.allow_destructive` — this function
696/// assumes the reviewer has consented.
697fn apply_remove_field(
698    r: &RemoveField,
699    schema: &Schema,
700    project: &ProjectView,
701    shadow: &mut BTreeMap<String, String>,
702    migration_counter: &mut u32,
703) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
704    let model = schema
705        .models
706        .iter()
707        .find(|m| m.name == r.model)
708        .ok_or_else(|| {
709            ExecutionError::SchemaMismatch(format!("model `{}` not in schema", r.model))
710        })?;
711    let field = model
712        .fields
713        .iter()
714        .find(|f| f.name == r.field)
715        .ok_or_else(|| {
716            ExecutionError::SchemaMismatch(format!("field `{}.{}` not in schema", r.model, r.field))
717        })?;
718    if r.field == "id" {
719        return Err(ExecutionError::UnsupportedPrimitive {
720            op: "remove_field",
721            reason: "cannot drop the `id` primary key; remove the model instead",
722        });
723    }
724
725    let (app, initial_source) = locate_model_file(project, &r.model)?;
726    let current = shadow
727        .get(&app)
728        .cloned()
729        .unwrap_or_else(|| initial_source.clone());
730    let table = find_table_for_struct(&current, &r.model)
731        .or_else(|| fallback_table_name(&r.model))
732        .ok_or_else(|| {
733            ExecutionError::ProjectStructure(format!(
734                "could not find `const TABLE` for struct `{}`",
735                r.model
736            ))
737        })?;
738
739    // Patch the models.rs file.
740    let patched =
741        patch_models_for_remove_field(&current, &r.model, &r.field, &field.ty, field.nullable)
742            .map_err(|msg| ExecutionError::FileConflict {
743                path: format!("apps/{app}/models.rs"),
744                reason: msg,
745            })?;
746    shadow.insert(app.clone(), patched.clone());
747
748    // Build the table-rebuild SQL with the field omitted.
749    let remaining: Vec<SchemaField> = model
750        .fields
751        .iter()
752        .filter(|f| f.name != r.field)
753        .cloned()
754        .collect();
755    // Resolve parent table names for any surviving FKs from the schema.
756    let table_for = |target: &str| -> Option<String> {
757        schema
758            .models
759            .iter()
760            .find(|m| m.name == target)
761            .and_then(|_| fallback_table_name(target))
762    };
763    let sql = generate_sqlite_recreate_table_migration_fk_aware(
764        &table,
765        &remaining,
766        &BTreeMap::new(), // no casts — surviving columns copy 1:1
767        &table_for,
768    );
769
770    let mig_name = format!("drop_{}_from_{}", r.field, table);
771    let (mig_path, mig_filename) = new_migration_path(project, *migration_counter, &mig_name);
772    *migration_counter += 1;
773
774    let file_path = project.root.join("apps").join(&app).join("models.rs");
775    let warn_line = format!(
776        "    ⚠ This rewrites `{table}`. Data in `{}` is lost.",
777        r.field
778    );
779    Ok((
780        vec![
781            PlannedFileChange {
782                path: file_path,
783                kind: FileChangeKind::Update,
784                new_contents: patched,
785                expected_current_contents: Some(initial_source),
786            },
787            PlannedFileChange {
788                path: mig_path,
789                kind: FileChangeKind::Create,
790                new_contents: sql,
791                expected_current_contents: None,
792            },
793        ],
794        format!(
795            "- Remove field `{}.{}` (migration {})\n{}",
796            r.model, r.field, mig_filename, warn_line,
797        ),
798    ))
799}
800
801/// 0.9.1 — materialise a `RemoveRelation` primitive.
802///
803/// Relations are stored as a single FK column on the owning side, so
804/// dropping a relation is exactly `apply_remove_field` on `r.via`. A
805/// dedicated entry keeps the summary line honest ("Remove relation"
806/// rather than "Remove field") and keeps callers from having to
807/// synthesise a `RemoveField` themselves.
808fn apply_remove_relation(
809    r: &RemoveRelation,
810    schema: &Schema,
811    project: &ProjectView,
812    shadow: &mut BTreeMap<String, String>,
813    migration_counter: &mut u32,
814) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
815    // Delegate the physical column drop to apply_remove_field.
816    let synthetic = RemoveField {
817        model: r.from.clone(),
818        field: r.via.clone(),
819    };
820    let (changes, _) = apply_remove_field(&synthetic, schema, project, shadow, migration_counter)?;
821    let mig_filename = changes
822        .iter()
823        .find(|c| c.kind == FileChangeKind::Create)
824        .and_then(|c| c.path.file_name())
825        .and_then(|n| n.to_str())
826        .unwrap_or("")
827        .to_string();
828    Ok((
829        changes,
830        format!(
831            "- Remove relation `{}.{}` (migration {})",
832            r.from, r.via, mig_filename,
833        ),
834    ))
835}
836
837/// 0.9.1 — like [`generate_sqlite_recreate_table_migration`] but every
838/// remaining field with a `Relation` block re-emits its `REFERENCES`
839/// clause on the new table so FKs survive the drop-field operation.
840fn generate_sqlite_recreate_table_migration_fk_aware(
841    table: &str,
842    new_fields: &[SchemaField],
843    source_exprs: &BTreeMap<String, String>,
844    table_for: &impl Fn(&str) -> Option<String>,
845) -> String {
846    let new_table = format!("{table}__new");
847    let mut out = String::new();
848    out.push_str("-- Generated by rustio ai apply (0.9.1). DO NOT EDIT.\n");
849    out.push_str("-- SQLite recreate-table, FK-aware: surviving foreign keys are\n");
850    out.push_str("-- re-emitted on the rebuilt table.\n");
851    out.push_str("PRAGMA foreign_keys = OFF;\n");
852    out.push_str("BEGIN;\n\n");
853    out.push_str(&format!("CREATE TABLE {new_table} (\n"));
854    for (i, f) in new_fields.iter().enumerate() {
855        out.push_str("    ");
856        out.push_str(&column_def_with_relation_context(f, table_for));
857        if i + 1 < new_fields.len() {
858            out.push(',');
859        }
860        out.push('\n');
861    }
862    out.push_str(");\n\n");
863    let col_list = new_fields
864        .iter()
865        .map(|f| f.name.clone())
866        .collect::<Vec<_>>()
867        .join(", ");
868    let expr_list = new_fields
869        .iter()
870        .map(|f| {
871            source_exprs
872                .get(&f.name)
873                .cloned()
874                .unwrap_or_else(|| f.name.clone())
875        })
876        .collect::<Vec<_>>()
877        .join(", ");
878    out.push_str(&format!(
879        "INSERT INTO {new_table} ({col_list}) SELECT {expr_list} FROM {table};\n\n"
880    ));
881    out.push_str(&format!("DROP TABLE {table};\n"));
882    out.push_str(&format!("ALTER TABLE {new_table} RENAME TO {table};\n\n"));
883    out.push_str("COMMIT;\n");
884    out.push_str("PRAGMA foreign_keys = ON;\n");
885    out
886}
887
888fn apply_rename_field(
889    r: &RenameField,
890    project: &ProjectView,
891    shadow: &mut BTreeMap<String, String>,
892    migration_counter: &mut u32,
893) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
894    let (app, initial_source) = locate_model_file(project, &r.model)?;
895    let current = shadow
896        .get(&app)
897        .cloned()
898        .unwrap_or_else(|| initial_source.clone());
899
900    let struct_bounds = find_struct_block(&current, &r.model).ok_or_else(|| {
901        ExecutionError::ProjectStructure(format!(
902            "apps/{app}/models.rs does not declare `pub struct {}`",
903            r.model
904        ))
905    })?;
906    let inside_struct = &current[struct_bounds.0..=struct_bounds.1];
907    if !struct_declares_field(inside_struct, &r.from) {
908        return Err(ExecutionError::FileConflict {
909            path: format!("apps/{app}/models.rs"),
910            reason: format!(
911                "struct {} does not declare `pub {}: …`; rename cannot proceed",
912                r.model, r.from,
913            ),
914        });
915    }
916    if struct_declares_field(inside_struct, &r.to) {
917        return Err(ExecutionError::FileConflict {
918            path: format!("apps/{app}/models.rs"),
919            reason: format!(
920                "struct {} already has a field called `{}`; rename target is taken",
921                r.model, r.to,
922            ),
923        });
924    }
925
926    let patched =
927        patch_models_for_rename_field(&current, &r.model, &r.from, &r.to).map_err(|msg| {
928            ExecutionError::FileConflict {
929                path: format!("apps/{app}/models.rs"),
930                reason: msg,
931            }
932        })?;
933    shadow.insert(app.clone(), patched.clone());
934
935    let table = find_table_for_struct(&current, &r.model)
936        .or_else(|| fallback_table_name(&r.model))
937        .ok_or_else(|| {
938            ExecutionError::ProjectStructure(format!(
939                "could not find `const TABLE` for struct `{}`",
940                r.model
941            ))
942        })?;
943    let sql = format!(
944        "-- Generated by rustio ai apply (0.5.2). DO NOT EDIT.\n\
945         ALTER TABLE {table} RENAME COLUMN {from} TO {to};\n",
946        from = r.from,
947        to = r.to,
948    );
949    let mig_name = format!("rename_{}_to_{}_on_{}", r.from, r.to, table);
950    let (mig_path, mig_filename) = new_migration_path(project, *migration_counter, &mig_name);
951    *migration_counter += 1;
952
953    let file_path = project.root.join("apps").join(&app).join("models.rs");
954    Ok((
955        vec![
956            PlannedFileChange {
957                path: file_path,
958                kind: FileChangeKind::Update,
959                new_contents: patched,
960                expected_current_contents: Some(initial_source),
961            },
962            PlannedFileChange {
963                path: mig_path,
964                kind: FileChangeKind::Create,
965                new_contents: sql,
966                expected_current_contents: None,
967            },
968        ],
969        format!(
970            "~ Rename field \"{}.{}\" to \"{}\" (migration {})",
971            r.model, r.from, r.to, mig_filename
972        ),
973    ))
974}
975
976// ---------------------------------------------------------------------------
977// change_field_type — SQLite recreate-table
978// ---------------------------------------------------------------------------
979
980fn apply_change_field_type(
981    c: &ChangeFieldType,
982    schema: &Schema,
983    project: &ProjectView,
984    shadow: &mut BTreeMap<String, String>,
985    migration_counter: &mut u32,
986) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
987    let model = schema
988        .models
989        .iter()
990        .find(|m| m.name == c.model)
991        .ok_or_else(|| {
992            ExecutionError::SchemaMismatch(format!("model `{}` not in schema", c.model))
993        })?;
994    let field = model
995        .fields
996        .iter()
997        .find(|f| f.name == c.field)
998        .ok_or_else(|| {
999            ExecutionError::SchemaMismatch(format!("field `{}.{}` not in schema", c.model, c.field))
1000        })?;
1001
1002    // Idempotency: field already has target type.
1003    if field.ty == c.new_type {
1004        return Err(ExecutionError::FileConflict {
1005            path: format!("apps/?/{}.rs", c.model.to_lowercase()),
1006            reason: format!(
1007                "field `{}.{}` already has type `{}`; change appears applied",
1008                c.model, c.field, c.new_type,
1009            ),
1010        });
1011    }
1012
1013    // Safe-cast gate.
1014    let cast_expr = cast_expression(&field.ty, &c.new_type, &c.field).ok_or(
1015        ExecutionError::UnsupportedPrimitive {
1016            op: "change_field_type",
1017            reason: "this type conversion is not in the 0.5.3 safe-cast set",
1018        },
1019    )?;
1020
1021    let (app, initial_source) = locate_model_file(project, &c.model)?;
1022    let current = shadow
1023        .get(&app)
1024        .cloned()
1025        .unwrap_or_else(|| initial_source.clone());
1026    let table = find_table_for_struct(&current, &c.model)
1027        .or_else(|| fallback_table_name(&c.model))
1028        .ok_or_else(|| {
1029            ExecutionError::ProjectStructure(format!(
1030                "could not find `const TABLE` for struct `{}`",
1031                c.model
1032            ))
1033        })?;
1034
1035    if table_has_any_foreign_key(project, &table) {
1036        return Err(ExecutionError::UnsupportedPrimitive {
1037            op: "change_field_type",
1038            reason:
1039                "table has foreign-key constraints (incoming or outgoing); SQLite recreate-table would break them — scheduled for 0.6.0",
1040        });
1041    }
1042
1043    let patched = patch_models_for_change_field_type(
1044        &current,
1045        &c.model,
1046        &c.field,
1047        &field.ty,
1048        &c.new_type,
1049        field.nullable,
1050    )
1051    .map_err(|msg| ExecutionError::FileConflict {
1052        path: format!("apps/{app}/models.rs"),
1053        reason: msg,
1054    })?;
1055    shadow.insert(app.clone(), patched.clone());
1056
1057    // Build the new column list (same order as current schema).
1058    let new_fields: Vec<SchemaField> = model
1059        .fields
1060        .iter()
1061        .map(|f| {
1062            if f.name == c.field {
1063                SchemaField {
1064                    ty: c.new_type.clone(),
1065                    ..f.clone()
1066                }
1067            } else {
1068                f.clone()
1069            }
1070        })
1071        .collect();
1072
1073    let mut source_exprs: BTreeMap<String, String> = BTreeMap::new();
1074    source_exprs.insert(c.field.clone(), cast_expr);
1075    let sql = generate_sqlite_recreate_table_migration(&table, &new_fields, &source_exprs);
1076
1077    let mig_name = format!("change_{}_type_on_{}", c.field, table);
1078    let (mig_path, mig_filename) = new_migration_path(project, *migration_counter, &mig_name);
1079    *migration_counter += 1;
1080
1081    let file_path = project.root.join("apps").join(&app).join("models.rs");
1082    let warn_line =
1083        format!("    ⚠ This rewrites the entire `{table}` table. Large tables may cause downtime.");
1084    Ok((
1085        vec![
1086            PlannedFileChange {
1087                path: file_path,
1088                kind: FileChangeKind::Update,
1089                new_contents: patched,
1090                expected_current_contents: Some(initial_source),
1091            },
1092            PlannedFileChange {
1093                path: mig_path,
1094                kind: FileChangeKind::Create,
1095                new_contents: sql,
1096                expected_current_contents: None,
1097            },
1098        ],
1099        format!(
1100            "~ Change type of {}.{} from {} to {} (migration {})\n{}",
1101            c.model, c.field, field.ty, c.new_type, mig_filename, warn_line,
1102        ),
1103    ))
1104}
1105
1106/// Decide whether `old_ty` → `new_ty` is a safe SQLite cast, and return
1107/// the SQL expression that performs it against the source column.
1108/// `None` means "not in the safe-cast set" — callers refuse with
1109/// `UnsupportedPrimitive`.
1110fn cast_expression(old_ty: &str, new_ty: &str, col_name: &str) -> Option<String> {
1111    match (old_ty, new_ty) {
1112        // Same type — caller should have bailed out earlier.
1113        (a, b) if a == b => None,
1114        // SQLite stores i32 / i64 / bool as INTEGER; no cast needed.
1115        ("i32", "i64") | ("i64", "i32") => Some(col_name.to_string()),
1116        ("bool", "i32") | ("bool", "i64") | ("i32", "bool") | ("i64", "bool") => {
1117            Some(col_name.to_string())
1118        }
1119        // SQLite stores DateTime as TEXT; no cast needed either way.
1120        ("DateTime", "String") | ("String", "DateTime") => Some(col_name.to_string()),
1121        // Safe widening to TEXT.
1122        ("i32", "String") | ("i64", "String") | ("bool", "String") => {
1123            Some(format!("CAST({col_name} AS TEXT)"))
1124        }
1125        // Narrowing to INTEGER — explicit cast; review warns that
1126        // non-numeric text becomes 0.
1127        ("String", "i32") | ("String", "i64") | ("String", "bool") => {
1128            Some(format!("CAST({col_name} AS INTEGER)"))
1129        }
1130        _ => None,
1131    }
1132}
1133
1134// ---------------------------------------------------------------------------
1135// change_field_nullability — SQLite recreate-table
1136// ---------------------------------------------------------------------------
1137
1138fn apply_change_field_nullability(
1139    c: &ChangeFieldNullability,
1140    schema: &Schema,
1141    project: &ProjectView,
1142    shadow: &mut BTreeMap<String, String>,
1143    migration_counter: &mut u32,
1144) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
1145    let model = schema
1146        .models
1147        .iter()
1148        .find(|m| m.name == c.model)
1149        .ok_or_else(|| {
1150            ExecutionError::SchemaMismatch(format!("model `{}` not in schema", c.model))
1151        })?;
1152    let field = model
1153        .fields
1154        .iter()
1155        .find(|f| f.name == c.field)
1156        .ok_or_else(|| {
1157            ExecutionError::SchemaMismatch(format!("field `{}.{}` not in schema", c.model, c.field))
1158        })?;
1159
1160    if field.nullable == c.nullable {
1161        return Err(ExecutionError::FileConflict {
1162            path: format!("apps/?/{}.rs", c.model.to_lowercase()),
1163            reason: format!(
1164                "field `{}.{}` is already {}; change appears applied",
1165                c.model,
1166                c.field,
1167                if c.nullable { "nullable" } else { "required" }
1168            ),
1169        });
1170    }
1171
1172    let (app, initial_source) = locate_model_file(project, &c.model)?;
1173    let current = shadow
1174        .get(&app)
1175        .cloned()
1176        .unwrap_or_else(|| initial_source.clone());
1177    let table = find_table_for_struct(&current, &c.model)
1178        .or_else(|| fallback_table_name(&c.model))
1179        .ok_or_else(|| {
1180            ExecutionError::ProjectStructure(format!(
1181                "could not find `const TABLE` for struct `{}`",
1182                c.model
1183            ))
1184        })?;
1185    if table_has_any_foreign_key(project, &table) {
1186        return Err(ExecutionError::UnsupportedPrimitive {
1187            op: "change_field_nullability",
1188            reason:
1189                "table has foreign-key constraints; SQLite recreate-table would break them — scheduled for 0.6.0",
1190        });
1191    }
1192
1193    let patched = patch_models_for_change_nullability(
1194        &current,
1195        &c.model,
1196        &c.field,
1197        &field.ty,
1198        field.nullable,
1199        c.nullable,
1200    )
1201    .map_err(|msg| ExecutionError::FileConflict {
1202        path: format!("apps/{app}/models.rs"),
1203        reason: msg,
1204    })?;
1205    shadow.insert(app.clone(), patched.clone());
1206
1207    let new_fields: Vec<SchemaField> = model
1208        .fields
1209        .iter()
1210        .map(|f| {
1211            if f.name == c.field {
1212                SchemaField {
1213                    nullable: c.nullable,
1214                    ..f.clone()
1215                }
1216            } else {
1217                f.clone()
1218            }
1219        })
1220        .collect();
1221
1222    // When tightening (nullable → required), replace NULL source rows
1223    // with the type default via COALESCE. When relaxing, straight copy.
1224    let mut source_exprs: BTreeMap<String, String> = BTreeMap::new();
1225    let tightening = !c.nullable && field.nullable;
1226    if tightening {
1227        source_exprs.insert(
1228            c.field.clone(),
1229            format!(
1230                "COALESCE({col}, {dflt})",
1231                col = c.field,
1232                dflt = safe_default_literal(&field.ty)
1233            ),
1234        );
1235    }
1236    let sql = generate_sqlite_recreate_table_migration(&table, &new_fields, &source_exprs);
1237
1238    let mig_name = format!("change_{}_nullability_on_{}", c.field, table);
1239    let (mig_path, mig_filename) = new_migration_path(project, *migration_counter, &mig_name);
1240    *migration_counter += 1;
1241
1242    let state = if c.nullable { "nullable" } else { "required" };
1243    let warn_line = if tightening {
1244        format!(
1245            "    ⚠ This rewrites `{table}` and substitutes existing NULLs with the type default ({}).",
1246            safe_default_literal(&field.ty)
1247        )
1248    } else {
1249        format!("    ⚠ This rewrites the entire `{table}` table. Large tables may cause downtime.")
1250    };
1251
1252    let file_path = project.root.join("apps").join(&app).join("models.rs");
1253    Ok((
1254        vec![
1255            PlannedFileChange {
1256                path: file_path,
1257                kind: FileChangeKind::Update,
1258                new_contents: patched,
1259                expected_current_contents: Some(initial_source),
1260            },
1261            PlannedFileChange {
1262                path: mig_path,
1263                kind: FileChangeKind::Create,
1264                new_contents: sql,
1265                expected_current_contents: None,
1266            },
1267        ],
1268        format!(
1269            "~ Mark {}.{} as {} (migration {})\n{}",
1270            c.model, c.field, state, mig_filename, warn_line
1271        ),
1272    ))
1273}
1274
1275// ---------------------------------------------------------------------------
1276// rename_model — full: struct, TABLE const, admin.rs, views.rs (bounded)
1277// ---------------------------------------------------------------------------
1278
1279fn apply_rename_model(
1280    r: &RenameModel,
1281    project: &ProjectView,
1282    shadow: &mut BTreeMap<String, String>,
1283    migration_counter: &mut u32,
1284) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
1285    let (app, initial_source) = locate_model_file(project, &r.from)?;
1286    let current = shadow
1287        .get(&app)
1288        .cloned()
1289        .unwrap_or_else(|| initial_source.clone());
1290
1291    // Idempotency.
1292    let struct_names = parse_struct_names(&current);
1293    if struct_names.iter().any(|n| n == &r.to) {
1294        return Err(ExecutionError::FileConflict {
1295            path: format!("apps/{app}/models.rs"),
1296            reason: format!(
1297                "struct `{}` already exists in this file; rename appears applied",
1298                r.to
1299            ),
1300        });
1301    }
1302    if !struct_names.iter().any(|n| n == &r.from) {
1303        return Err(ExecutionError::FileConflict {
1304            path: format!("apps/{app}/models.rs"),
1305            reason: format!("struct `{}` not found — nothing to rename", r.from),
1306        });
1307    }
1308
1309    let old_table = find_table_for_struct(&current, &r.from)
1310        .or_else(|| fallback_table_name(&r.from))
1311        .ok_or_else(|| {
1312            ExecutionError::ProjectStructure(format!(
1313                "could not find `const TABLE` for struct `{}`",
1314                r.from
1315            ))
1316        })?;
1317    let new_table = fallback_table_name(&r.to).unwrap_or_else(|| old_table.clone());
1318
1319    if table_has_any_foreign_key(project, &old_table) {
1320        return Err(ExecutionError::UnsupportedPrimitive {
1321            op: "rename_model",
1322            reason:
1323                "table has foreign-key constraints (incoming or outgoing); FK rewriting is scheduled for 0.6.0",
1324        });
1325    }
1326
1327    // Patch models.rs.
1328    let patched_models = patch_models_for_rename_model(
1329        &current, &r.from, &r.to, &old_table, &new_table,
1330    )
1331    .map_err(|msg| ExecutionError::FileConflict {
1332        path: format!("apps/{app}/models.rs"),
1333        reason: msg,
1334    })?;
1335    shadow.insert(app.clone(), patched_models.clone());
1336
1337    // Patch admin.rs (required — the app must re-register the model).
1338    let admin_path = project.root.join("apps").join(&app).join("admin.rs");
1339    let admin_source =
1340        std::fs::read_to_string(&admin_path).map_err(|e| ExecutionError::IoError {
1341            path: admin_path.display().to_string(),
1342            message: e.to_string(),
1343        })?;
1344    let admin_patched =
1345        patch_admin_for_rename_model(&admin_source, &r.from, &r.to).map_err(|msg| {
1346            ExecutionError::FileConflict {
1347                path: admin_path.display().to_string(),
1348                reason: msg,
1349            }
1350        })?;
1351
1352    // Patch views.rs best-effort (identifier boundaries only). Only
1353    // emit a change if the file exists and actually contains the old
1354    // name as a standalone identifier.
1355    let views_path = project.root.join("apps").join(&app).join("views.rs");
1356    let views_change: Option<PlannedFileChange> = if views_path.is_file() {
1357        let views_source =
1358            std::fs::read_to_string(&views_path).map_err(|e| ExecutionError::IoError {
1359                path: views_path.display().to_string(),
1360                message: e.to_string(),
1361            })?;
1362        let patched_views = rename_identifier_bounded(&views_source, &r.from, &r.to);
1363        if patched_views != views_source {
1364            Some(PlannedFileChange {
1365                path: views_path,
1366                kind: FileChangeKind::Update,
1367                new_contents: patched_views,
1368                expected_current_contents: Some(views_source),
1369            })
1370        } else {
1371            None
1372        }
1373    } else {
1374        None
1375    };
1376
1377    let sql = format!(
1378        "-- Generated by rustio ai apply (0.5.3). DO NOT EDIT.\n\
1379         ALTER TABLE {old_table} RENAME TO {new_table};\n"
1380    );
1381    let mig_name = format!("rename_{old_table}_to_{new_table}");
1382    let (mig_path, mig_filename) = new_migration_path(project, *migration_counter, &mig_name);
1383    *migration_counter += 1;
1384
1385    let mut changes: Vec<PlannedFileChange> = vec![
1386        PlannedFileChange {
1387            path: project.root.join("apps").join(&app).join("models.rs"),
1388            kind: FileChangeKind::Update,
1389            new_contents: patched_models,
1390            expected_current_contents: Some(initial_source),
1391        },
1392        PlannedFileChange {
1393            path: admin_path,
1394            kind: FileChangeKind::Update,
1395            new_contents: admin_patched,
1396            expected_current_contents: Some(admin_source),
1397        },
1398    ];
1399    if let Some(vc) = views_change {
1400        changes.push(vc);
1401    }
1402    changes.push(PlannedFileChange {
1403        path: mig_path,
1404        kind: FileChangeKind::Create,
1405        new_contents: sql,
1406        expected_current_contents: None,
1407    });
1408
1409    Ok((
1410        changes,
1411        format!(
1412            "~ Rename model \"{from}\" to \"{to}\" (migration {mig})\n\
1413             \x20   ⚠ Table renamed from `{old_table}` to `{new_table}`. User code using `{from}` outside apps/{app}/ must be updated manually.",
1414            from = r.from,
1415            to = r.to,
1416            mig = mig_filename,
1417        ),
1418    ))
1419}
1420
1421// ---------------------------------------------------------------------------
1422// SQLite recreate-table helper
1423// ---------------------------------------------------------------------------
1424
1425/// Build the standard SQLite recreate-table migration for an in-place
1426/// schema change: create a `<table>__new` with the target shape, copy
1427/// rows via `INSERT … SELECT …` (with per-column expressions for type
1428/// casts or nullability defaults), drop the old table, rename the new
1429/// one back. This is the *only* pattern SQLite supports for changes
1430/// that ALTER TABLE won't accept.
1431///
1432/// Caller guarantees: `new_fields` contains every column the target
1433/// table should have (preserving order). Columns missing from
1434/// `source_exprs` are copied by name from the old table.
1435fn generate_sqlite_recreate_table_migration(
1436    table: &str,
1437    new_fields: &[SchemaField],
1438    source_exprs: &BTreeMap<String, String>,
1439) -> String {
1440    let new_table = format!("{table}__new");
1441    let mut out = String::new();
1442    out.push_str("-- Generated by rustio ai apply (0.5.3). DO NOT EDIT.\n");
1443    out.push_str("-- SQLite recreate-table pattern: SQLite cannot ALTER COLUMN in place.\n");
1444    out.push_str(&format!("CREATE TABLE {new_table} (\n"));
1445    for (i, f) in new_fields.iter().enumerate() {
1446        out.push_str("    ");
1447        out.push_str(&column_def(f));
1448        if i + 1 < new_fields.len() {
1449            out.push(',');
1450        }
1451        out.push('\n');
1452    }
1453    out.push_str(");\n\n");
1454
1455    let col_list = new_fields
1456        .iter()
1457        .map(|f| f.name.clone())
1458        .collect::<Vec<_>>()
1459        .join(", ");
1460    let expr_list = new_fields
1461        .iter()
1462        .map(|f| {
1463            source_exprs
1464                .get(&f.name)
1465                .cloned()
1466                .unwrap_or_else(|| f.name.clone())
1467        })
1468        .collect::<Vec<_>>()
1469        .join(", ");
1470    out.push_str(&format!(
1471        "INSERT INTO {new_table} ({col_list})\nSELECT {expr_list}\nFROM {table};\n\n"
1472    ));
1473    out.push_str(&format!("DROP TABLE {table};\n\n"));
1474    out.push_str(&format!("ALTER TABLE {new_table} RENAME TO {table};\n"));
1475    out
1476}
1477
1478/// One column DDL for recreate-table. `id INTEGER PRIMARY KEY
1479/// AUTOINCREMENT` is the special case every scaffolded table uses.
1480///
1481/// 0.9.0: if the field carries a `Relation` with an `on_delete` policy,
1482/// a trailing `REFERENCES <parent>(id) ON DELETE <policy>` is appended.
1483/// The caller is responsible for passing a schema that has those
1484/// populated — schema files from 0.8.x have `on_delete: None` and so
1485/// the FK clause is skipped (backward-compat: old schemas produce the
1486/// same output they always have).
1487fn column_def(f: &SchemaField) -> String {
1488    column_def_with_relation_context(f, fallback_table_name)
1489}
1490
1491fn column_def_with_relation_context(
1492    f: &SchemaField,
1493    resolve_parent_table: impl Fn(&str) -> Option<String>,
1494) -> String {
1495    let sql_ty = sql_type_for(&f.ty);
1496    if f.name == "id" && f.ty == "i64" && !f.nullable {
1497        return "id INTEGER PRIMARY KEY AUTOINCREMENT".to_string();
1498    }
1499    let suffix = if f.nullable {
1500        String::new()
1501    } else {
1502        format!(" NOT NULL DEFAULT {}", safe_default_literal(&f.ty))
1503    };
1504    let fk_clause = f
1505        .relation
1506        .as_ref()
1507        .and_then(|rel| {
1508            let policy = rel.on_delete.as_deref()?;
1509            let parent_table = resolve_parent_table(&rel.model)?;
1510            Some(format!(
1511                " REFERENCES {parent}({target_col}) ON DELETE {policy}",
1512                parent = parent_table,
1513                target_col = if rel.field.is_empty() {
1514                    "id"
1515                } else {
1516                    &rel.field
1517                },
1518                policy = policy.to_uppercase().replace('_', " "),
1519            ))
1520        })
1521        .unwrap_or_default();
1522    format!("{} {}{}{}", f.name, sql_ty, suffix, fk_clause)
1523}
1524
1525/// Refuse recreate-table on any table that participates in foreign
1526/// keys — outgoing (declared inside the table) or incoming (referenced
1527/// by another table). The recreate pattern DROPs the table, which
1528/// would cascade-delete dependent rows under PRAGMA foreign_keys=ON.
1529/// 0.6.0 is scheduled to handle FK rewriting.
1530fn table_has_any_foreign_key(project: &ProjectView, table: &str) -> bool {
1531    let lt = table.to_lowercase();
1532    for contents in project.migration_sources.values() {
1533        let c = contents.to_lowercase();
1534        // Incoming reference from any migration.
1535        if c.contains(&format!("references {lt}")) || c.contains(&format!("references {lt}(")) {
1536            return true;
1537        }
1538        // Outgoing FK in this table's own CREATE.
1539        let create_needles = [
1540            format!("create table {lt} ("),
1541            format!("create table if not exists {lt} ("),
1542        ];
1543        for needle in &create_needles {
1544            if let Some(start) = c.find(needle) {
1545                let tail = &c[start..];
1546                if let Some(end) = tail.find(");") {
1547                    if tail[..end].contains("foreign key") {
1548                        return true;
1549                    }
1550                }
1551            }
1552        }
1553    }
1554    false
1555}
1556
1557/// Replace `from` with `to` only at identifier boundaries (byte before
1558/// and after are not identifier chars). Used for the bounded rename
1559/// sweep in `views.rs` so we don't clobber substrings inside string
1560/// literals or comments that happen to contain the old name.
1561fn rename_identifier_bounded(src: &str, from: &str, to: &str) -> String {
1562    let bytes = src.as_bytes();
1563    let from_bytes = from.as_bytes();
1564    let n = from_bytes.len();
1565    if n == 0 {
1566        return src.to_string();
1567    }
1568    let mut out = String::with_capacity(src.len());
1569    let mut i = 0;
1570    let mut last = 0;
1571    while i + n <= bytes.len() {
1572        if &bytes[i..i + n] == from_bytes {
1573            let left_ok = i == 0 || !is_ident_byte(bytes[i - 1]);
1574            let right_ok = i + n == bytes.len() || !is_ident_byte(bytes[i + n]);
1575            if left_ok && right_ok {
1576                out.push_str(&src[last..i]);
1577                out.push_str(to);
1578                i += n;
1579                last = i;
1580                continue;
1581            }
1582        }
1583        i += 1;
1584    }
1585    out.push_str(&src[last..]);
1586    out
1587}
1588
1589fn is_ident_byte(b: u8) -> bool {
1590    b.is_ascii_alphanumeric() || b == b'_'
1591}
1592
1593/// Shadow-apply a primitive to a schema copy so later steps in the
1594/// same plan see the earlier step's shape change. Mirrors the review
1595/// layer's logic but is executor-internal so we aren't coupled to
1596/// review's visibility rules.
1597fn apply_schema_shadow(p: &Primitive, schema: &mut Schema) {
1598    match p {
1599        Primitive::AddField(a) => {
1600            if let Some(m) = schema.models.iter_mut().find(|m| m.name == a.model) {
1601                m.fields.push(SchemaField {
1602                    name: a.field.name.clone(),
1603                    ty: a.field.ty.clone(),
1604                    nullable: a.field.nullable,
1605                    editable: a.field.editable,
1606                    relation: None,
1607                });
1608            }
1609        }
1610        Primitive::RenameField(r) => {
1611            if let Some(m) = schema.models.iter_mut().find(|m| m.name == r.model) {
1612                if let Some(f) = m.fields.iter_mut().find(|f| f.name == r.from) {
1613                    f.name = r.to.clone();
1614                }
1615            }
1616        }
1617        Primitive::ChangeFieldType(c) => {
1618            if let Some(m) = schema.models.iter_mut().find(|m| m.name == c.model) {
1619                if let Some(f) = m.fields.iter_mut().find(|f| f.name == c.field) {
1620                    f.ty = c.new_type.clone();
1621                }
1622            }
1623        }
1624        Primitive::ChangeFieldNullability(c) => {
1625            if let Some(m) = schema.models.iter_mut().find(|m| m.name == c.model) {
1626                if let Some(f) = m.fields.iter_mut().find(|f| f.name == c.field) {
1627                    f.nullable = c.nullable;
1628                }
1629            }
1630        }
1631        Primitive::RenameModel(r) => {
1632            if let Some(m) = schema.models.iter_mut().find(|m| m.name == r.from) {
1633                m.name = r.to.clone();
1634            }
1635        }
1636        Primitive::AddRelation(r) => {
1637            use crate::schema::{Relation, RelationKind};
1638            if !matches!(r.kind, RelationKind::BelongsTo) {
1639                return;
1640            }
1641            if let Some(m) = schema.models.iter_mut().find(|m| m.name == r.from) {
1642                if m.fields.iter().any(|f| f.name == r.via) {
1643                    return;
1644                }
1645                m.fields.push(SchemaField {
1646                    name: r.via.clone(),
1647                    ty: "i64".to_string(),
1648                    nullable: !r.required,
1649                    editable: true,
1650                    relation: Some(Relation {
1651                        model: r.to.clone(),
1652                        field: "id".to_string(),
1653                        kind: RelationKind::BelongsTo,
1654                        // AI-planner-authored relations don't declare a
1655                        // display field: the admin renders `#<id>` until
1656                        // the model author opts in via the macro.
1657                        display_field: None,
1658                        // 0.9.0 — propagate FK metadata into the schema
1659                        // snapshot so `rustio.schema.json` reflects what
1660                        // the migration just committed.
1661                        required: Some(r.required),
1662                        on_delete: Some(r.on_delete.as_str().to_string()),
1663                    }),
1664                });
1665            }
1666        }
1667        _ => {}
1668    }
1669}
1670
1671/// Return a human-readable reason string if `step` violates a policy
1672/// under the given context; `None` means the step is allowed.
1673/// Conservative by design — the list grows only as each rule is
1674/// justified.
1675fn policy_violation_for(step: &Primitive, pii: &[&str], ctx: &ContextConfig) -> Option<String> {
1676    let ctx_tag = {
1677        let mut parts: Vec<String> = Vec::new();
1678        if let Some(c) = &ctx.country {
1679            parts.push(format!("country={c}"));
1680        }
1681        if let Some(i) = &ctx.industry {
1682            parts.push(format!("industry={i}"));
1683        }
1684        if ctx.requires_gdpr() {
1685            parts.push("GDPR".to_string());
1686        }
1687        if parts.is_empty() {
1688            String::new()
1689        } else {
1690            format!(" ({})", parts.join(", "))
1691        }
1692    };
1693    match step {
1694        Primitive::RemoveField(r) if pii.iter().any(|p| *p == r.field) => Some(format!(
1695            "refusing to remove `{}.{}` — it is personally-identifying data under the project context{}. Change the context or update the plan by hand.",
1696            r.model, r.field, ctx_tag,
1697        )),
1698        Primitive::ChangeFieldType(c) if pii.iter().any(|p| *p == c.field) => Some(format!(
1699            "refusing to change the type of `{}.{}` — it is personally-identifying data under the project context{}; retention / hashing pipelines depend on the stored shape.",
1700            c.model, c.field, ctx_tag,
1701        )),
1702        Primitive::RenameField(r) if pii.iter().any(|p| *p == r.from) => Some(format!(
1703            "refusing to rename `{}.{}` — it is personally-identifying data under the project context{}; audit trails keyed on the old name would break.",
1704            r.model, r.from, ctx_tag,
1705        )),
1706        _ => None,
1707    }
1708}
1709
1710// ---------------------------------------------------------------------------
1711// models.rs patching
1712// ---------------------------------------------------------------------------
1713
1714/// 0.9.1 — inverse of [`patch_models_for_add_field`]. Strips a field's
1715/// struct declaration, its entries in `COLUMNS` / `INSERT_COLUMNS`, its
1716/// `from_row` accessor line, and its `insert_values` line.
1717///
1718/// Every sub-step uses exact-match substring removal and reports a
1719/// clean error when the expected shape isn't present. The failure mode
1720/// matches the add path: refuse, don't half-remove.
1721fn patch_models_for_remove_field(
1722    source: &str,
1723    struct_name: &str,
1724    field_name: &str,
1725    field_ty: &str,
1726    nullable: bool,
1727) -> Result<String, String> {
1728    let mut out = source.to_string();
1729
1730    // 1. Struct field line.
1731    let rust_type = rust_type_for(field_ty, nullable);
1732    let struct_line = format!("    pub {field_name}: {rust_type},\n");
1733    out = replace_in_struct_literal(&out, struct_name, &struct_line, "")?;
1734
1735    // 2. COLUMNS array. Scoped to the matching `impl Model for <struct>`
1736    // block so files with multiple models don't cross-contaminate.
1737    out = remove_from_str_array_scoped(&out, struct_name, "COLUMNS", field_name)?;
1738
1739    // 3. INSERT_COLUMNS (not populated for auto-fields like `id`).
1740    // Best-effort: ignore the error if the field wasn't listed.
1741    if out.contains("const INSERT_COLUMNS") {
1742        out = remove_from_str_array_scoped(&out, struct_name, "INSERT_COLUMNS", field_name)
1743            .unwrap_or(out);
1744    }
1745
1746    // 4. from_row accessor — scoped to the struct's impl block so a
1747    // same-named field in a sibling model isn't stripped by accident.
1748    let accessor = row_accessor(field_ty, nullable);
1749    let from_row_line = format!("            {field_name}: row.{accessor}(\"{field_name}\")?,\n",);
1750    out = replace_in_impl_method_literal(
1751        &out,
1752        struct_name,
1753        "fn from_row(",
1754        "Ok(Self {",
1755        &from_row_line,
1756        "",
1757    )?;
1758
1759    // 5. insert_values line — same scoping rationale.
1760    let insert_line = build_insert_values_line(field_name, field_ty, nullable);
1761    out = replace_in_impl_method_literal(
1762        &out,
1763        struct_name,
1764        "fn insert_values(",
1765        "vec![",
1766        &insert_line,
1767        "",
1768    )?;
1769
1770    Ok(out)
1771}
1772
1773/// 0.9.1 — generic in-place replace inside a specific method body of a
1774/// specific `impl Model for <struct>` block. The method is located by
1775/// `fn_anchor` (e.g. `"fn from_row("`); the body delimiter is the
1776/// innermost brace/bracket after that, found via `body_open`
1777/// (`"Ok(Self {"` or `"vec!["`). Replaces the first occurrence of
1778/// `from` inside the body with `to`, failing cleanly if absent.
1779fn replace_in_impl_method_literal(
1780    src: &str,
1781    struct_name: &str,
1782    fn_anchor: &str,
1783    body_open: &str,
1784    from: &str,
1785    to: &str,
1786) -> Result<String, String> {
1787    let impl_anchor = format!("impl Model for {struct_name}");
1788    let impl_start = src
1789        .find(&impl_anchor)
1790        .ok_or_else(|| format!("could not find `{impl_anchor}`"))?;
1791    let impl_brace_rel = src[impl_start..]
1792        .find('{')
1793        .ok_or_else(|| format!("`{impl_anchor}` has no opening brace"))?;
1794    let impl_open = impl_start + impl_brace_rel;
1795    let impl_close = find_matching_brace(src, impl_open)
1796        .ok_or_else(|| format!("`{impl_anchor}` is not closed"))?;
1797
1798    let block = &src[impl_open..=impl_close];
1799    let fn_rel = block
1800        .find(fn_anchor)
1801        .ok_or_else(|| format!("`{fn_anchor}` not found inside `{impl_anchor}`"))?;
1802    let body_rel_in_fn = block[fn_rel..]
1803        .find(body_open)
1804        .ok_or_else(|| format!("`{body_open}` not found after `{fn_anchor}`"))?;
1805    let body_open_abs = impl_open + fn_rel + body_rel_in_fn + body_open.len() - 1;
1806    // `body_open.len() - 1` lands on the opening bracket/brace itself.
1807    let body_close_abs = match src.as_bytes()[body_open_abs] {
1808        b'{' => find_matching_brace(src, body_open_abs),
1809        b'[' => find_matching_bracket(src, body_open_abs),
1810        _ => None,
1811    }
1812    .ok_or_else(|| format!("unterminated body after `{body_open}`"))?;
1813
1814    let body = &src[body_open_abs + 1..body_close_abs];
1815    if !body.contains(from) {
1816        return Err(format!(
1817            "{fn_anchor} body on `{struct_name}` does not contain `{from}`"
1818        ));
1819    }
1820    let new_body = body.replacen(from, to, 1);
1821    let mut out = String::with_capacity(src.len());
1822    out.push_str(&src[..=body_open_abs]);
1823    out.push_str(&new_body);
1824    out.push_str(&src[body_close_abs..]);
1825    Ok(out)
1826}
1827
1828/// 0.9.1 — same as [`remove_from_str_array`] but restricts the search
1829/// to the `impl Model for <struct>` block so files that declare
1830/// multiple models don't cross-contaminate.
1831fn remove_from_str_array_scoped(
1832    src: &str,
1833    struct_name: &str,
1834    const_name: &str,
1835    field: &str,
1836) -> Result<String, String> {
1837    let impl_anchor = format!("impl Model for {struct_name}");
1838    let impl_start = src
1839        .find(&impl_anchor)
1840        .ok_or_else(|| format!("could not find `{impl_anchor}`"))?;
1841    let brace_rel = src[impl_start..]
1842        .find('{')
1843        .ok_or_else(|| format!("`{impl_anchor}` has no opening brace"))?;
1844    let impl_open = impl_start + brace_rel;
1845    let impl_close = find_matching_brace(src, impl_open)
1846        .ok_or_else(|| format!("`{impl_anchor}` is not closed"))?;
1847
1848    // Run the array removal only on the block's text, then splice it
1849    // back into the file.
1850    let block = &src[impl_open..=impl_close];
1851    let new_block = remove_from_str_array(block, const_name, field)?;
1852    let mut out = String::with_capacity(src.len() + new_block.len() - block.len());
1853    out.push_str(&src[..impl_open]);
1854    out.push_str(&new_block);
1855    out.push_str(&src[impl_close + 1..]);
1856    Ok(out)
1857}
1858
1859/// 0.9.1 — drop an exact `"<field>"` entry from the named
1860/// `const X: &[&str]` array. Works on both single-line arrays
1861/// (`&["a", "b", "c"]`) and multi-line arrays (one literal per line,
1862/// which is what the scaffolder generates once the list grows). Handles
1863/// the surrounding comma so the resulting array stays valid Rust.
1864/// Errors cleanly if the field is not present.
1865fn remove_from_str_array(src: &str, const_name: &str, field: &str) -> Result<String, String> {
1866    let anchor = format!("const {const_name}");
1867    let start = src
1868        .find(&anchor)
1869        .ok_or_else(|| format!("could not find `const {const_name}`"))?;
1870    let rel_open = src[start..]
1871        .find("= &[")
1872        .ok_or_else(|| format!("`const {const_name}` does not use `= &[ … ]`"))?;
1873    let open = start + rel_open + "= &".len();
1874    let close = find_matching_bracket(src, open)
1875        .ok_or_else(|| format!("`const {const_name}` array is not closed"))?;
1876    let inner = &src[open + 1..close];
1877    let literal = format!("\"{field}\"");
1878    let literal_idx = inner
1879        .find(&literal)
1880        .ok_or_else(|| format!("`{const_name}` does not contain \"{field}\""))?;
1881
1882    // Expand the removal slice to cover the separator surrounding the
1883    // literal, whether the separator is `, ` (single-line), `,\n    `
1884    // (multi-line interior), or a leading newline+indent followed by
1885    // no trailing separator (multi-line final element). The rule:
1886    //   - Start at the literal's first byte.
1887    //   - End at the next non-whitespace byte AFTER the comma that
1888    //     follows the literal. If there is no trailing comma (last
1889    //     element), rewind the start to just after the previous
1890    //     non-whitespace byte so we drop the leading `,` instead.
1891    let mut slice_start = literal_idx;
1892    let mut slice_end = literal_idx + literal.len();
1893
1894    // Scan right looking for the next comma + any following whitespace.
1895    let after_literal = &inner.as_bytes()[slice_end..];
1896    if let Some(comma_rel) = after_literal.iter().position(|&b| !b.is_ascii_whitespace()) {
1897        if after_literal[comma_rel] == b',' {
1898            slice_end += comma_rel + 1;
1899            // Consume trailing whitespace after the comma too so we
1900            // don't leave a stranded blank line in the multi-line form.
1901            while slice_end < inner.len()
1902                && (inner.as_bytes()[slice_end] == b' ' || inner.as_bytes()[slice_end] == b'\t')
1903            {
1904                slice_end += 1;
1905            }
1906            if slice_end < inner.len() && inner.as_bytes()[slice_end] == b'\n' {
1907                // Multi-line case: keep exactly one newline + indent
1908                // before the next element by *not* consuming the \n.
1909                // (The leading `    ` indent of the next element was
1910                // already consumed by the trailing-ws loop above.)
1911            }
1912        }
1913    } else {
1914        // No trailing comma — this was the last element. Walk left
1915        // from the literal to the previous comma and eat it.
1916        let before = &inner.as_bytes()[..slice_start];
1917        if let Some(pos) = before.iter().rposition(|&b| !b.is_ascii_whitespace()) {
1918            if before[pos] == b',' {
1919                slice_start = pos;
1920            }
1921        }
1922    }
1923
1924    let mut new_inner = String::with_capacity(inner.len());
1925    new_inner.push_str(&inner[..slice_start]);
1926    new_inner.push_str(&inner[slice_end..]);
1927
1928    // Collapse the blank line that multi-line arrays leave behind when
1929    // a middle element is removed (`"x",\n        \n        "y",` →
1930    // `"x",\n        "y",`). The needle is `\n<horizontal-ws>\n` —
1931    // drop the whitespace-only line by removing the leading
1932    // whitespace + its trailing newline.
1933    let mut cursor = 0;
1934    while let Some(rel) = new_inner[cursor..].find('\n') {
1935        let pos = cursor + rel;
1936        let after = &new_inner[pos + 1..];
1937        let lead_ws = after
1938            .bytes()
1939            .take_while(|&b| b == b' ' || b == b'\t')
1940            .count();
1941        if after
1942            .as_bytes()
1943            .get(lead_ws)
1944            .map(|&b| b == b'\n')
1945            .unwrap_or(false)
1946        {
1947            let drain_end = pos + 1 + lead_ws + 1;
1948            new_inner.drain(pos + 1..drain_end);
1949            // Don't advance — the collapse might have produced a
1950            // second blank line.
1951        } else {
1952            cursor = pos + 1;
1953        }
1954    }
1955
1956    let mut out = String::with_capacity(src.len());
1957    out.push_str(&src[..=open]);
1958    out.push_str(&new_inner);
1959    out.push_str(&src[close..]);
1960    Ok(out)
1961}
1962
1963fn patch_models_for_add_field(
1964    source: &str,
1965    struct_name: &str,
1966    field: &FieldSpec,
1967) -> Result<String, String> {
1968    let rust_type = rust_type_for(&field.ty, field.nullable);
1969    let mut out = source.to_string();
1970
1971    // 1. Make sure chrono is imported when we're adding a DateTime.
1972    // Check for an actual `use chrono::` statement, not the string
1973    // "chrono::" anywhere — the scaffolded docstring mentions
1974    // `chrono::DateTime<Utc>` in comments, which would otherwise
1975    // fool the check and leave the file without an import.
1976    if field.ty == "DateTime" && !has_chrono_use(&out) {
1977        out = insert_chrono_import(&out);
1978    }
1979
1980    // 2. Struct field.
1981    let field_line = format!("    pub {}: {},\n", field.name, rust_type);
1982    out = insert_before_struct_close(&out, struct_name, &field_line)?;
1983
1984    // 3. COLUMNS.
1985    out = insert_into_str_array(&out, "COLUMNS", &field.name)?;
1986    // 4. INSERT_COLUMNS (best-effort; some models may skip it for
1987    // auto-populated fields like id).
1988    if out.contains("const INSERT_COLUMNS") {
1989        out = insert_into_str_array(&out, "INSERT_COLUMNS", &field.name)?;
1990    }
1991
1992    // 5. from_row accessor.
1993    let accessor = row_accessor(&field.ty, field.nullable);
1994    let from_row_line = format!(
1995        "            {name}: row.{accessor}(\"{name}\")?,\n",
1996        name = field.name,
1997        accessor = accessor,
1998    );
1999    out = insert_before_ok_self_close(&out, &from_row_line)?;
2000
2001    // 6. insert_values.
2002    let insert_line = build_insert_values_line(&field.name, &field.ty, field.nullable);
2003    out = insert_before_vec_close(&out, &insert_line)?;
2004
2005    Ok(out)
2006}
2007
2008fn patch_models_for_rename_field(
2009    source: &str,
2010    struct_name: &str,
2011    from: &str,
2012    to: &str,
2013) -> Result<String, String> {
2014    let mut out = source.to_string();
2015
2016    // 1. Struct field name.
2017    out = rename_in_struct(&out, struct_name, from, to)?;
2018
2019    // 2. COLUMNS + INSERT_COLUMNS — match the exact "<from>" literal.
2020    out = replace_in_str_array(&out, "COLUMNS", from, to)?;
2021    if out.contains("const INSERT_COLUMNS") {
2022        // INSERT_COLUMNS may not contain the field (e.g. id is excluded).
2023        // `replace_in_str_array` is lenient: it only rewrites on match.
2024        out = replace_in_str_array(&out, "INSERT_COLUMNS", from, to).unwrap_or(out);
2025    }
2026
2027    // 3. from_row body: `<from>: row.get_X("<from>")?,` → `<to>: row.get_X("<to>")?,`
2028    out = rename_in_from_row(&out, from, to)?;
2029
2030    // 4. insert_values body: `self.<from>` → `self.<to>`
2031    out = rename_in_insert_values(&out, from, to)?;
2032
2033    Ok(out)
2034}
2035
2036/// Rewrite the struct field declaration, the `from_row` accessor, and
2037/// (for `String`) the `.clone()` call in `insert_values` so the Rust
2038/// side matches the new column type.
2039fn patch_models_for_change_field_type(
2040    source: &str,
2041    struct_name: &str,
2042    field_name: &str,
2043    old_ty: &str,
2044    new_ty: &str,
2045    nullable: bool,
2046) -> Result<String, String> {
2047    let mut out = source.to_string();
2048    // Ensure chrono import when introducing DateTime.
2049    if (new_ty == "DateTime") && !has_chrono_use(&out) {
2050        out = insert_chrono_import(&out);
2051    }
2052    // 1. Struct field line.
2053    let old_rust = rust_type_for(old_ty, nullable);
2054    let new_rust = rust_type_for(new_ty, nullable);
2055    out = replace_in_struct_literal(
2056        &out,
2057        struct_name,
2058        &format!("pub {field_name}: {old_rust},"),
2059        &format!("pub {field_name}: {new_rust},"),
2060    )?;
2061    // 2. from_row accessor.
2062    let old_acc = row_accessor(old_ty, nullable);
2063    let new_acc = row_accessor(new_ty, nullable);
2064    if old_acc != new_acc {
2065        out = replace_in_from_row_literal(
2066            &out,
2067            &format!("{field_name}: row.{old_acc}(\"{field_name}\")?,"),
2068            &format!("{field_name}: row.{new_acc}(\"{field_name}\")?,"),
2069        )?;
2070    }
2071    // 3. insert_values line — may gain/lose `.clone()` when moving
2072    // between `String` and Copy-able types.
2073    let old_line = build_insert_values_line(field_name, old_ty, nullable);
2074    let new_line = build_insert_values_line(field_name, new_ty, nullable);
2075    if old_line != new_line {
2076        let old_trim = old_line.trim().to_string();
2077        let new_trim = new_line.trim().to_string();
2078        out = replace_in_insert_values_literal(&out, &old_trim, &new_trim)?;
2079    }
2080    Ok(out)
2081}
2082
2083/// Flip the Rust-side shape for a nullability change. Struct field type
2084/// gains/loses `Option<…>`; the `from_row` accessor swaps between
2085/// `get_X` and `get_optional_X`. `insert_values` is unchanged — the
2086/// `From<Option<T>> for Value` blanket handles both shapes.
2087fn patch_models_for_change_nullability(
2088    source: &str,
2089    struct_name: &str,
2090    field_name: &str,
2091    ty: &str,
2092    was_nullable: bool,
2093    now_nullable: bool,
2094) -> Result<String, String> {
2095    let mut out = source.to_string();
2096    let old_rust = rust_type_for(ty, was_nullable);
2097    let new_rust = rust_type_for(ty, now_nullable);
2098    out = replace_in_struct_literal(
2099        &out,
2100        struct_name,
2101        &format!("pub {field_name}: {old_rust},"),
2102        &format!("pub {field_name}: {new_rust},"),
2103    )?;
2104    let old_acc = row_accessor(ty, was_nullable);
2105    let new_acc = row_accessor(ty, now_nullable);
2106    out = replace_in_from_row_literal(
2107        &out,
2108        &format!("{field_name}: row.{old_acc}(\"{field_name}\")?,"),
2109        &format!("{field_name}: row.{new_acc}(\"{field_name}\")?,"),
2110    )?;
2111    Ok(out)
2112}
2113
2114/// Update `models.rs` for a model rename: the struct name, the
2115/// `impl Model for …` header, and the `TABLE` const.
2116fn patch_models_for_rename_model(
2117    source: &str,
2118    old_struct: &str,
2119    new_struct: &str,
2120    old_table: &str,
2121    new_table: &str,
2122) -> Result<String, String> {
2123    let mut out = source.to_string();
2124
2125    let old_struct_decl = format!("pub struct {old_struct}");
2126    let new_struct_decl = format!("pub struct {new_struct}");
2127    if !out.contains(&old_struct_decl) {
2128        return Err(format!("struct `{old_struct}` not found"));
2129    }
2130    out = out.replacen(&old_struct_decl, &new_struct_decl, 1);
2131
2132    let old_impl = format!("impl Model for {old_struct}");
2133    let new_impl = format!("impl Model for {new_struct}");
2134    if out.contains(&old_impl) {
2135        out = out.replacen(&old_impl, &new_impl, 1);
2136    }
2137
2138    let old_tbl = format!("const TABLE: &'static str = \"{old_table}\";");
2139    let new_tbl = format!("const TABLE: &'static str = \"{new_table}\";");
2140    if out.contains(&old_tbl) {
2141        out = out.replacen(&old_tbl, &new_tbl, 1);
2142    }
2143    Ok(out)
2144}
2145
2146/// Update `admin.rs` for a model rename: `use super::models::Old;`
2147/// and `admin.model::<Old>()`.
2148fn patch_admin_for_rename_model(
2149    source: &str,
2150    old_struct: &str,
2151    new_struct: &str,
2152) -> Result<String, String> {
2153    let mut out = source.to_string();
2154    let old_use = format!("use super::models::{old_struct};");
2155    let new_use = format!("use super::models::{new_struct};");
2156    if out.contains(&old_use) {
2157        out = out.replacen(&old_use, &new_use, 1);
2158    }
2159    let old_call = format!("admin.model::<{old_struct}>()");
2160    let new_call = format!("admin.model::<{new_struct}>()");
2161    if !out.contains(&old_call) {
2162        return Err(format!(
2163            "`admin.rs` does not call `admin.model::<{old_struct}>()`"
2164        ));
2165    }
2166    out = out.replacen(&old_call, &new_call, 1);
2167    Ok(out)
2168}
2169
2170// --- tiny, targeted source-patching primitives ------------------------------
2171
2172/// Replace a literal substring inside the named struct block only.
2173/// Used by change-type / nullability patchers where we need the old
2174/// string to be matched exactly rather than by field-name heuristics.
2175fn replace_in_struct_literal(
2176    src: &str,
2177    struct_name: &str,
2178    from: &str,
2179    to: &str,
2180) -> Result<String, String> {
2181    let (open, close) = find_struct_block(src, struct_name)
2182        .ok_or_else(|| format!("struct `{struct_name}` block not found"))?;
2183    let block = &src[open..=close];
2184    if !block.contains(from) {
2185        return Err(format!("struct `{struct_name}` does not contain `{from}`"));
2186    }
2187    let new_block = block.replacen(from, to, 1);
2188    let mut out = String::with_capacity(src.len());
2189    out.push_str(&src[..open]);
2190    out.push_str(&new_block);
2191    out.push_str(&src[close + 1..]);
2192    Ok(out)
2193}
2194
2195fn replace_in_from_row_literal(src: &str, from: &str, to: &str) -> Result<String, String> {
2196    let fn_start = src
2197        .find("fn from_row(")
2198        .ok_or_else(|| "`fn from_row(` not found".to_string())?;
2199    let ok_self_rel = src[fn_start..]
2200        .find("Ok(Self {")
2201        .ok_or_else(|| "`Ok(Self {` not found".to_string())?;
2202    let ok_self_open = fn_start + ok_self_rel + "Ok(Self ".len();
2203    let ok_self_close = find_matching_brace(src, ok_self_open)
2204        .ok_or_else(|| "`Ok(Self { … }` is not closed".to_string())?;
2205    let block = &src[ok_self_open..=ok_self_close];
2206    if !block.contains(from) {
2207        return Err(format!("from_row does not contain `{from}`"));
2208    }
2209    let replaced = block.replacen(from, to, 1);
2210    let mut out = String::with_capacity(src.len());
2211    out.push_str(&src[..ok_self_open]);
2212    out.push_str(&replaced);
2213    out.push_str(&src[ok_self_close + 1..]);
2214    Ok(out)
2215}
2216
2217fn replace_in_insert_values_literal(src: &str, from: &str, to: &str) -> Result<String, String> {
2218    let fn_start = src
2219        .find("fn insert_values(")
2220        .ok_or_else(|| "`fn insert_values(` not found".to_string())?;
2221    let vec_rel = src[fn_start..]
2222        .find("vec![")
2223        .ok_or_else(|| "no `vec![` inside insert_values".to_string())?;
2224    let vec_open = fn_start + vec_rel + 4;
2225    let vec_close = find_matching_bracket(src, vec_open)
2226        .ok_or_else(|| "`vec![ … ]` is not closed".to_string())?;
2227    let block = &src[vec_open..=vec_close];
2228    if !block.contains(from) {
2229        return Err(format!("insert_values does not contain `{from}`"));
2230    }
2231    let replaced = block.replacen(from, to, 1);
2232    let mut out = String::with_capacity(src.len());
2233    out.push_str(&src[..vec_open]);
2234    out.push_str(&replaced);
2235    out.push_str(&src[vec_close + 1..]);
2236    Ok(out)
2237}
2238
2239fn find_struct_block(src: &str, name: &str) -> Option<(usize, usize)> {
2240    let anchor = format!("pub struct {name}");
2241    let start = src.find(&anchor)?;
2242    // Guard against substring matches (`TaskExtra` when looking for `Task`):
2243    // the next char after the name must be whitespace or `{` or `<`.
2244    let after_name = start + anchor.len();
2245    match src.as_bytes().get(after_name)? {
2246        b' ' | b'{' | b'\t' | b'\n' | b'<' => {}
2247        _ => return None,
2248    }
2249    let open = start + src[start..].find('{')?;
2250    let close = find_matching_brace(src, open)?;
2251    Some((open, close))
2252}
2253
2254fn find_matching_brace(src: &str, open_idx: usize) -> Option<usize> {
2255    let bytes = src.as_bytes();
2256    if *bytes.get(open_idx)? != b'{' {
2257        return None;
2258    }
2259    let mut depth: i32 = 0;
2260    let mut i = open_idx;
2261    while i < bytes.len() {
2262        match bytes[i] {
2263            b'{' => depth += 1,
2264            b'}' => {
2265                depth -= 1;
2266                if depth == 0 {
2267                    return Some(i);
2268                }
2269            }
2270            _ => {}
2271        }
2272        i += 1;
2273    }
2274    None
2275}
2276
2277fn struct_declares_field(inside_struct: &str, field_name: &str) -> bool {
2278    // Match `pub <field>:` or `pub <field> :`. Line-scoped.
2279    for line in inside_struct.lines() {
2280        let trimmed = line.trim_start();
2281        if let Some(rest) = trimmed.strip_prefix("pub ") {
2282            let rest = rest.trim_start();
2283            // Identifier then optional whitespace then ":"
2284            let mut chars = rest.chars();
2285            let mut ident = String::new();
2286            for ch in chars.by_ref() {
2287                if ch.is_ascii_alphanumeric() || ch == '_' {
2288                    ident.push(ch);
2289                } else {
2290                    break;
2291                }
2292            }
2293            if ident == field_name {
2294                let rest = rest.trim_start_matches(&ident[..]).trim_start();
2295                if rest.starts_with(':') {
2296                    return true;
2297                }
2298            }
2299        }
2300    }
2301    false
2302}
2303
2304fn insert_before_struct_close(
2305    src: &str,
2306    struct_name: &str,
2307    new_line: &str,
2308) -> Result<String, String> {
2309    let (_open, close) = find_struct_block(src, struct_name)
2310        .ok_or_else(|| format!("could not locate `pub struct {struct_name}` block"))?;
2311    insert_before_brace(src, close, new_line)
2312}
2313
2314fn insert_before_ok_self_close(src: &str, new_line: &str) -> Result<String, String> {
2315    // Find "Ok(Self {" inside a `fn from_row` body — simple string
2316    // search is good enough because the token is distinctive in the
2317    // scaffold template. Refuse if we see more than one occurrence.
2318    let needle = "Ok(Self {";
2319    let first = src
2320        .find(needle)
2321        .ok_or_else(|| "could not locate `Ok(Self {` in from_row".to_string())?;
2322    if src[first + needle.len()..].contains(needle) {
2323        return Err("multiple `Ok(Self {` in file; refusing to choose".into());
2324    }
2325    let open = first + needle.len() - 1; // index of `{`
2326    let close = find_matching_brace(src, open)
2327        .ok_or_else(|| "`Ok(Self { … }` is not closed".to_string())?;
2328    insert_before_brace(src, close, new_line)
2329}
2330
2331fn insert_before_vec_close(src: &str, new_line: &str) -> Result<String, String> {
2332    // Find `fn insert_values(` then `vec![` then the matching `]`.
2333    let fn_idx = src
2334        .find("fn insert_values(")
2335        .ok_or_else(|| "could not locate `fn insert_values(`".to_string())?;
2336    let vec_rel = src[fn_idx..]
2337        .find("vec![")
2338        .ok_or_else(|| "no `vec![` inside `insert_values`".to_string())?;
2339    let vec_open = fn_idx + vec_rel + 4; // index of `[`
2340    let close = find_matching_bracket(src, vec_open)
2341        .ok_or_else(|| "`vec![ … ]` is not closed".to_string())?;
2342    insert_before_bracket(src, close, new_line)
2343}
2344
2345fn find_matching_bracket(src: &str, open_idx: usize) -> Option<usize> {
2346    let bytes = src.as_bytes();
2347    if *bytes.get(open_idx)? != b'[' {
2348        return None;
2349    }
2350    let mut depth: i32 = 0;
2351    let mut i = open_idx;
2352    while i < bytes.len() {
2353        match bytes[i] {
2354            b'[' => depth += 1,
2355            b']' => {
2356                depth -= 1;
2357                if depth == 0 {
2358                    return Some(i);
2359                }
2360            }
2361            _ => {}
2362        }
2363        i += 1;
2364    }
2365    None
2366}
2367
2368fn insert_before_brace(src: &str, close: usize, new_line: &str) -> Result<String, String> {
2369    let before = &src[..close];
2370    let last_nl = before.rfind('\n').ok_or_else(|| {
2371        "refusing to patch single-line `{ … }`: file layout is outside the 0.5.2 safe subset"
2372            .to_string()
2373    })?;
2374    let mut out = String::with_capacity(src.len() + new_line.len());
2375    out.push_str(&src[..=last_nl]);
2376    out.push_str(new_line);
2377    if !new_line.ends_with('\n') {
2378        out.push('\n');
2379    }
2380    out.push_str(&src[last_nl + 1..]);
2381    Ok(out)
2382}
2383
2384fn insert_before_bracket(src: &str, close: usize, new_line: &str) -> Result<String, String> {
2385    let before = &src[..close];
2386    let last_nl = before.rfind('\n').ok_or_else(|| {
2387        "refusing to patch single-line `vec![ … ]`: outside the safe subset".to_string()
2388    })?;
2389    let mut out = String::with_capacity(src.len() + new_line.len());
2390    out.push_str(&src[..=last_nl]);
2391    out.push_str(new_line);
2392    if !new_line.ends_with('\n') {
2393        out.push('\n');
2394    }
2395    out.push_str(&src[last_nl + 1..]);
2396    Ok(out)
2397}
2398
2399fn insert_into_str_array(src: &str, const_name: &str, column: &str) -> Result<String, String> {
2400    let anchor = format!("const {const_name}");
2401    let start = src
2402        .find(&anchor)
2403        .ok_or_else(|| format!("could not find `const {const_name}`"))?;
2404    // Skip past the type annotation (e.g. `&'static [&'static str]`) to
2405    // the literal `= &[ … ]`. Looking for `= &[` is precise enough for
2406    // the scaffold's layout and refuses exotic formats loudly.
2407    let rel_open = src[start..]
2408        .find("= &[")
2409        .ok_or_else(|| format!("`const {const_name}` does not use the expected `= &[ … ]` form"))?;
2410    let open = start + rel_open + "= &".len();
2411    let close = find_matching_bracket(src, open)
2412        .ok_or_else(|| format!("`const {const_name}` array is not closed"))?;
2413    let inner = &src[open + 1..close];
2414    if inner.contains(&format!("\"{column}\"")) {
2415        return Err(format!(
2416            "`{const_name}` already contains \"{column}\"; refusing to duplicate"
2417        ));
2418    }
2419    // Build the new inner content.
2420    let trimmed = inner.trim_end_matches(|c: char| c.is_whitespace() || c == ',');
2421    let addition = if trimmed.trim().is_empty() {
2422        format!("\"{column}\"")
2423    } else {
2424        format!("{trimmed}, \"{column}\"")
2425    };
2426    // Preserve any trailing whitespace (newline indent) between the
2427    // last element and the closing bracket for multi-line arrays.
2428    let tail_ws_start = inner
2429        .rfind(|c: char| !c.is_whitespace() && c != ',')
2430        .map(|i| i + 1)
2431        .unwrap_or(0);
2432    let tail_ws = &inner[tail_ws_start..];
2433    let mut out = String::with_capacity(src.len() + column.len() + 4);
2434    out.push_str(&src[..=open]);
2435    out.push_str(&addition);
2436    out.push_str(tail_ws);
2437    out.push_str(&src[close..]);
2438    Ok(out)
2439}
2440
2441fn replace_in_str_array(
2442    src: &str,
2443    const_name: &str,
2444    from: &str,
2445    to: &str,
2446) -> Result<String, String> {
2447    let anchor = format!("const {const_name}");
2448    let start = src
2449        .find(&anchor)
2450        .ok_or_else(|| format!("could not find `const {const_name}`"))?;
2451    let rel_open = src[start..]
2452        .find("= &[")
2453        .ok_or_else(|| format!("`const {const_name}` does not use the expected `= &[ … ]` form"))?;
2454    let open = start + rel_open + "= &".len();
2455    let close = find_matching_bracket(src, open)
2456        .ok_or_else(|| format!("`const {const_name}` array is not closed"))?;
2457    let inner = &src[open + 1..close];
2458    let from_literal = format!("\"{from}\"");
2459    let to_literal = format!("\"{to}\"");
2460    if !inner.contains(&from_literal) {
2461        return Err(format!(
2462            "`{const_name}` does not contain \"{from}\"; rename cannot proceed"
2463        ));
2464    }
2465    if inner.contains(&to_literal) {
2466        return Err(format!(
2467            "`{const_name}` already contains \"{to}\"; rename target is taken"
2468        ));
2469    }
2470    // Replace only inside the bracketed range so we don't clobber other
2471    // occurrences of the same string elsewhere in the file.
2472    let new_inner = inner.replacen(&from_literal, &to_literal, 1);
2473    let mut out = String::with_capacity(src.len());
2474    out.push_str(&src[..=open]);
2475    out.push_str(&new_inner);
2476    out.push_str(&src[close..]);
2477    Ok(out)
2478}
2479
2480fn rename_in_struct(src: &str, struct_name: &str, from: &str, to: &str) -> Result<String, String> {
2481    let (open, close) =
2482        find_struct_block(src, struct_name).ok_or_else(|| "struct block not found".to_string())?;
2483    let block = &src[open..=close];
2484    let from_pattern = format!("pub {from}:");
2485    let to_pattern = format!("pub {to}:");
2486    if !block.contains(&from_pattern) {
2487        return Err(format!(
2488            "struct {struct_name} does not declare `pub {from}:`"
2489        ));
2490    }
2491    let new_block = block.replacen(&from_pattern, &to_pattern, 1);
2492    let mut out = String::with_capacity(src.len());
2493    out.push_str(&src[..open]);
2494    out.push_str(&new_block);
2495    out.push_str(&src[close + 1..]);
2496    Ok(out)
2497}
2498
2499fn rename_in_from_row(src: &str, from: &str, to: &str) -> Result<String, String> {
2500    let fn_start = src
2501        .find("fn from_row(")
2502        .ok_or_else(|| "from_row not found".to_string())?;
2503    let ok_self_rel = src[fn_start..]
2504        .find("Ok(Self {")
2505        .ok_or_else(|| "Ok(Self not found".to_string())?;
2506    let ok_self_open = fn_start + ok_self_rel + "Ok(Self ".len();
2507    let ok_self_close = find_matching_brace(src, ok_self_open)
2508        .ok_or_else(|| "Ok(Self block is not closed".to_string())?;
2509    let block = &src[ok_self_open..=ok_self_close];
2510    // Match the full accessor line so `priority` doesn't collide with `priority_2`.
2511    // Pattern: `\n<ws><from>: row.get_*("<from>")?,`
2512    let from_lhs = format!("{from}:");
2513    let from_arg = format!("\"{from}\"");
2514    let to_lhs = format!("{to}:");
2515    let to_arg = format!("\"{to}\"");
2516    if !block.contains(&from_lhs) {
2517        return Err(format!(
2518            "from_row does not reference `{from}:`; rename cannot proceed"
2519        ));
2520    }
2521    let replaced = block
2522        .replacen(&from_lhs, &to_lhs, 1)
2523        .replacen(&from_arg, &to_arg, 1);
2524    let mut out = String::with_capacity(src.len());
2525    out.push_str(&src[..ok_self_open]);
2526    out.push_str(&replaced);
2527    out.push_str(&src[ok_self_close + 1..]);
2528    Ok(out)
2529}
2530
2531fn rename_in_insert_values(src: &str, from: &str, to: &str) -> Result<String, String> {
2532    let fn_start = src
2533        .find("fn insert_values(")
2534        .ok_or_else(|| "insert_values not found".to_string())?;
2535    let vec_rel = src[fn_start..]
2536        .find("vec![")
2537        .ok_or_else(|| "no `vec![` inside insert_values".to_string())?;
2538    let vec_open = fn_start + vec_rel + 4;
2539    let vec_close = find_matching_bracket(src, vec_open)
2540        .ok_or_else(|| "vec![ … ] is not closed".to_string())?;
2541    let block = &src[vec_open..=vec_close];
2542    let from_pattern = format!("self.{from}");
2543    let to_pattern = format!("self.{to}");
2544    if !block.contains(&from_pattern) {
2545        return Err(format!(
2546            "insert_values does not reference `self.{from}`; rename cannot proceed"
2547        ));
2548    }
2549    let replaced = block.replacen(&from_pattern, &to_pattern, 1);
2550    let mut out = String::with_capacity(src.len());
2551    out.push_str(&src[..vec_open]);
2552    out.push_str(&replaced);
2553    out.push_str(&src[vec_close + 1..]);
2554    Ok(out)
2555}
2556
2557/// True when `src` contains an actual `use chrono::…;` statement at the
2558/// start of a line. A docstring mentioning `chrono::DateTime` does not
2559/// count — only a real import satisfies the check.
2560fn has_chrono_use(src: &str) -> bool {
2561    src.lines()
2562        .any(|l| l.trim_start().starts_with("use chrono::"))
2563}
2564
2565fn insert_chrono_import(src: &str) -> String {
2566    // Put the use statement right after the last top-level `use` line.
2567    let mut last_use_end: Option<usize> = None;
2568    for (idx, line) in src.match_indices('\n') {
2569        // Re-use match_indices to walk line boundaries.
2570        let before_nl = &src[..idx];
2571        let line_start = before_nl.rfind('\n').map(|p| p + 1).unwrap_or(0);
2572        let line_txt = &src[line_start..idx];
2573        if line_txt.trim_start().starts_with("use ") {
2574            last_use_end = Some(idx);
2575        }
2576        let _ = line; // unused
2577    }
2578    match last_use_end {
2579        Some(end) => {
2580            let mut out = String::with_capacity(src.len() + 40);
2581            out.push_str(&src[..=end]);
2582            out.push_str("use chrono::{DateTime, Utc};\n");
2583            out.push_str(&src[end + 1..]);
2584            out
2585        }
2586        None => format!("use chrono::{{DateTime, Utc}};\n{src}"),
2587    }
2588}
2589
2590// --- per-type helpers -------------------------------------------------------
2591
2592fn rust_type_for(ty: &str, nullable: bool) -> String {
2593    let base = match ty {
2594        "i32" => "i32",
2595        "i64" => "i64",
2596        "String" => "String",
2597        "bool" => "bool",
2598        "DateTime" => "DateTime<Utc>",
2599        other => other,
2600    };
2601    if nullable {
2602        format!("Option<{base}>")
2603    } else {
2604        base.to_string()
2605    }
2606}
2607
2608fn row_accessor(ty: &str, nullable: bool) -> String {
2609    let suffix = match ty {
2610        "i32" => "i32",
2611        "i64" => "i64",
2612        "String" => "string",
2613        "bool" => "bool",
2614        "DateTime" => "datetime",
2615        _ => "string",
2616    };
2617    if nullable {
2618        format!("get_optional_{suffix}")
2619    } else {
2620        format!("get_{suffix}")
2621    }
2622}
2623
2624fn build_insert_values_line(field: &str, ty: &str, _nullable: bool) -> String {
2625    // `.clone()` is needed for non-`Copy` types so `insert_values(&self)`
2626    // doesn't move out of `self`. `String` (and `Option<String>`) are
2627    // the ones that matter today; every other supported primitive is
2628    // `Copy` (or converts from `Copy`). If more non-Copy types land,
2629    // extend this list explicitly rather than guessing.
2630    let call = if ty == "String" {
2631        format!("self.{field}.clone().into()")
2632    } else {
2633        format!("self.{field}.into()")
2634    };
2635    format!("            {call},\n")
2636}
2637
2638// --- project introspection --------------------------------------------------
2639
2640fn locate_model_file(
2641    project: &ProjectView,
2642    struct_name: &str,
2643) -> Result<(String, String), ExecutionError> {
2644    let mut matches: Vec<&str> = project
2645        .models_files
2646        .iter()
2647        .filter(|(_, f)| f.struct_names.iter().any(|s| s == struct_name))
2648        .map(|(app, _)| app.as_str())
2649        .collect();
2650    match matches.len() {
2651        0 => Err(ExecutionError::ProjectStructure(format!(
2652            "no apps/<app>/models.rs declares `pub struct {struct_name}`"
2653        ))),
2654        1 => {
2655            let app = matches.remove(0).to_string();
2656            let source = project.models_files[&app].source.clone();
2657            Ok((app, source))
2658        }
2659        _ => Err(ExecutionError::ProjectStructure(format!(
2660            "multiple apps declare `pub struct {struct_name}`: {}",
2661            matches.join(", ")
2662        ))),
2663    }
2664}
2665
2666fn find_table_for_struct(src: &str, struct_name: &str) -> Option<String> {
2667    // Extract the string value of `const TABLE: &'static str = "<name>";`
2668    // from the matching `impl Model for <struct>` block.
2669    //
2670    // 0.9.1: scoped by struct name. Older callers that want the
2671    // whole-file first match should pass an empty string — but no
2672    // in-tree site does. The previous "one Model impl per file"
2673    // assumption breaks on real projects like medflow that declare
2674    // several models per app.
2675    let impl_anchor = format!("impl Model for {struct_name}");
2676    let slice = if let Some(impl_start) = src.find(&impl_anchor) {
2677        // Restrict to the matching impl block so sibling models can't
2678        // shadow this one's `const TABLE`.
2679        let brace_rel = src[impl_start..].find('{')?;
2680        let open = impl_start + brace_rel;
2681        let close = find_matching_brace(src, open)?;
2682        &src[open..=close]
2683    } else {
2684        // Fallback (rare): no matching impl found — scan the whole file.
2685        // Preserves the 0.8.x behaviour for callers that rely on the
2686        // fallback path before the struct exists.
2687        src
2688    };
2689    let anchor = "const TABLE: &'static str = \"";
2690    let start = slice.find(anchor)? + anchor.len();
2691    let end = slice[start..].find('"')?;
2692    Some(slice[start..start + end].to_string())
2693}
2694
2695/// Snake-case derivation used when `const TABLE` can't be read
2696/// (shouldn't happen with scaffold output — defensive fallback).
2697fn fallback_table_name(struct_name: &str) -> Option<String> {
2698    let mut out = String::with_capacity(struct_name.len() + 4);
2699    for (i, ch) in struct_name.chars().enumerate() {
2700        if ch.is_ascii_uppercase() {
2701            if i > 0 {
2702                out.push('_');
2703            }
2704            out.extend(ch.to_lowercase());
2705        } else {
2706            out.push(ch);
2707        }
2708    }
2709    // Pluralise naively — matches what `rustio new app` does.
2710    if !out.ends_with('s') {
2711        out.push('s');
2712    }
2713    Some(out)
2714}
2715
2716fn next_migration_number(existing: &[String]) -> u32 {
2717    let mut max: u32 = 0;
2718    for name in existing {
2719        let Some(prefix) = name.split('_').next() else {
2720            continue;
2721        };
2722        if let Ok(n) = prefix.parse::<u32>() {
2723            if n > max {
2724                max = n;
2725            }
2726        }
2727    }
2728    max + 1
2729}
2730
2731fn new_migration_path(project: &ProjectView, number: u32, slug: &str) -> (PathBuf, String) {
2732    let filename = format!("{number:04}_{slug}.sql");
2733    (project.root.join("migrations").join(&filename), filename)
2734}
2735
2736fn sql_for_add_field(table: &str, field: &FieldSpec) -> String {
2737    let sql_type = sql_type_for(&field.ty);
2738    if field.nullable {
2739        format!(
2740            "-- Generated by rustio ai apply (0.5.2). DO NOT EDIT.\n\
2741             ALTER TABLE {table} ADD COLUMN {name} {sql_type};\n",
2742            name = field.name,
2743        )
2744    } else {
2745        let default = safe_default_literal(&field.ty);
2746        format!(
2747            "-- Generated by rustio ai apply (0.5.2). DO NOT EDIT.\n\
2748             ALTER TABLE {table} ADD COLUMN {name} {sql_type} NOT NULL DEFAULT {default};\n",
2749            name = field.name,
2750        )
2751    }
2752}
2753
2754fn sql_type_for(ty: &str) -> &'static str {
2755    match ty {
2756        "i32" | "i64" | "bool" => "INTEGER",
2757        "String" => "TEXT",
2758        "DateTime" => "TEXT",
2759        _ => "TEXT",
2760    }
2761}
2762
2763fn safe_default_literal(ty: &str) -> &'static str {
2764    match ty {
2765        "i32" | "i64" | "bool" => "0",
2766        "String" => "''",
2767        // SQLite's `ALTER TABLE ADD COLUMN` rejects non-constant
2768        // defaults, so `CURRENT_TIMESTAMP` is refused outright. Use
2769        // a fixed epoch string that chrono parses back cleanly.
2770        "DateTime" => "'1970-01-01 00:00:00'",
2771        _ => "''",
2772    }
2773}
2774
2775// ---------------------------------------------------------------------------
2776// Impure entry — reads project from disk, applies atomically
2777// ---------------------------------------------------------------------------
2778
2779impl ProjectView {
2780    /// Build a [`ProjectView`] by reading the project at `root`. Reads
2781    /// every `apps/*/models.rs` and lists `migrations/*`. Returns a
2782    /// [`ExecutionError::ProjectStructure`] if the scaffold isn't
2783    /// recognisable — the executor will not apply to a non-rustio
2784    /// directory.
2785    pub fn from_dir(root: &Path) -> Result<Self, ExecutionError> {
2786        let apps_dir = root.join("apps");
2787        let migrations_dir = root.join("migrations");
2788        if !apps_dir.is_dir() {
2789            return Err(ExecutionError::ProjectStructure(format!(
2790                "expected directory `apps/` at {}",
2791                root.display()
2792            )));
2793        }
2794        if !migrations_dir.is_dir() {
2795            return Err(ExecutionError::ProjectStructure(format!(
2796                "expected directory `migrations/` at {}",
2797                root.display()
2798            )));
2799        }
2800
2801        let mut models_files = BTreeMap::new();
2802        let entries = std::fs::read_dir(&apps_dir).map_err(|e| ExecutionError::IoError {
2803            path: apps_dir.display().to_string(),
2804            message: e.to_string(),
2805        })?;
2806        for entry in entries {
2807            let entry = entry.map_err(|e| ExecutionError::IoError {
2808                path: apps_dir.display().to_string(),
2809                message: e.to_string(),
2810            })?;
2811            let ty = entry.file_type().map_err(|e| ExecutionError::IoError {
2812                path: entry.path().display().to_string(),
2813                message: e.to_string(),
2814            })?;
2815            if !ty.is_dir() {
2816                continue;
2817            }
2818            let app_dir = entry.path();
2819            let app_name = app_dir
2820                .file_name()
2821                .and_then(|n| n.to_str())
2822                .map(String::from)
2823                .unwrap_or_default();
2824            if app_name.is_empty() {
2825                continue;
2826            }
2827            let models_path = app_dir.join("models.rs");
2828            if !models_path.is_file() {
2829                continue;
2830            }
2831            let source =
2832                std::fs::read_to_string(&models_path).map_err(|e| ExecutionError::IoError {
2833                    path: models_path.display().to_string(),
2834                    message: e.to_string(),
2835                })?;
2836            let struct_names = parse_struct_names(&source);
2837            models_files.insert(
2838                app_name,
2839                ParsedModelsFile {
2840                    path: models_path,
2841                    source,
2842                    struct_names,
2843                },
2844            );
2845        }
2846
2847        let mut existing_migrations = Vec::new();
2848        let mut migration_sources: BTreeMap<String, String> = BTreeMap::new();
2849        let entries = std::fs::read_dir(&migrations_dir).map_err(|e| ExecutionError::IoError {
2850            path: migrations_dir.display().to_string(),
2851            message: e.to_string(),
2852        })?;
2853        for entry in entries {
2854            let entry = entry.map_err(|e| ExecutionError::IoError {
2855                path: migrations_dir.display().to_string(),
2856                message: e.to_string(),
2857            })?;
2858            if let Some(name) = entry.file_name().to_str() {
2859                if name.ends_with(".sql") {
2860                    let path = entry.path();
2861                    let contents =
2862                        std::fs::read_to_string(&path).map_err(|e| ExecutionError::IoError {
2863                            path: path.display().to_string(),
2864                            message: e.to_string(),
2865                        })?;
2866                    migration_sources.insert(name.to_string(), contents);
2867                    existing_migrations.push(name.to_string());
2868                }
2869            }
2870        }
2871        existing_migrations.sort();
2872
2873        Ok(ProjectView {
2874            root: root.to_path_buf(),
2875            models_files,
2876            existing_migrations,
2877            migration_sources,
2878        })
2879    }
2880}
2881
2882fn parse_struct_names(source: &str) -> Vec<String> {
2883    let mut out: Vec<String> = Vec::new();
2884    for line in source.lines() {
2885        let t = line.trim_start();
2886        if let Some(rest) = t.strip_prefix("pub struct ") {
2887            // Name runs until whitespace, `{`, or `<`.
2888            let name: String = rest
2889                .chars()
2890                .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
2891                .collect();
2892            if !name.is_empty() {
2893                out.push(name);
2894            }
2895        }
2896    }
2897    out
2898}
2899
2900/// Run a plan against the project on disk.
2901///
2902/// Reads the schema at `<root>/rustio.schema.json`, builds a
2903/// [`ProjectView`], calls [`plan_execution`], verifies preconditions
2904/// against the live filesystem, and applies the change set atomically.
2905/// No migrations are executed — the user runs `rustio migrate apply`
2906/// afterwards.
2907pub fn execute_plan_document(
2908    project_root: &Path,
2909    doc: &PlanDocument,
2910    options: &ExecuteOptions,
2911    context: Option<&ContextConfig>,
2912) -> Result<ExecutionResult, ExecutionError> {
2913    let schema_path = project_root.join("rustio.schema.json");
2914    let schema_json =
2915        std::fs::read_to_string(&schema_path).map_err(|e| ExecutionError::IoError {
2916            path: schema_path.display().to_string(),
2917            message: e.to_string(),
2918        })?;
2919    let schema =
2920        Schema::parse(&schema_json).map_err(|e| ExecutionError::ValidationFailed(e.to_string()))?;
2921    let project = ProjectView::from_dir(project_root)?;
2922    let preview = plan_execution(&schema, &project, doc, options, context)?;
2923    commit_changes(&preview)?;
2924    let generated: Vec<String> = preview
2925        .file_changes
2926        .iter()
2927        .map(|c| display_path(project_root, &c.path))
2928        .collect();
2929    Ok(ExecutionResult {
2930        applied_steps: preview.applied_steps,
2931        generated_files: generated,
2932        summary: preview.summary,
2933    })
2934}
2935
2936/// Commit the preview to disk. Each target is written to a sibling
2937/// `.rustio_tmp` file first; only after every target has a tempfile
2938/// does the executor rename them into place. If any rename fails, the
2939/// already-renamed files are restored from their pre-apply content.
2940fn commit_changes(preview: &ExecutionPreview) -> Result<(), ExecutionError> {
2941    // 1. Conflict + precondition pass against the live filesystem.
2942    for change in &preview.file_changes {
2943        match change.kind {
2944            FileChangeKind::Create => {
2945                if change.path.exists() {
2946                    return Err(ExecutionError::FileConflict {
2947                        path: change.path.display().to_string(),
2948                        reason: "file already exists — refusing to overwrite".to_string(),
2949                    });
2950                }
2951                if let Some(parent) = change.path.parent() {
2952                    if !parent.is_dir() {
2953                        return Err(ExecutionError::ProjectStructure(format!(
2954                            "parent directory `{}` does not exist",
2955                            parent.display()
2956                        )));
2957                    }
2958                }
2959            }
2960            FileChangeKind::Update => {
2961                let actual =
2962                    std::fs::read_to_string(&change.path).map_err(|e| ExecutionError::IoError {
2963                        path: change.path.display().to_string(),
2964                        message: e.to_string(),
2965                    })?;
2966                if let Some(expected) = &change.expected_current_contents {
2967                    if &actual != expected {
2968                        return Err(ExecutionError::FileConflict {
2969                            path: change.path.display().to_string(),
2970                            reason: "file changed on disk after the plan was generated".to_string(),
2971                        });
2972                    }
2973                }
2974            }
2975        }
2976    }
2977
2978    // 2. Write each change to a .rustio_tmp sibling file.
2979    let mut tmp_paths: Vec<PathBuf> = Vec::with_capacity(preview.file_changes.len());
2980    for change in &preview.file_changes {
2981        let tmp = change.path.with_extension(match change.path.extension() {
2982            Some(e) => format!("{}.rustio_tmp", e.to_string_lossy()),
2983            None => "rustio_tmp".to_string(),
2984        });
2985        if let Err(e) = std::fs::write(&tmp, &change.new_contents) {
2986            cleanup_tmps(&tmp_paths);
2987            return Err(ExecutionError::IoError {
2988                path: tmp.display().to_string(),
2989                message: e.to_string(),
2990            });
2991        }
2992        tmp_paths.push(tmp);
2993    }
2994
2995    // 3. Rename .rustio_tmp → final path. Track (target, original) so
2996    // we can roll back if a later rename fails.
2997    let mut renamed: Vec<(PathBuf, Option<String>)> =
2998        Vec::with_capacity(preview.file_changes.len());
2999    for (i, change) in preview.file_changes.iter().enumerate() {
3000        let tmp = &tmp_paths[i];
3001        let original = match change.kind {
3002            FileChangeKind::Update => change.expected_current_contents.clone(),
3003            FileChangeKind::Create => None,
3004        };
3005        if let Err(e) = std::fs::rename(tmp, &change.path) {
3006            // Roll back: restore already-renamed targets, clean up
3007            // remaining tmps.
3008            rollback_renames(&renamed);
3009            cleanup_tmps(&tmp_paths[i..]);
3010            return Err(ExecutionError::IoError {
3011                path: change.path.display().to_string(),
3012                message: e.to_string(),
3013            });
3014        }
3015        renamed.push((change.path.clone(), original));
3016    }
3017    Ok(())
3018}
3019
3020fn cleanup_tmps(paths: &[PathBuf]) {
3021    for p in paths {
3022        let _ = std::fs::remove_file(p);
3023    }
3024}
3025
3026fn rollback_renames(renamed: &[(PathBuf, Option<String>)]) {
3027    for (path, original) in renamed.iter().rev() {
3028        match original {
3029            Some(contents) => {
3030                let _ = std::fs::write(path, contents);
3031            }
3032            None => {
3033                let _ = std::fs::remove_file(path);
3034            }
3035        }
3036    }
3037}
3038
3039fn display_path(root: &Path, absolute: &Path) -> String {
3040    absolute
3041        .strip_prefix(root)
3042        .ok()
3043        .and_then(|p| p.to_str())
3044        .map(String::from)
3045        .unwrap_or_else(|| absolute.display().to_string())
3046}
3047
3048// ---------------------------------------------------------------------------
3049// 0.9.0 — FK retrofit
3050// ---------------------------------------------------------------------------
3051
3052/// Result of scanning a schema for belongs_to relations that were
3053/// materialised before 0.9.0 (no `on_delete` / `required` metadata).
3054#[derive(Debug, Clone)]
3055pub struct RetrofitReport {
3056    /// `(model_name, field_name)` pairs the retrofit would upgrade.
3057    pub upgraded: Vec<(String, String)>,
3058    /// `(filename, sql)` pairs to write into the `migrations/` dir.
3059    /// Empty when nothing needs retrofitting.
3060    pub migrations: Vec<(String, String)>,
3061}
3062
3063/// 0.9.0 — generate a retrofit plan for every belongs_to relation that
3064/// lacks `on_delete` metadata. Returns a report the caller can either
3065/// print (dry-run) or materialise (write migrations + update schema).
3066///
3067/// Safety: the caller must pass a `Schema` that faithfully reflects the
3068/// live SQL tables. If the user has hand-written migrations that added
3069/// columns, indexes, or constraints outside the schema.json shape, the
3070/// recreate-table SQL will drop them. The CLI surfaces a warning to
3071/// this effect and requires a `--write` flag to actually emit files.
3072pub fn plan_retrofit_foreign_keys(schema: &crate::schema::Schema) -> RetrofitReport {
3073    use crate::schema::{RelationKind, SchemaField};
3074
3075    let mut upgraded = Vec::new();
3076    let mut migrations = Vec::new();
3077
3078    // Build a model name → table name lookup. Retrofit targets only
3079    // schema entries, so fallback_table_name fills the gap for
3080    // projects whose `const TABLE` we can't read offline.
3081    let table_for = |model_name: &str| -> Option<String> {
3082        schema
3083            .models
3084            .iter()
3085            .find(|m| m.name == model_name)
3086            .and_then(|m| {
3087                // `SchemaModel` carries `table` implicitly through the
3088                // exporter — fall through to the naming convention when
3089                // the field isn't present.
3090                fallback_table_name(&m.name)
3091            })
3092    };
3093
3094    for model in &schema.models {
3095        let table = match fallback_table_name(&model.name) {
3096            Some(t) => t,
3097            None => continue,
3098        };
3099        // Build the upgraded field list (populate on_delete+required
3100        // defaults on any relation field that lacks them).
3101        let mut touched_this_model = false;
3102        let upgraded_fields: Vec<SchemaField> = model
3103            .fields
3104            .iter()
3105            .map(|f| {
3106                let mut f = f.clone();
3107                if let Some(rel) = f.relation.as_mut() {
3108                    if matches!(rel.kind, RelationKind::BelongsTo) && rel.on_delete.is_none() {
3109                        rel.on_delete = Some("restrict".to_string());
3110                        rel.required = Some(!f.nullable);
3111                        upgraded.push((model.name.clone(), f.name.clone()));
3112                        touched_this_model = true;
3113                    }
3114                }
3115                f
3116            })
3117            .collect();
3118
3119        if !touched_this_model {
3120            continue;
3121        }
3122
3123        // Build SQL that recreates the table with explicit FOREIGN KEYs.
3124        let mut sql = String::new();
3125        sql.push_str("-- Generated by `rustio migrate add-fks` (0.9.0).\n");
3126        sql.push_str("-- Review before running: the recreate-table pattern drops and\n");
3127        sql.push_str("-- rebuilds the table. Columns or indexes added outside of\n");
3128        sql.push_str("-- rustio's schema.json will be lost if not reflected here.\n");
3129        sql.push_str("PRAGMA foreign_keys = OFF;\n");
3130        sql.push_str("BEGIN;\n\n");
3131        let new_table = format!("{table}__new");
3132        sql.push_str(&format!("CREATE TABLE {new_table} (\n"));
3133        for (i, f) in upgraded_fields.iter().enumerate() {
3134            sql.push_str("    ");
3135            sql.push_str(&column_def_with_relation_context(f, |target| {
3136                table_for(target)
3137            }));
3138            if i + 1 < upgraded_fields.len() {
3139                sql.push(',');
3140            }
3141            sql.push('\n');
3142        }
3143        sql.push_str(");\n\n");
3144        let col_list = upgraded_fields
3145            .iter()
3146            .map(|f| f.name.clone())
3147            .collect::<Vec<_>>()
3148            .join(", ");
3149        sql.push_str(&format!(
3150            "INSERT INTO {new_table} ({col_list}) SELECT {col_list} FROM {table};\n\n"
3151        ));
3152        sql.push_str(&format!("DROP TABLE {table};\n"));
3153        sql.push_str(&format!("ALTER TABLE {new_table} RENAME TO {table};\n\n"));
3154        sql.push_str("COMMIT;\n");
3155        sql.push_str("PRAGMA foreign_keys = ON;\n");
3156
3157        migrations.push((format!("retrofit_fks_{table}"), sql));
3158    }
3159
3160    RetrofitReport {
3161        upgraded,
3162        migrations,
3163    }
3164}
3165
3166// ---------------------------------------------------------------------------
3167// Human-readable preview
3168// ---------------------------------------------------------------------------
3169
3170/// Render an [`ExecutionPreview`] as an operator-friendly block. The
3171/// CLI prints this before asking for confirmation.
3172pub fn render_preview_human(preview: &ExecutionPreview, risk: RiskLevel) -> String {
3173    let mut out = String::from("Plan to apply\n\n");
3174    out.push_str("Applying:\n");
3175    // Each summary line already carries its own glyph (`+` for add,
3176    // `~` for mutate, `-` for destructive; warning lines are indented
3177    // with four leading spaces). The renderer just reserves a two-
3178    // space indent for every line so the block is visually uniform.
3179    for line in preview.summary.lines() {
3180        out.push_str("  ");
3181        out.push_str(line);
3182        out.push('\n');
3183    }
3184    out.push_str("\nFiles to be written:\n");
3185    for change in &preview.file_changes {
3186        let kind = match change.kind {
3187            FileChangeKind::Create => "create",
3188            FileChangeKind::Update => "update",
3189        };
3190        out.push_str(&format!("  - {kind} {}\n", change.path.display()));
3191    }
3192    out.push_str(&format!("\nRisk:\n  {}\n", risk.as_str()));
3193    out
3194}