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 BIGINT REFERENCES parent(id)
513///       ON DELETE <policy>;
514///
515/// PostgreSQL enforces foreign keys by default — no PRAGMA needed.
516/// The column is nullable: a `required: true` relation can't be
517/// added via `ALTER TABLE … ADD COLUMN` on a populated table without
518/// a default (PG would raise "column contains null values"). The
519/// retrofit command (`rustio migrate --add-fks`) handles the
520/// add-nullable / backfill / SET NOT NULL sequence in three statements.
521///
522/// Kinds other than `BelongsTo` refuse — planning them is allowed so
523/// the review layer can warn, but the executor has no story for
524/// `HasMany` yet (it's a virtual accessor, not a column).
525fn apply_add_relation(
526    r: &AddRelation,
527    project: &ProjectView,
528    shadow: &mut BTreeMap<String, String>,
529    migration_counter: &mut u32,
530) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
531    use crate::ai::OnDelete;
532    use crate::schema::RelationKind;
533    match r.kind {
534        RelationKind::BelongsTo => {}
535        _ => {
536            return Err(ExecutionError::UnsupportedPrimitive {
537                op: "add_relation",
538                reason: "only `belongs_to` is materialised in 0.9.0 — `has_many` is a virtual accessor with no column change",
539            });
540        }
541    }
542
543    // Reject NOT NULL + REFERENCES on `ai apply`: PG would raise
544    // "column contains null values" on any non-empty table because
545    // ADD COLUMN with a `NOT NULL` constraint requires either an
546    // empty table or a `DEFAULT`. The retrofit command sequences
547    // add-nullable / backfill / SET NOT NULL into three statements.
548    if r.required {
549        return Err(ExecutionError::UnsupportedPrimitive {
550            op: "add_relation",
551            reason: "a required (NOT NULL) foreign key cannot be added via a single ALTER TABLE on a populated table; use `rustio migrate --add-fks` to add-nullable / backfill / SET NOT NULL in sequence",
552        });
553    }
554    // SET NULL is only defined when the column can actually hold NULL.
555    // With `required: false` this always passes; the check guards
556    // against future primitive-level misuse.
557    if matches!(r.on_delete, OnDelete::SetNull) && r.required {
558        return Err(ExecutionError::UnsupportedPrimitive {
559            op: "add_relation",
560            reason: "`on_delete: set_null` requires a nullable FK column",
561        });
562    }
563
564    // Patch models.rs via the same machinery as add_field — the struct
565    // change is identical (an `i64` column, plus a `#[rustio(belongs_to)]`
566    // attribute). We reuse apply_add_field for the file patch, then
567    // *replace* its generated SQL migration with one that includes the
568    // FOREIGN KEY clause.
569    let synthetic = AddField {
570        model: r.from.clone(),
571        field: FieldSpec {
572            name: r.via.clone(),
573            ty: "i64".to_string(),
574            nullable: true, // see above — must be nullable for ALTER TABLE
575            editable: true,
576        },
577    };
578    let (mut changes, _) = apply_add_field(&synthetic, project, shadow, migration_counter)?;
579
580    // Find the parent's models.rs to resolve its `const TABLE`. The
581    // child's table was already resolved by `apply_add_field` above, but
582    // we didn't capture it — redo the cheap lookup here rather than
583    // threading the result through.
584    let (child_app, _) = locate_model_file(project, &r.from)?;
585    let child_src = shadow
586        .get(&child_app)
587        .cloned()
588        .unwrap_or_else(|| project.models_files[&child_app].source.clone());
589    let child_table = find_table_for_struct(&child_src, &r.from)
590        .or_else(|| fallback_table_name(&r.from))
591        .ok_or_else(|| {
592            ExecutionError::ProjectStructure(format!(
593                "could not find `const TABLE` for child struct `{}`",
594                r.from
595            ))
596        })?;
597
598    // Parent may live in a different app, or — for plans targeting a
599    // model that isn't scaffolded locally — be absent entirely. In the
600    // missing-parent case, use the snake-plural fallback (matches what
601    // `rustio new app` produces) rather than refusing the migration.
602    let parent_table = match locate_model_file(project, &r.to) {
603        Ok((parent_app, parent_source)) => {
604            let parent_src = shadow.get(&parent_app).cloned().unwrap_or(parent_source);
605            find_table_for_struct(&parent_src, &r.to)
606                .or_else(|| fallback_table_name(&r.to))
607                .ok_or_else(|| {
608                    ExecutionError::ProjectStructure(format!(
609                        "could not find `const TABLE` for parent struct `{}`",
610                        r.to
611                    ))
612                })?
613        }
614        Err(_) => fallback_table_name(&r.to).ok_or_else(|| {
615            ExecutionError::ProjectStructure(format!(
616                "could not derive a table name for parent struct `{}`",
617                r.to
618            ))
619        })?,
620    };
621
622    let fk_sql = sql_for_add_fk_column(&child_table, &r.via, &parent_table, r.on_delete);
623    let mig_filename = {
624        let create = changes
625            .iter_mut()
626            .find(|c| c.kind == FileChangeKind::Create)
627            .expect("apply_add_field always plans a Create for the migration");
628        create.new_contents = fk_sql;
629        create
630            .path
631            .file_name()
632            .and_then(|n| n.to_str())
633            .unwrap_or("")
634            .to_string()
635    };
636
637    Ok((
638        changes,
639        format!(
640            "+ Add relation `{}` from \"{}\" to \"{}\" (belongs_to → {}, {}, migration {})",
641            r.via,
642            r.from,
643            r.to,
644            parent_table,
645            r.on_delete.as_str(),
646            mig_filename,
647        ),
648    ))
649}
650
651/// Phase 2: emit the SQL for adding a FK column to an existing PG
652/// table. PostgreSQL enforces foreign keys by default, so the SQLite
653/// `PRAGMA foreign_keys = ON` is gone. Column type is `BIGINT` to
654/// match the parent's `BIGSERIAL PRIMARY KEY`.
655fn sql_for_add_fk_column(
656    child_table: &str,
657    via: &str,
658    parent_table: &str,
659    on_delete: crate::ai::OnDelete,
660) -> String {
661    format!(
662        "-- Generated by rustio ai apply. DO NOT EDIT.\n\
663         ALTER TABLE {child} ADD COLUMN {via} BIGINT REFERENCES {parent}(id) {policy};\n",
664        child = child_table,
665        via = via,
666        parent = parent_table,
667        policy = on_delete.sql(),
668    )
669}
670
671// ---------------------------------------------------------------------------
672// 0.9.1 — remove_field / remove_relation (gated on `allow_destructive`)
673// ---------------------------------------------------------------------------
674
675/// 0.9.1 — materialise a `RemoveField` primitive.
676///
677/// SQLite's `ALTER TABLE` has supported `DROP COLUMN` since 3.35, but
678/// the clause refuses when the column participates in a foreign key or
679/// is referenced by an index — and rustio projects routinely have both.
680/// Use the same recreate-table pattern as `change_field_type`: clone
681/// the table with the field omitted, copy data across, drop, rename.
682/// The recreate preserves every remaining field's relation metadata
683/// (`column_def_with_relation_context`), so other FKs on the table
684/// survive the migration unchanged.
685///
686/// Caller has already verified `opts.allow_destructive` — this function
687/// assumes the reviewer has consented.
688fn apply_remove_field(
689    r: &RemoveField,
690    schema: &Schema,
691    project: &ProjectView,
692    shadow: &mut BTreeMap<String, String>,
693    migration_counter: &mut u32,
694) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
695    let model = schema
696        .models
697        .iter()
698        .find(|m| m.name == r.model)
699        .ok_or_else(|| {
700            ExecutionError::SchemaMismatch(format!("model `{}` not in schema", r.model))
701        })?;
702    let field = model
703        .fields
704        .iter()
705        .find(|f| f.name == r.field)
706        .ok_or_else(|| {
707            ExecutionError::SchemaMismatch(format!("field `{}.{}` not in schema", r.model, r.field))
708        })?;
709    if r.field == "id" {
710        return Err(ExecutionError::UnsupportedPrimitive {
711            op: "remove_field",
712            reason: "cannot drop the `id` primary key; remove the model instead",
713        });
714    }
715
716    let (app, initial_source) = locate_model_file(project, &r.model)?;
717    let current = shadow
718        .get(&app)
719        .cloned()
720        .unwrap_or_else(|| initial_source.clone());
721    let table = find_table_for_struct(&current, &r.model)
722        .or_else(|| fallback_table_name(&r.model))
723        .ok_or_else(|| {
724            ExecutionError::ProjectStructure(format!(
725                "could not find `const TABLE` for struct `{}`",
726                r.model
727            ))
728        })?;
729
730    // Patch the models.rs file (struct field, COLUMNS array, from_row,
731    // insert_values). Same machinery whether we're dropping a plain
732    // field or a relation FK column — only the SQL emission differs.
733    let patched =
734        patch_models_for_remove_field(&current, &r.model, &r.field, &field.ty, field.nullable)
735            .map_err(|msg| ExecutionError::FileConflict {
736                path: format!("apps/{app}/models.rs"),
737                reason: msg,
738            })?;
739    shadow.insert(app.clone(), patched.clone());
740
741    // Phase 2: PostgreSQL has a real `ALTER TABLE … DROP COLUMN`. The
742    // SQLite recreate-table dance is gone. CASCADE drops any FK or
743    // index that depended on the column — exactly what a remove_field
744    // operation should do (the user accepted the destructive flag).
745    let sql = format!(
746        "-- Generated by rustio ai apply. DO NOT EDIT.\n\
747         ALTER TABLE {table} DROP COLUMN {field} CASCADE;\n",
748        field = r.field,
749    );
750
751    let mig_name = format!("drop_{}_from_{}", r.field, table);
752    let (mig_path, mig_filename) = new_migration_path(project, *migration_counter, &mig_name);
753    *migration_counter += 1;
754
755    let file_path = project.root.join("apps").join(&app).join("models.rs");
756    let warn_line = format!(
757        "    ⚠ Drops column `{}` from `{table}` (CASCADE — kills dependent FKs/indexes).",
758        r.field
759    );
760    Ok((
761        vec![
762            PlannedFileChange {
763                path: file_path,
764                kind: FileChangeKind::Update,
765                new_contents: patched,
766                expected_current_contents: Some(initial_source),
767            },
768            PlannedFileChange {
769                path: mig_path,
770                kind: FileChangeKind::Create,
771                new_contents: sql,
772                expected_current_contents: None,
773            },
774        ],
775        format!(
776            "- Remove field `{}.{}` (migration {})\n{}",
777            r.model, r.field, mig_filename, warn_line,
778        ),
779    ))
780}
781
782/// 0.9.1 — materialise a `RemoveRelation` primitive.
783///
784/// Relations are stored as a single FK column on the owning side, so
785/// dropping a relation is exactly `apply_remove_field` on `r.via`. A
786/// dedicated entry keeps the summary line honest ("Remove relation"
787/// rather than "Remove field") and keeps callers from having to
788/// synthesise a `RemoveField` themselves.
789fn apply_remove_relation(
790    r: &RemoveRelation,
791    schema: &Schema,
792    project: &ProjectView,
793    shadow: &mut BTreeMap<String, String>,
794    migration_counter: &mut u32,
795) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
796    // Delegate the physical column drop to apply_remove_field.
797    let synthetic = RemoveField {
798        model: r.from.clone(),
799        field: r.via.clone(),
800    };
801    let (changes, _) = apply_remove_field(&synthetic, schema, project, shadow, migration_counter)?;
802    let mig_filename = changes
803        .iter()
804        .find(|c| c.kind == FileChangeKind::Create)
805        .and_then(|c| c.path.file_name())
806        .and_then(|n| n.to_str())
807        .unwrap_or("")
808        .to_string();
809    Ok((
810        changes,
811        format!(
812            "- Remove relation `{}.{}` (migration {})",
813            r.from, r.via, mig_filename,
814        ),
815    ))
816}
817
818fn apply_rename_field(
819    r: &RenameField,
820    project: &ProjectView,
821    shadow: &mut BTreeMap<String, String>,
822    migration_counter: &mut u32,
823) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
824    let (app, initial_source) = locate_model_file(project, &r.model)?;
825    let current = shadow
826        .get(&app)
827        .cloned()
828        .unwrap_or_else(|| initial_source.clone());
829
830    let struct_bounds = find_struct_block(&current, &r.model).ok_or_else(|| {
831        ExecutionError::ProjectStructure(format!(
832            "apps/{app}/models.rs does not declare `pub struct {}`",
833            r.model
834        ))
835    })?;
836    let inside_struct = &current[struct_bounds.0..=struct_bounds.1];
837    if !struct_declares_field(inside_struct, &r.from) {
838        return Err(ExecutionError::FileConflict {
839            path: format!("apps/{app}/models.rs"),
840            reason: format!(
841                "struct {} does not declare `pub {}: …`; rename cannot proceed",
842                r.model, r.from,
843            ),
844        });
845    }
846    if struct_declares_field(inside_struct, &r.to) {
847        return Err(ExecutionError::FileConflict {
848            path: format!("apps/{app}/models.rs"),
849            reason: format!(
850                "struct {} already has a field called `{}`; rename target is taken",
851                r.model, r.to,
852            ),
853        });
854    }
855
856    let patched =
857        patch_models_for_rename_field(&current, &r.model, &r.from, &r.to).map_err(|msg| {
858            ExecutionError::FileConflict {
859                path: format!("apps/{app}/models.rs"),
860                reason: msg,
861            }
862        })?;
863    shadow.insert(app.clone(), patched.clone());
864
865    let table = find_table_for_struct(&current, &r.model)
866        .or_else(|| fallback_table_name(&r.model))
867        .ok_or_else(|| {
868            ExecutionError::ProjectStructure(format!(
869                "could not find `const TABLE` for struct `{}`",
870                r.model
871            ))
872        })?;
873    let sql = format!(
874        "-- Generated by rustio ai apply (0.5.2). DO NOT EDIT.\n\
875         ALTER TABLE {table} RENAME COLUMN {from} TO {to};\n",
876        from = r.from,
877        to = r.to,
878    );
879    let mig_name = format!("rename_{}_to_{}_on_{}", r.from, r.to, table);
880    let (mig_path, mig_filename) = new_migration_path(project, *migration_counter, &mig_name);
881    *migration_counter += 1;
882
883    let file_path = project.root.join("apps").join(&app).join("models.rs");
884    Ok((
885        vec![
886            PlannedFileChange {
887                path: file_path,
888                kind: FileChangeKind::Update,
889                new_contents: patched,
890                expected_current_contents: Some(initial_source),
891            },
892            PlannedFileChange {
893                path: mig_path,
894                kind: FileChangeKind::Create,
895                new_contents: sql,
896                expected_current_contents: None,
897            },
898        ],
899        format!(
900            "~ Rename field \"{}.{}\" to \"{}\" (migration {})",
901            r.model, r.from, r.to, mig_filename
902        ),
903    ))
904}
905
906// ---------------------------------------------------------------------------
907// change_field_type — SQLite recreate-table
908// ---------------------------------------------------------------------------
909
910fn apply_change_field_type(
911    c: &ChangeFieldType,
912    schema: &Schema,
913    project: &ProjectView,
914    shadow: &mut BTreeMap<String, String>,
915    migration_counter: &mut u32,
916) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
917    let model = schema
918        .models
919        .iter()
920        .find(|m| m.name == c.model)
921        .ok_or_else(|| {
922            ExecutionError::SchemaMismatch(format!("model `{}` not in schema", c.model))
923        })?;
924    let field = model
925        .fields
926        .iter()
927        .find(|f| f.name == c.field)
928        .ok_or_else(|| {
929            ExecutionError::SchemaMismatch(format!("field `{}.{}` not in schema", c.model, c.field))
930        })?;
931
932    // Idempotency: field already has target type.
933    if field.ty == c.new_type {
934        return Err(ExecutionError::FileConflict {
935            path: format!("apps/?/{}.rs", c.model.to_lowercase()),
936            reason: format!(
937                "field `{}.{}` already has type `{}`; change appears applied",
938                c.model, c.field, c.new_type,
939            ),
940        });
941    }
942
943    // Safe-cast gate.
944    let cast_expr = cast_expression(&field.ty, &c.new_type, &c.field).ok_or(
945        ExecutionError::UnsupportedPrimitive {
946            op: "change_field_type",
947            reason: "this type conversion is not in the 0.5.3 safe-cast set",
948        },
949    )?;
950
951    let (app, initial_source) = locate_model_file(project, &c.model)?;
952    let current = shadow
953        .get(&app)
954        .cloned()
955        .unwrap_or_else(|| initial_source.clone());
956    let table = find_table_for_struct(&current, &c.model)
957        .or_else(|| fallback_table_name(&c.model))
958        .ok_or_else(|| {
959            ExecutionError::ProjectStructure(format!(
960                "could not find `const TABLE` for struct `{}`",
961                c.model
962            ))
963        })?;
964
965    // Phase 2: PostgreSQL has native column-type alteration. The SQLite
966    // FK guard is gone (PG re-validates dependent FKs as part of the
967    // ALTER, refusing the migration if a stored value would violate
968    // them — exactly the behaviour the SQLite recreate-table dance was
969    // approximating). We still patch the Rust source so the in-tree
970    // model matches the new column type.
971    let patched = patch_models_for_change_field_type(
972        &current,
973        &c.model,
974        &c.field,
975        &field.ty,
976        &c.new_type,
977        field.nullable,
978    )
979    .map_err(|msg| ExecutionError::FileConflict {
980        path: format!("apps/{app}/models.rs"),
981        reason: msg,
982    })?;
983    shadow.insert(app.clone(), patched.clone());
984
985    let new_sql_type = sql_type_for(&c.new_type);
986    let sql = format!(
987        "-- Generated by rustio ai apply. DO NOT EDIT.\n\
988         ALTER TABLE {table} ALTER COLUMN {field} TYPE {new_sql_type} USING ({cast});\n",
989        field = c.field,
990        cast = cast_expr,
991    );
992
993    let mig_name = format!("change_{}_type_on_{}", c.field, table);
994    let (mig_path, mig_filename) = new_migration_path(project, *migration_counter, &mig_name);
995    *migration_counter += 1;
996
997    let file_path = project.root.join("apps").join(&app).join("models.rs");
998    let warn_line = format!(
999        "    ⚠ Rewrites every row of `{table}.{}` in place. PG will refuse the \
1000         migration if a stored value violates a dependent FK or check.",
1001        c.field,
1002    );
1003    Ok((
1004        vec![
1005            PlannedFileChange {
1006                path: file_path,
1007                kind: FileChangeKind::Update,
1008                new_contents: patched,
1009                expected_current_contents: Some(initial_source),
1010            },
1011            PlannedFileChange {
1012                path: mig_path,
1013                kind: FileChangeKind::Create,
1014                new_contents: sql,
1015                expected_current_contents: None,
1016            },
1017        ],
1018        format!(
1019            "~ Change type of {}.{} from {} to {} (migration {})\n{}",
1020            c.model, c.field, field.ty, c.new_type, mig_filename, warn_line,
1021        ),
1022    ))
1023}
1024
1025/// Phase 2: decide whether `old_ty` → `new_ty` is a safe PostgreSQL cast
1026/// and return the expression that goes inside the `USING (…)` clause of
1027/// `ALTER TABLE … ALTER COLUMN … TYPE …`. `None` means "not in the
1028/// safe-cast set" — callers refuse with `UnsupportedPrimitive`.
1029///
1030/// PG, unlike SQLite, has real types and won't auto-coerce between
1031/// `INTEGER` ↔ `BOOLEAN` ↔ `TEXT` ↔ `TIMESTAMPTZ`. Every cross-type
1032/// change needs an explicit `::target` cast. Failures (a non-numeric
1033/// `TEXT` → `BIGINT`, a malformed timestamp string) surface at apply
1034/// time as a Postgres error, not as silent data loss.
1035fn cast_expression(old_ty: &str, new_ty: &str, col_name: &str) -> Option<String> {
1036    match (old_ty, new_ty) {
1037        (a, b) if a == b => None,
1038        // INTEGER ↔ BIGINT — PG won't widen implicitly inside USING.
1039        ("i32", "i64") => Some(format!("{col_name}::BIGINT")),
1040        ("i64", "i32") => Some(format!("{col_name}::INTEGER")),
1041        // BOOLEAN ↔ INTEGER / BIGINT — PG provides explicit casts.
1042        ("bool", "i32") => Some(format!("{col_name}::INTEGER")),
1043        ("bool", "i64") => Some(format!("{col_name}::BIGINT")),
1044        ("i32", "bool") | ("i64", "bool") => Some(format!("{col_name}::BOOLEAN")),
1045        // TIMESTAMPTZ ↔ TEXT — both directions need an explicit cast.
1046        ("DateTime", "String") => Some(format!("{col_name}::TEXT")),
1047        ("String", "DateTime") => Some(format!("{col_name}::TIMESTAMPTZ")),
1048        // Widening to TEXT is always safe.
1049        ("i32", "String") | ("i64", "String") | ("bool", "String") => {
1050            Some(format!("{col_name}::TEXT"))
1051        }
1052        // Narrowing from TEXT — PG raises a runtime error if a row's
1053        // value isn't parseable. Reviewer accepts that risk.
1054        ("String", "i32") => Some(format!("{col_name}::INTEGER")),
1055        ("String", "i64") => Some(format!("{col_name}::BIGINT")),
1056        ("String", "bool") => Some(format!("{col_name}::BOOLEAN")),
1057        _ => None,
1058    }
1059}
1060
1061// ---------------------------------------------------------------------------
1062// change_field_nullability — SQLite recreate-table
1063// ---------------------------------------------------------------------------
1064
1065fn apply_change_field_nullability(
1066    c: &ChangeFieldNullability,
1067    schema: &Schema,
1068    project: &ProjectView,
1069    shadow: &mut BTreeMap<String, String>,
1070    migration_counter: &mut u32,
1071) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
1072    let model = schema
1073        .models
1074        .iter()
1075        .find(|m| m.name == c.model)
1076        .ok_or_else(|| {
1077            ExecutionError::SchemaMismatch(format!("model `{}` not in schema", c.model))
1078        })?;
1079    let field = model
1080        .fields
1081        .iter()
1082        .find(|f| f.name == c.field)
1083        .ok_or_else(|| {
1084            ExecutionError::SchemaMismatch(format!("field `{}.{}` not in schema", c.model, c.field))
1085        })?;
1086
1087    if field.nullable == c.nullable {
1088        return Err(ExecutionError::FileConflict {
1089            path: format!("apps/?/{}.rs", c.model.to_lowercase()),
1090            reason: format!(
1091                "field `{}.{}` is already {}; change appears applied",
1092                c.model,
1093                c.field,
1094                if c.nullable { "nullable" } else { "required" }
1095            ),
1096        });
1097    }
1098
1099    let (app, initial_source) = locate_model_file(project, &c.model)?;
1100    let current = shadow
1101        .get(&app)
1102        .cloned()
1103        .unwrap_or_else(|| initial_source.clone());
1104    let table = find_table_for_struct(&current, &c.model)
1105        .or_else(|| fallback_table_name(&c.model))
1106        .ok_or_else(|| {
1107            ExecutionError::ProjectStructure(format!(
1108                "could not find `const TABLE` for struct `{}`",
1109                c.model
1110            ))
1111        })?;
1112    // Phase 2: PostgreSQL has native nullability flips. The SQLite
1113    // FK guard is gone — PG's `ALTER COLUMN SET/DROP NOT NULL` doesn't
1114    // disturb FKs at all.
1115    let patched = patch_models_for_change_nullability(
1116        &current,
1117        &c.model,
1118        &c.field,
1119        &field.ty,
1120        field.nullable,
1121        c.nullable,
1122    )
1123    .map_err(|msg| ExecutionError::FileConflict {
1124        path: format!("apps/{app}/models.rs"),
1125        reason: msg,
1126    })?;
1127    shadow.insert(app.clone(), patched.clone());
1128
1129    let tightening = !c.nullable && field.nullable;
1130    let sql = if tightening {
1131        // Backfill any existing NULLs with the type's safe default
1132        // first, then flip the constraint. The two statements run in
1133        // the same migration; PG executes them sequentially.
1134        let dflt = safe_default_literal(&field.ty);
1135        format!(
1136            "-- Generated by rustio ai apply. DO NOT EDIT.\n\
1137             UPDATE {table} SET {field} = {dflt} WHERE {field} IS NULL;\n\
1138             ALTER TABLE {table} ALTER COLUMN {field} SET NOT NULL;\n",
1139            field = c.field,
1140        )
1141    } else {
1142        format!(
1143            "-- Generated by rustio ai apply. DO NOT EDIT.\n\
1144             ALTER TABLE {table} ALTER COLUMN {field} DROP NOT NULL;\n",
1145            field = c.field,
1146        )
1147    };
1148
1149    let mig_name = format!("change_{}_nullability_on_{}", c.field, table);
1150    let (mig_path, mig_filename) = new_migration_path(project, *migration_counter, &mig_name);
1151    *migration_counter += 1;
1152
1153    let state = if c.nullable { "nullable" } else { "required" };
1154    let warn_line = if tightening {
1155        format!(
1156            "    ⚠ Backfills existing NULLs in `{table}.{}` with the type default ({}) before adding NOT NULL.",
1157            c.field,
1158            safe_default_literal(&field.ty),
1159        )
1160    } else {
1161        format!("    ⚠ Drops the NOT NULL constraint on `{table}.{}`.", c.field)
1162    };
1163
1164    let file_path = project.root.join("apps").join(&app).join("models.rs");
1165    Ok((
1166        vec![
1167            PlannedFileChange {
1168                path: file_path,
1169                kind: FileChangeKind::Update,
1170                new_contents: patched,
1171                expected_current_contents: Some(initial_source),
1172            },
1173            PlannedFileChange {
1174                path: mig_path,
1175                kind: FileChangeKind::Create,
1176                new_contents: sql,
1177                expected_current_contents: None,
1178            },
1179        ],
1180        format!(
1181            "~ Mark {}.{} as {} (migration {})\n{}",
1182            c.model, c.field, state, mig_filename, warn_line
1183        ),
1184    ))
1185}
1186
1187// ---------------------------------------------------------------------------
1188// rename_model — full: struct, TABLE const, admin.rs, views.rs (bounded)
1189// ---------------------------------------------------------------------------
1190
1191fn apply_rename_model(
1192    r: &RenameModel,
1193    project: &ProjectView,
1194    shadow: &mut BTreeMap<String, String>,
1195    migration_counter: &mut u32,
1196) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
1197    let (app, initial_source) = locate_model_file(project, &r.from)?;
1198    let current = shadow
1199        .get(&app)
1200        .cloned()
1201        .unwrap_or_else(|| initial_source.clone());
1202
1203    // Idempotency.
1204    let struct_names = parse_struct_names(&current);
1205    if struct_names.iter().any(|n| n == &r.to) {
1206        return Err(ExecutionError::FileConflict {
1207            path: format!("apps/{app}/models.rs"),
1208            reason: format!(
1209                "struct `{}` already exists in this file; rename appears applied",
1210                r.to
1211            ),
1212        });
1213    }
1214    if !struct_names.iter().any(|n| n == &r.from) {
1215        return Err(ExecutionError::FileConflict {
1216            path: format!("apps/{app}/models.rs"),
1217            reason: format!("struct `{}` not found — nothing to rename", r.from),
1218        });
1219    }
1220
1221    let old_table = find_table_for_struct(&current, &r.from)
1222        .or_else(|| fallback_table_name(&r.from))
1223        .ok_or_else(|| {
1224            ExecutionError::ProjectStructure(format!(
1225                "could not find `const TABLE` for struct `{}`",
1226                r.from
1227            ))
1228        })?;
1229    let new_table = fallback_table_name(&r.to).unwrap_or_else(|| old_table.clone());
1230
1231    // Phase 2: PG's ALTER TABLE … RENAME TO automatically updates
1232    // every dependent FK reference, so the SQLite FK guard is gone.
1233
1234    // Patch models.rs.
1235    let patched_models = patch_models_for_rename_model(
1236        &current, &r.from, &r.to, &old_table, &new_table,
1237    )
1238    .map_err(|msg| ExecutionError::FileConflict {
1239        path: format!("apps/{app}/models.rs"),
1240        reason: msg,
1241    })?;
1242    shadow.insert(app.clone(), patched_models.clone());
1243
1244    // Patch admin.rs (required — the app must re-register the model).
1245    let admin_path = project.root.join("apps").join(&app).join("admin.rs");
1246    let admin_source =
1247        std::fs::read_to_string(&admin_path).map_err(|e| ExecutionError::IoError {
1248            path: admin_path.display().to_string(),
1249            message: e.to_string(),
1250        })?;
1251    let admin_patched =
1252        patch_admin_for_rename_model(&admin_source, &r.from, &r.to).map_err(|msg| {
1253            ExecutionError::FileConflict {
1254                path: admin_path.display().to_string(),
1255                reason: msg,
1256            }
1257        })?;
1258
1259    // Patch views.rs best-effort (identifier boundaries only). Only
1260    // emit a change if the file exists and actually contains the old
1261    // name as a standalone identifier.
1262    let views_path = project.root.join("apps").join(&app).join("views.rs");
1263    let views_change: Option<PlannedFileChange> = if views_path.is_file() {
1264        let views_source =
1265            std::fs::read_to_string(&views_path).map_err(|e| ExecutionError::IoError {
1266                path: views_path.display().to_string(),
1267                message: e.to_string(),
1268            })?;
1269        let patched_views = rename_identifier_bounded(&views_source, &r.from, &r.to);
1270        if patched_views != views_source {
1271            Some(PlannedFileChange {
1272                path: views_path,
1273                kind: FileChangeKind::Update,
1274                new_contents: patched_views,
1275                expected_current_contents: Some(views_source),
1276            })
1277        } else {
1278            None
1279        }
1280    } else {
1281        None
1282    };
1283
1284    let sql = format!(
1285        "-- Generated by rustio ai apply (0.5.3). DO NOT EDIT.\n\
1286         ALTER TABLE {old_table} RENAME TO {new_table};\n"
1287    );
1288    let mig_name = format!("rename_{old_table}_to_{new_table}");
1289    let (mig_path, mig_filename) = new_migration_path(project, *migration_counter, &mig_name);
1290    *migration_counter += 1;
1291
1292    let mut changes: Vec<PlannedFileChange> = vec![
1293        PlannedFileChange {
1294            path: project.root.join("apps").join(&app).join("models.rs"),
1295            kind: FileChangeKind::Update,
1296            new_contents: patched_models,
1297            expected_current_contents: Some(initial_source),
1298        },
1299        PlannedFileChange {
1300            path: admin_path,
1301            kind: FileChangeKind::Update,
1302            new_contents: admin_patched,
1303            expected_current_contents: Some(admin_source),
1304        },
1305    ];
1306    if let Some(vc) = views_change {
1307        changes.push(vc);
1308    }
1309    changes.push(PlannedFileChange {
1310        path: mig_path,
1311        kind: FileChangeKind::Create,
1312        new_contents: sql,
1313        expected_current_contents: None,
1314    });
1315
1316    Ok((
1317        changes,
1318        format!(
1319            "~ Rename model \"{from}\" to \"{to}\" (migration {mig})\n\
1320             \x20   ⚠ Table renamed from `{old_table}` to `{new_table}`. User code using `{from}` outside apps/{app}/ must be updated manually.",
1321            from = r.from,
1322            to = r.to,
1323            mig = mig_filename,
1324        ),
1325    ))
1326}
1327
1328// ---------------------------------------------------------------------------
1329// Phase 2 cleanup notes
1330// ---------------------------------------------------------------------------
1331//
1332// The SQLite recreate-table machinery (generate_sqlite_recreate_table_migration,
1333// generate_sqlite_recreate_table_migration_fk_aware, column_def,
1334// column_def_with_relation_context, table_has_any_foreign_key) is gone.
1335// PostgreSQL's native ALTER paths replaced every caller —
1336// apply_change_field_type, apply_change_field_nullability,
1337// apply_remove_field, apply_remove_relation, apply_rename_model, and
1338// plan_retrofit_foreign_keys. See the per-function comments for
1339// the new SQL shape each one emits.
1340
1341/// Replace `from` with `to` only at identifier boundaries (byte before
1342/// and after are not identifier chars). Used for the bounded rename
1343/// sweep in `views.rs` so we don't clobber substrings inside string
1344/// literals or comments that happen to contain the old name.
1345fn rename_identifier_bounded(src: &str, from: &str, to: &str) -> String {
1346    let bytes = src.as_bytes();
1347    let from_bytes = from.as_bytes();
1348    let n = from_bytes.len();
1349    if n == 0 {
1350        return src.to_string();
1351    }
1352    let mut out = String::with_capacity(src.len());
1353    let mut i = 0;
1354    let mut last = 0;
1355    while i + n <= bytes.len() {
1356        if &bytes[i..i + n] == from_bytes {
1357            let left_ok = i == 0 || !is_ident_byte(bytes[i - 1]);
1358            let right_ok = i + n == bytes.len() || !is_ident_byte(bytes[i + n]);
1359            if left_ok && right_ok {
1360                out.push_str(&src[last..i]);
1361                out.push_str(to);
1362                i += n;
1363                last = i;
1364                continue;
1365            }
1366        }
1367        i += 1;
1368    }
1369    out.push_str(&src[last..]);
1370    out
1371}
1372
1373fn is_ident_byte(b: u8) -> bool {
1374    b.is_ascii_alphanumeric() || b == b'_'
1375}
1376
1377/// Shadow-apply a primitive to a schema copy so later steps in the
1378/// same plan see the earlier step's shape change. Mirrors the review
1379/// layer's logic but is executor-internal so we aren't coupled to
1380/// review's visibility rules.
1381fn apply_schema_shadow(p: &Primitive, schema: &mut Schema) {
1382    match p {
1383        Primitive::AddField(a) => {
1384            if let Some(m) = schema.models.iter_mut().find(|m| m.name == a.model) {
1385                m.fields.push(SchemaField {
1386                    name: a.field.name.clone(),
1387                    ty: a.field.ty.clone(),
1388                    nullable: a.field.nullable,
1389                    editable: a.field.editable,
1390                    relation: None,
1391                });
1392            }
1393        }
1394        Primitive::RenameField(r) => {
1395            if let Some(m) = schema.models.iter_mut().find(|m| m.name == r.model) {
1396                if let Some(f) = m.fields.iter_mut().find(|f| f.name == r.from) {
1397                    f.name = r.to.clone();
1398                }
1399            }
1400        }
1401        Primitive::ChangeFieldType(c) => {
1402            if let Some(m) = schema.models.iter_mut().find(|m| m.name == c.model) {
1403                if let Some(f) = m.fields.iter_mut().find(|f| f.name == c.field) {
1404                    f.ty = c.new_type.clone();
1405                }
1406            }
1407        }
1408        Primitive::ChangeFieldNullability(c) => {
1409            if let Some(m) = schema.models.iter_mut().find(|m| m.name == c.model) {
1410                if let Some(f) = m.fields.iter_mut().find(|f| f.name == c.field) {
1411                    f.nullable = c.nullable;
1412                }
1413            }
1414        }
1415        Primitive::RenameModel(r) => {
1416            if let Some(m) = schema.models.iter_mut().find(|m| m.name == r.from) {
1417                m.name = r.to.clone();
1418            }
1419        }
1420        Primitive::AddRelation(r) => {
1421            use crate::schema::{Relation, RelationKind};
1422            if !matches!(r.kind, RelationKind::BelongsTo) {
1423                return;
1424            }
1425            if let Some(m) = schema.models.iter_mut().find(|m| m.name == r.from) {
1426                if m.fields.iter().any(|f| f.name == r.via) {
1427                    return;
1428                }
1429                m.fields.push(SchemaField {
1430                    name: r.via.clone(),
1431                    ty: "i64".to_string(),
1432                    nullable: !r.required,
1433                    editable: true,
1434                    relation: Some(Relation {
1435                        model: r.to.clone(),
1436                        field: "id".to_string(),
1437                        kind: RelationKind::BelongsTo,
1438                        // AI-planner-authored relations don't declare a
1439                        // display field: the admin renders `#<id>` until
1440                        // the model author opts in via the macro.
1441                        display_field: None,
1442                        // 0.9.0 — propagate FK metadata into the schema
1443                        // snapshot so `rustio.schema.json` reflects what
1444                        // the migration just committed.
1445                        required: Some(r.required),
1446                        on_delete: Some(r.on_delete.as_str().to_string()),
1447                    }),
1448                });
1449            }
1450        }
1451        _ => {}
1452    }
1453}
1454
1455/// Return a human-readable reason string if `step` violates a policy
1456/// under the given context; `None` means the step is allowed.
1457/// Conservative by design — the list grows only as each rule is
1458/// justified.
1459fn policy_violation_for(step: &Primitive, pii: &[&str], ctx: &ContextConfig) -> Option<String> {
1460    let ctx_tag = {
1461        let mut parts: Vec<String> = Vec::new();
1462        if let Some(c) = &ctx.country {
1463            parts.push(format!("country={c}"));
1464        }
1465        if let Some(i) = &ctx.industry {
1466            parts.push(format!("industry={i}"));
1467        }
1468        if ctx.requires_gdpr() {
1469            parts.push("GDPR".to_string());
1470        }
1471        if parts.is_empty() {
1472            String::new()
1473        } else {
1474            format!(" ({})", parts.join(", "))
1475        }
1476    };
1477    match step {
1478        Primitive::RemoveField(r) if pii.iter().any(|p| *p == r.field) => Some(format!(
1479            "refusing to remove `{}.{}` — it is personally-identifying data under the project context{}. Change the context or update the plan by hand.",
1480            r.model, r.field, ctx_tag,
1481        )),
1482        Primitive::ChangeFieldType(c) if pii.iter().any(|p| *p == c.field) => Some(format!(
1483            "refusing to change the type of `{}.{}` — it is personally-identifying data under the project context{}; retention / hashing pipelines depend on the stored shape.",
1484            c.model, c.field, ctx_tag,
1485        )),
1486        Primitive::RenameField(r) if pii.iter().any(|p| *p == r.from) => Some(format!(
1487            "refusing to rename `{}.{}` — it is personally-identifying data under the project context{}; audit trails keyed on the old name would break.",
1488            r.model, r.from, ctx_tag,
1489        )),
1490        _ => None,
1491    }
1492}
1493
1494// ---------------------------------------------------------------------------
1495// models.rs patching
1496// ---------------------------------------------------------------------------
1497
1498/// 0.9.1 — inverse of [`patch_models_for_add_field`]. Strips a field's
1499/// struct declaration, its entries in `COLUMNS` / `INSERT_COLUMNS`, its
1500/// `from_row` accessor line, and its `insert_values` line.
1501///
1502/// Every sub-step uses exact-match substring removal and reports a
1503/// clean error when the expected shape isn't present. The failure mode
1504/// matches the add path: refuse, don't half-remove.
1505fn patch_models_for_remove_field(
1506    source: &str,
1507    struct_name: &str,
1508    field_name: &str,
1509    field_ty: &str,
1510    nullable: bool,
1511) -> Result<String, String> {
1512    let mut out = source.to_string();
1513
1514    // 1. Struct field line.
1515    let rust_type = rust_type_for(field_ty, nullable);
1516    let struct_line = format!("    pub {field_name}: {rust_type},\n");
1517    out = replace_in_struct_literal(&out, struct_name, &struct_line, "")?;
1518
1519    // 2. COLUMNS array. Scoped to the matching `impl Model for <struct>`
1520    // block so files with multiple models don't cross-contaminate.
1521    out = remove_from_str_array_scoped(&out, struct_name, "COLUMNS", field_name)?;
1522
1523    // 3. INSERT_COLUMNS (not populated for auto-fields like `id`).
1524    // Best-effort: ignore the error if the field wasn't listed.
1525    if out.contains("const INSERT_COLUMNS") {
1526        out = remove_from_str_array_scoped(&out, struct_name, "INSERT_COLUMNS", field_name)
1527            .unwrap_or(out);
1528    }
1529
1530    // 4. from_row accessor — scoped to the struct's impl block so a
1531    // same-named field in a sibling model isn't stripped by accident.
1532    let accessor = row_accessor(field_ty, nullable);
1533    let from_row_line = format!("            {field_name}: row.{accessor}(\"{field_name}\")?,\n",);
1534    out = replace_in_impl_method_literal(
1535        &out,
1536        struct_name,
1537        "fn from_row(",
1538        "Ok(Self {",
1539        &from_row_line,
1540        "",
1541    )?;
1542
1543    // 5. insert_values line — same scoping rationale.
1544    let insert_line = build_insert_values_line(field_name, field_ty, nullable);
1545    out = replace_in_impl_method_literal(
1546        &out,
1547        struct_name,
1548        "fn insert_values(",
1549        "vec![",
1550        &insert_line,
1551        "",
1552    )?;
1553
1554    Ok(out)
1555}
1556
1557/// 0.9.1 — generic in-place replace inside a specific method body of a
1558/// specific `impl Model for <struct>` block. The method is located by
1559/// `fn_anchor` (e.g. `"fn from_row("`); the body delimiter is the
1560/// innermost brace/bracket after that, found via `body_open`
1561/// (`"Ok(Self {"` or `"vec!["`). Replaces the first occurrence of
1562/// `from` inside the body with `to`, failing cleanly if absent.
1563fn replace_in_impl_method_literal(
1564    src: &str,
1565    struct_name: &str,
1566    fn_anchor: &str,
1567    body_open: &str,
1568    from: &str,
1569    to: &str,
1570) -> Result<String, String> {
1571    let impl_anchor = format!("impl Model for {struct_name}");
1572    let impl_start = src
1573        .find(&impl_anchor)
1574        .ok_or_else(|| format!("could not find `{impl_anchor}`"))?;
1575    let impl_brace_rel = src[impl_start..]
1576        .find('{')
1577        .ok_or_else(|| format!("`{impl_anchor}` has no opening brace"))?;
1578    let impl_open = impl_start + impl_brace_rel;
1579    let impl_close = find_matching_brace(src, impl_open)
1580        .ok_or_else(|| format!("`{impl_anchor}` is not closed"))?;
1581
1582    let block = &src[impl_open..=impl_close];
1583    let fn_rel = block
1584        .find(fn_anchor)
1585        .ok_or_else(|| format!("`{fn_anchor}` not found inside `{impl_anchor}`"))?;
1586    let body_rel_in_fn = block[fn_rel..]
1587        .find(body_open)
1588        .ok_or_else(|| format!("`{body_open}` not found after `{fn_anchor}`"))?;
1589    let body_open_abs = impl_open + fn_rel + body_rel_in_fn + body_open.len() - 1;
1590    // `body_open.len() - 1` lands on the opening bracket/brace itself.
1591    let body_close_abs = match src.as_bytes()[body_open_abs] {
1592        b'{' => find_matching_brace(src, body_open_abs),
1593        b'[' => find_matching_bracket(src, body_open_abs),
1594        _ => None,
1595    }
1596    .ok_or_else(|| format!("unterminated body after `{body_open}`"))?;
1597
1598    let body = &src[body_open_abs + 1..body_close_abs];
1599    if !body.contains(from) {
1600        return Err(format!(
1601            "{fn_anchor} body on `{struct_name}` does not contain `{from}`"
1602        ));
1603    }
1604    let new_body = body.replacen(from, to, 1);
1605    let mut out = String::with_capacity(src.len());
1606    out.push_str(&src[..=body_open_abs]);
1607    out.push_str(&new_body);
1608    out.push_str(&src[body_close_abs..]);
1609    Ok(out)
1610}
1611
1612/// 0.9.1 — same as [`remove_from_str_array`] but restricts the search
1613/// to the `impl Model for <struct>` block so files that declare
1614/// multiple models don't cross-contaminate.
1615fn remove_from_str_array_scoped(
1616    src: &str,
1617    struct_name: &str,
1618    const_name: &str,
1619    field: &str,
1620) -> Result<String, String> {
1621    let impl_anchor = format!("impl Model for {struct_name}");
1622    let impl_start = src
1623        .find(&impl_anchor)
1624        .ok_or_else(|| format!("could not find `{impl_anchor}`"))?;
1625    let brace_rel = src[impl_start..]
1626        .find('{')
1627        .ok_or_else(|| format!("`{impl_anchor}` has no opening brace"))?;
1628    let impl_open = impl_start + brace_rel;
1629    let impl_close = find_matching_brace(src, impl_open)
1630        .ok_or_else(|| format!("`{impl_anchor}` is not closed"))?;
1631
1632    // Run the array removal only on the block's text, then splice it
1633    // back into the file.
1634    let block = &src[impl_open..=impl_close];
1635    let new_block = remove_from_str_array(block, const_name, field)?;
1636    let mut out = String::with_capacity(src.len() + new_block.len() - block.len());
1637    out.push_str(&src[..impl_open]);
1638    out.push_str(&new_block);
1639    out.push_str(&src[impl_close + 1..]);
1640    Ok(out)
1641}
1642
1643/// 0.9.1 — drop an exact `"<field>"` entry from the named
1644/// `const X: &[&str]` array. Works on both single-line arrays
1645/// (`&["a", "b", "c"]`) and multi-line arrays (one literal per line,
1646/// which is what the scaffolder generates once the list grows). Handles
1647/// the surrounding comma so the resulting array stays valid Rust.
1648/// Errors cleanly if the field is not present.
1649fn remove_from_str_array(src: &str, const_name: &str, field: &str) -> Result<String, String> {
1650    let anchor = format!("const {const_name}");
1651    let start = src
1652        .find(&anchor)
1653        .ok_or_else(|| format!("could not find `const {const_name}`"))?;
1654    let rel_open = src[start..]
1655        .find("= &[")
1656        .ok_or_else(|| format!("`const {const_name}` does not use `= &[ … ]`"))?;
1657    let open = start + rel_open + "= &".len();
1658    let close = find_matching_bracket(src, open)
1659        .ok_or_else(|| format!("`const {const_name}` array is not closed"))?;
1660    let inner = &src[open + 1..close];
1661    let literal = format!("\"{field}\"");
1662    let literal_idx = inner
1663        .find(&literal)
1664        .ok_or_else(|| format!("`{const_name}` does not contain \"{field}\""))?;
1665
1666    // Expand the removal slice to cover the separator surrounding the
1667    // literal, whether the separator is `, ` (single-line), `,\n    `
1668    // (multi-line interior), or a leading newline+indent followed by
1669    // no trailing separator (multi-line final element). The rule:
1670    //   - Start at the literal's first byte.
1671    //   - End at the next non-whitespace byte AFTER the comma that
1672    //     follows the literal. If there is no trailing comma (last
1673    //     element), rewind the start to just after the previous
1674    //     non-whitespace byte so we drop the leading `,` instead.
1675    let mut slice_start = literal_idx;
1676    let mut slice_end = literal_idx + literal.len();
1677
1678    // Scan right looking for the next comma + any following whitespace.
1679    let after_literal = &inner.as_bytes()[slice_end..];
1680    if let Some(comma_rel) = after_literal.iter().position(|&b| !b.is_ascii_whitespace()) {
1681        if after_literal[comma_rel] == b',' {
1682            slice_end += comma_rel + 1;
1683            // Consume trailing whitespace after the comma too so we
1684            // don't leave a stranded blank line in the multi-line form.
1685            while slice_end < inner.len()
1686                && (inner.as_bytes()[slice_end] == b' ' || inner.as_bytes()[slice_end] == b'\t')
1687            {
1688                slice_end += 1;
1689            }
1690            if slice_end < inner.len() && inner.as_bytes()[slice_end] == b'\n' {
1691                // Multi-line case: keep exactly one newline + indent
1692                // before the next element by *not* consuming the \n.
1693                // (The leading `    ` indent of the next element was
1694                // already consumed by the trailing-ws loop above.)
1695            }
1696        }
1697    } else {
1698        // No trailing comma — this was the last element. Walk left
1699        // from the literal to the previous comma and eat it.
1700        let before = &inner.as_bytes()[..slice_start];
1701        if let Some(pos) = before.iter().rposition(|&b| !b.is_ascii_whitespace()) {
1702            if before[pos] == b',' {
1703                slice_start = pos;
1704            }
1705        }
1706    }
1707
1708    let mut new_inner = String::with_capacity(inner.len());
1709    new_inner.push_str(&inner[..slice_start]);
1710    new_inner.push_str(&inner[slice_end..]);
1711
1712    // Collapse the blank line that multi-line arrays leave behind when
1713    // a middle element is removed (`"x",\n        \n        "y",` →
1714    // `"x",\n        "y",`). The needle is `\n<horizontal-ws>\n` —
1715    // drop the whitespace-only line by removing the leading
1716    // whitespace + its trailing newline.
1717    let mut cursor = 0;
1718    while let Some(rel) = new_inner[cursor..].find('\n') {
1719        let pos = cursor + rel;
1720        let after = &new_inner[pos + 1..];
1721        let lead_ws = after
1722            .bytes()
1723            .take_while(|&b| b == b' ' || b == b'\t')
1724            .count();
1725        if after
1726            .as_bytes()
1727            .get(lead_ws)
1728            .map(|&b| b == b'\n')
1729            .unwrap_or(false)
1730        {
1731            let drain_end = pos + 1 + lead_ws + 1;
1732            new_inner.drain(pos + 1..drain_end);
1733            // Don't advance — the collapse might have produced a
1734            // second blank line.
1735        } else {
1736            cursor = pos + 1;
1737        }
1738    }
1739
1740    let mut out = String::with_capacity(src.len());
1741    out.push_str(&src[..=open]);
1742    out.push_str(&new_inner);
1743    out.push_str(&src[close..]);
1744    Ok(out)
1745}
1746
1747fn patch_models_for_add_field(
1748    source: &str,
1749    struct_name: &str,
1750    field: &FieldSpec,
1751) -> Result<String, String> {
1752    let rust_type = rust_type_for(&field.ty, field.nullable);
1753    let mut out = source.to_string();
1754
1755    // 1. Make sure chrono is imported when we're adding a DateTime.
1756    // Check for an actual `use chrono::` statement, not the string
1757    // "chrono::" anywhere — the scaffolded docstring mentions
1758    // `chrono::DateTime<Utc>` in comments, which would otherwise
1759    // fool the check and leave the file without an import.
1760    if field.ty == "DateTime" && !has_chrono_use(&out) {
1761        out = insert_chrono_import(&out);
1762    }
1763
1764    // 2. Struct field.
1765    let field_line = format!("    pub {}: {},\n", field.name, rust_type);
1766    out = insert_before_struct_close(&out, struct_name, &field_line)?;
1767
1768    // 3. COLUMNS.
1769    out = insert_into_str_array(&out, "COLUMNS", &field.name)?;
1770    // 4. INSERT_COLUMNS (best-effort; some models may skip it for
1771    // auto-populated fields like id).
1772    if out.contains("const INSERT_COLUMNS") {
1773        out = insert_into_str_array(&out, "INSERT_COLUMNS", &field.name)?;
1774    }
1775
1776    // 5. from_row accessor.
1777    let accessor = row_accessor(&field.ty, field.nullable);
1778    let from_row_line = format!(
1779        "            {name}: row.{accessor}(\"{name}\")?,\n",
1780        name = field.name,
1781        accessor = accessor,
1782    );
1783    out = insert_before_ok_self_close(&out, &from_row_line)?;
1784
1785    // 6. insert_values.
1786    let insert_line = build_insert_values_line(&field.name, &field.ty, field.nullable);
1787    out = insert_before_vec_close(&out, &insert_line)?;
1788
1789    Ok(out)
1790}
1791
1792fn patch_models_for_rename_field(
1793    source: &str,
1794    struct_name: &str,
1795    from: &str,
1796    to: &str,
1797) -> Result<String, String> {
1798    let mut out = source.to_string();
1799
1800    // 1. Struct field name.
1801    out = rename_in_struct(&out, struct_name, from, to)?;
1802
1803    // 2. COLUMNS + INSERT_COLUMNS — match the exact "<from>" literal.
1804    out = replace_in_str_array(&out, "COLUMNS", from, to)?;
1805    if out.contains("const INSERT_COLUMNS") {
1806        // INSERT_COLUMNS may not contain the field (e.g. id is excluded).
1807        // `replace_in_str_array` is lenient: it only rewrites on match.
1808        out = replace_in_str_array(&out, "INSERT_COLUMNS", from, to).unwrap_or(out);
1809    }
1810
1811    // 3. from_row body: `<from>: row.get_X("<from>")?,` → `<to>: row.get_X("<to>")?,`
1812    out = rename_in_from_row(&out, from, to)?;
1813
1814    // 4. insert_values body: `self.<from>` → `self.<to>`
1815    out = rename_in_insert_values(&out, from, to)?;
1816
1817    Ok(out)
1818}
1819
1820/// Rewrite the struct field declaration, the `from_row` accessor, and
1821/// (for `String`) the `.clone()` call in `insert_values` so the Rust
1822/// side matches the new column type.
1823fn patch_models_for_change_field_type(
1824    source: &str,
1825    struct_name: &str,
1826    field_name: &str,
1827    old_ty: &str,
1828    new_ty: &str,
1829    nullable: bool,
1830) -> Result<String, String> {
1831    let mut out = source.to_string();
1832    // Ensure chrono import when introducing DateTime.
1833    if (new_ty == "DateTime") && !has_chrono_use(&out) {
1834        out = insert_chrono_import(&out);
1835    }
1836    // 1. Struct field line.
1837    let old_rust = rust_type_for(old_ty, nullable);
1838    let new_rust = rust_type_for(new_ty, nullable);
1839    out = replace_in_struct_literal(
1840        &out,
1841        struct_name,
1842        &format!("pub {field_name}: {old_rust},"),
1843        &format!("pub {field_name}: {new_rust},"),
1844    )?;
1845    // 2. from_row accessor.
1846    let old_acc = row_accessor(old_ty, nullable);
1847    let new_acc = row_accessor(new_ty, nullable);
1848    if old_acc != new_acc {
1849        out = replace_in_from_row_literal(
1850            &out,
1851            &format!("{field_name}: row.{old_acc}(\"{field_name}\")?,"),
1852            &format!("{field_name}: row.{new_acc}(\"{field_name}\")?,"),
1853        )?;
1854    }
1855    // 3. insert_values line — may gain/lose `.clone()` when moving
1856    // between `String` and Copy-able types.
1857    let old_line = build_insert_values_line(field_name, old_ty, nullable);
1858    let new_line = build_insert_values_line(field_name, new_ty, nullable);
1859    if old_line != new_line {
1860        let old_trim = old_line.trim().to_string();
1861        let new_trim = new_line.trim().to_string();
1862        out = replace_in_insert_values_literal(&out, &old_trim, &new_trim)?;
1863    }
1864    Ok(out)
1865}
1866
1867/// Flip the Rust-side shape for a nullability change. Struct field type
1868/// gains/loses `Option<…>`; the `from_row` accessor swaps between
1869/// `get_X` and `get_optional_X`. `insert_values` is unchanged — the
1870/// `From<Option<T>> for Value` blanket handles both shapes.
1871fn patch_models_for_change_nullability(
1872    source: &str,
1873    struct_name: &str,
1874    field_name: &str,
1875    ty: &str,
1876    was_nullable: bool,
1877    now_nullable: bool,
1878) -> Result<String, String> {
1879    let mut out = source.to_string();
1880    let old_rust = rust_type_for(ty, was_nullable);
1881    let new_rust = rust_type_for(ty, now_nullable);
1882    out = replace_in_struct_literal(
1883        &out,
1884        struct_name,
1885        &format!("pub {field_name}: {old_rust},"),
1886        &format!("pub {field_name}: {new_rust},"),
1887    )?;
1888    let old_acc = row_accessor(ty, was_nullable);
1889    let new_acc = row_accessor(ty, now_nullable);
1890    out = replace_in_from_row_literal(
1891        &out,
1892        &format!("{field_name}: row.{old_acc}(\"{field_name}\")?,"),
1893        &format!("{field_name}: row.{new_acc}(\"{field_name}\")?,"),
1894    )?;
1895    Ok(out)
1896}
1897
1898/// Update `models.rs` for a model rename: the struct name, the
1899/// `impl Model for …` header, and the `TABLE` const.
1900fn patch_models_for_rename_model(
1901    source: &str,
1902    old_struct: &str,
1903    new_struct: &str,
1904    old_table: &str,
1905    new_table: &str,
1906) -> Result<String, String> {
1907    let mut out = source.to_string();
1908
1909    let old_struct_decl = format!("pub struct {old_struct}");
1910    let new_struct_decl = format!("pub struct {new_struct}");
1911    if !out.contains(&old_struct_decl) {
1912        return Err(format!("struct `{old_struct}` not found"));
1913    }
1914    out = out.replacen(&old_struct_decl, &new_struct_decl, 1);
1915
1916    let old_impl = format!("impl Model for {old_struct}");
1917    let new_impl = format!("impl Model for {new_struct}");
1918    if out.contains(&old_impl) {
1919        out = out.replacen(&old_impl, &new_impl, 1);
1920    }
1921
1922    let old_tbl = format!("const TABLE: &'static str = \"{old_table}\";");
1923    let new_tbl = format!("const TABLE: &'static str = \"{new_table}\";");
1924    if out.contains(&old_tbl) {
1925        out = out.replacen(&old_tbl, &new_tbl, 1);
1926    }
1927    Ok(out)
1928}
1929
1930/// Update `admin.rs` for a model rename: `use super::models::Old;`
1931/// and `admin.model::<Old>()`.
1932fn patch_admin_for_rename_model(
1933    source: &str,
1934    old_struct: &str,
1935    new_struct: &str,
1936) -> Result<String, String> {
1937    let mut out = source.to_string();
1938    let old_use = format!("use super::models::{old_struct};");
1939    let new_use = format!("use super::models::{new_struct};");
1940    if out.contains(&old_use) {
1941        out = out.replacen(&old_use, &new_use, 1);
1942    }
1943    let old_call = format!("admin.model::<{old_struct}>()");
1944    let new_call = format!("admin.model::<{new_struct}>()");
1945    if !out.contains(&old_call) {
1946        return Err(format!(
1947            "`admin.rs` does not call `admin.model::<{old_struct}>()`"
1948        ));
1949    }
1950    out = out.replacen(&old_call, &new_call, 1);
1951    Ok(out)
1952}
1953
1954// --- tiny, targeted source-patching primitives ------------------------------
1955
1956/// Replace a literal substring inside the named struct block only.
1957/// Used by change-type / nullability patchers where we need the old
1958/// string to be matched exactly rather than by field-name heuristics.
1959fn replace_in_struct_literal(
1960    src: &str,
1961    struct_name: &str,
1962    from: &str,
1963    to: &str,
1964) -> Result<String, String> {
1965    let (open, close) = find_struct_block(src, struct_name)
1966        .ok_or_else(|| format!("struct `{struct_name}` block not found"))?;
1967    let block = &src[open..=close];
1968    if !block.contains(from) {
1969        return Err(format!("struct `{struct_name}` does not contain `{from}`"));
1970    }
1971    let new_block = block.replacen(from, to, 1);
1972    let mut out = String::with_capacity(src.len());
1973    out.push_str(&src[..open]);
1974    out.push_str(&new_block);
1975    out.push_str(&src[close + 1..]);
1976    Ok(out)
1977}
1978
1979fn replace_in_from_row_literal(src: &str, from: &str, to: &str) -> Result<String, String> {
1980    let fn_start = src
1981        .find("fn from_row(")
1982        .ok_or_else(|| "`fn from_row(` not found".to_string())?;
1983    let ok_self_rel = src[fn_start..]
1984        .find("Ok(Self {")
1985        .ok_or_else(|| "`Ok(Self {` not found".to_string())?;
1986    let ok_self_open = fn_start + ok_self_rel + "Ok(Self ".len();
1987    let ok_self_close = find_matching_brace(src, ok_self_open)
1988        .ok_or_else(|| "`Ok(Self { … }` is not closed".to_string())?;
1989    let block = &src[ok_self_open..=ok_self_close];
1990    if !block.contains(from) {
1991        return Err(format!("from_row does not contain `{from}`"));
1992    }
1993    let replaced = block.replacen(from, to, 1);
1994    let mut out = String::with_capacity(src.len());
1995    out.push_str(&src[..ok_self_open]);
1996    out.push_str(&replaced);
1997    out.push_str(&src[ok_self_close + 1..]);
1998    Ok(out)
1999}
2000
2001fn replace_in_insert_values_literal(src: &str, from: &str, to: &str) -> Result<String, String> {
2002    let fn_start = src
2003        .find("fn insert_values(")
2004        .ok_or_else(|| "`fn insert_values(` not found".to_string())?;
2005    let vec_rel = src[fn_start..]
2006        .find("vec![")
2007        .ok_or_else(|| "no `vec![` inside insert_values".to_string())?;
2008    let vec_open = fn_start + vec_rel + 4;
2009    let vec_close = find_matching_bracket(src, vec_open)
2010        .ok_or_else(|| "`vec![ … ]` is not closed".to_string())?;
2011    let block = &src[vec_open..=vec_close];
2012    if !block.contains(from) {
2013        return Err(format!("insert_values does not contain `{from}`"));
2014    }
2015    let replaced = block.replacen(from, to, 1);
2016    let mut out = String::with_capacity(src.len());
2017    out.push_str(&src[..vec_open]);
2018    out.push_str(&replaced);
2019    out.push_str(&src[vec_close + 1..]);
2020    Ok(out)
2021}
2022
2023fn find_struct_block(src: &str, name: &str) -> Option<(usize, usize)> {
2024    let anchor = format!("pub struct {name}");
2025    let start = src.find(&anchor)?;
2026    // Guard against substring matches (`TaskExtra` when looking for `Task`):
2027    // the next char after the name must be whitespace or `{` or `<`.
2028    let after_name = start + anchor.len();
2029    match src.as_bytes().get(after_name)? {
2030        b' ' | b'{' | b'\t' | b'\n' | b'<' => {}
2031        _ => return None,
2032    }
2033    let open = start + src[start..].find('{')?;
2034    let close = find_matching_brace(src, open)?;
2035    Some((open, close))
2036}
2037
2038fn find_matching_brace(src: &str, open_idx: usize) -> Option<usize> {
2039    let bytes = src.as_bytes();
2040    if *bytes.get(open_idx)? != b'{' {
2041        return None;
2042    }
2043    let mut depth: i32 = 0;
2044    let mut i = open_idx;
2045    while i < bytes.len() {
2046        match bytes[i] {
2047            b'{' => depth += 1,
2048            b'}' => {
2049                depth -= 1;
2050                if depth == 0 {
2051                    return Some(i);
2052                }
2053            }
2054            _ => {}
2055        }
2056        i += 1;
2057    }
2058    None
2059}
2060
2061fn struct_declares_field(inside_struct: &str, field_name: &str) -> bool {
2062    // Match `pub <field>:` or `pub <field> :`. Line-scoped.
2063    for line in inside_struct.lines() {
2064        let trimmed = line.trim_start();
2065        if let Some(rest) = trimmed.strip_prefix("pub ") {
2066            let rest = rest.trim_start();
2067            // Identifier then optional whitespace then ":"
2068            let mut chars = rest.chars();
2069            let mut ident = String::new();
2070            for ch in chars.by_ref() {
2071                if ch.is_ascii_alphanumeric() || ch == '_' {
2072                    ident.push(ch);
2073                } else {
2074                    break;
2075                }
2076            }
2077            if ident == field_name {
2078                let rest = rest.trim_start_matches(&ident[..]).trim_start();
2079                if rest.starts_with(':') {
2080                    return true;
2081                }
2082            }
2083        }
2084    }
2085    false
2086}
2087
2088fn insert_before_struct_close(
2089    src: &str,
2090    struct_name: &str,
2091    new_line: &str,
2092) -> Result<String, String> {
2093    let (_open, close) = find_struct_block(src, struct_name)
2094        .ok_or_else(|| format!("could not locate `pub struct {struct_name}` block"))?;
2095    insert_before_brace(src, close, new_line)
2096}
2097
2098fn insert_before_ok_self_close(src: &str, new_line: &str) -> Result<String, String> {
2099    // Find "Ok(Self {" inside a `fn from_row` body — simple string
2100    // search is good enough because the token is distinctive in the
2101    // scaffold template. Refuse if we see more than one occurrence.
2102    let needle = "Ok(Self {";
2103    let first = src
2104        .find(needle)
2105        .ok_or_else(|| "could not locate `Ok(Self {` in from_row".to_string())?;
2106    if src[first + needle.len()..].contains(needle) {
2107        return Err("multiple `Ok(Self {` in file; refusing to choose".into());
2108    }
2109    let open = first + needle.len() - 1; // index of `{`
2110    let close = find_matching_brace(src, open)
2111        .ok_or_else(|| "`Ok(Self { … }` is not closed".to_string())?;
2112    insert_before_brace(src, close, new_line)
2113}
2114
2115fn insert_before_vec_close(src: &str, new_line: &str) -> Result<String, String> {
2116    // Find `fn insert_values(` then `vec![` then the matching `]`.
2117    let fn_idx = src
2118        .find("fn insert_values(")
2119        .ok_or_else(|| "could not locate `fn insert_values(`".to_string())?;
2120    let vec_rel = src[fn_idx..]
2121        .find("vec![")
2122        .ok_or_else(|| "no `vec![` inside `insert_values`".to_string())?;
2123    let vec_open = fn_idx + vec_rel + 4; // index of `[`
2124    let close = find_matching_bracket(src, vec_open)
2125        .ok_or_else(|| "`vec![ … ]` is not closed".to_string())?;
2126    insert_before_bracket(src, close, new_line)
2127}
2128
2129fn find_matching_bracket(src: &str, open_idx: usize) -> Option<usize> {
2130    let bytes = src.as_bytes();
2131    if *bytes.get(open_idx)? != b'[' {
2132        return None;
2133    }
2134    let mut depth: i32 = 0;
2135    let mut i = open_idx;
2136    while i < bytes.len() {
2137        match bytes[i] {
2138            b'[' => depth += 1,
2139            b']' => {
2140                depth -= 1;
2141                if depth == 0 {
2142                    return Some(i);
2143                }
2144            }
2145            _ => {}
2146        }
2147        i += 1;
2148    }
2149    None
2150}
2151
2152fn insert_before_brace(src: &str, close: usize, new_line: &str) -> Result<String, String> {
2153    let before = &src[..close];
2154    let last_nl = before.rfind('\n').ok_or_else(|| {
2155        "refusing to patch single-line `{ … }`: file layout is outside the 0.5.2 safe subset"
2156            .to_string()
2157    })?;
2158    let mut out = String::with_capacity(src.len() + new_line.len());
2159    out.push_str(&src[..=last_nl]);
2160    out.push_str(new_line);
2161    if !new_line.ends_with('\n') {
2162        out.push('\n');
2163    }
2164    out.push_str(&src[last_nl + 1..]);
2165    Ok(out)
2166}
2167
2168fn insert_before_bracket(src: &str, close: usize, new_line: &str) -> Result<String, String> {
2169    let before = &src[..close];
2170    let last_nl = before.rfind('\n').ok_or_else(|| {
2171        "refusing to patch single-line `vec![ … ]`: outside the safe subset".to_string()
2172    })?;
2173    let mut out = String::with_capacity(src.len() + new_line.len());
2174    out.push_str(&src[..=last_nl]);
2175    out.push_str(new_line);
2176    if !new_line.ends_with('\n') {
2177        out.push('\n');
2178    }
2179    out.push_str(&src[last_nl + 1..]);
2180    Ok(out)
2181}
2182
2183fn insert_into_str_array(src: &str, const_name: &str, column: &str) -> Result<String, String> {
2184    let anchor = format!("const {const_name}");
2185    let start = src
2186        .find(&anchor)
2187        .ok_or_else(|| format!("could not find `const {const_name}`"))?;
2188    // Skip past the type annotation (e.g. `&'static [&'static str]`) to
2189    // the literal `= &[ … ]`. Looking for `= &[` is precise enough for
2190    // the scaffold's layout and refuses exotic formats loudly.
2191    let rel_open = src[start..]
2192        .find("= &[")
2193        .ok_or_else(|| format!("`const {const_name}` does not use the expected `= &[ … ]` form"))?;
2194    let open = start + rel_open + "= &".len();
2195    let close = find_matching_bracket(src, open)
2196        .ok_or_else(|| format!("`const {const_name}` array is not closed"))?;
2197    let inner = &src[open + 1..close];
2198    if inner.contains(&format!("\"{column}\"")) {
2199        return Err(format!(
2200            "`{const_name}` already contains \"{column}\"; refusing to duplicate"
2201        ));
2202    }
2203    // Build the new inner content.
2204    let trimmed = inner.trim_end_matches(|c: char| c.is_whitespace() || c == ',');
2205    let addition = if trimmed.trim().is_empty() {
2206        format!("\"{column}\"")
2207    } else {
2208        format!("{trimmed}, \"{column}\"")
2209    };
2210    // Preserve any trailing whitespace (newline indent) between the
2211    // last element and the closing bracket for multi-line arrays.
2212    let tail_ws_start = inner
2213        .rfind(|c: char| !c.is_whitespace() && c != ',')
2214        .map(|i| i + 1)
2215        .unwrap_or(0);
2216    let tail_ws = &inner[tail_ws_start..];
2217    let mut out = String::with_capacity(src.len() + column.len() + 4);
2218    out.push_str(&src[..=open]);
2219    out.push_str(&addition);
2220    out.push_str(tail_ws);
2221    out.push_str(&src[close..]);
2222    Ok(out)
2223}
2224
2225fn replace_in_str_array(
2226    src: &str,
2227    const_name: &str,
2228    from: &str,
2229    to: &str,
2230) -> Result<String, String> {
2231    let anchor = format!("const {const_name}");
2232    let start = src
2233        .find(&anchor)
2234        .ok_or_else(|| format!("could not find `const {const_name}`"))?;
2235    let rel_open = src[start..]
2236        .find("= &[")
2237        .ok_or_else(|| format!("`const {const_name}` does not use the expected `= &[ … ]` form"))?;
2238    let open = start + rel_open + "= &".len();
2239    let close = find_matching_bracket(src, open)
2240        .ok_or_else(|| format!("`const {const_name}` array is not closed"))?;
2241    let inner = &src[open + 1..close];
2242    let from_literal = format!("\"{from}\"");
2243    let to_literal = format!("\"{to}\"");
2244    if !inner.contains(&from_literal) {
2245        return Err(format!(
2246            "`{const_name}` does not contain \"{from}\"; rename cannot proceed"
2247        ));
2248    }
2249    if inner.contains(&to_literal) {
2250        return Err(format!(
2251            "`{const_name}` already contains \"{to}\"; rename target is taken"
2252        ));
2253    }
2254    // Replace only inside the bracketed range so we don't clobber other
2255    // occurrences of the same string elsewhere in the file.
2256    let new_inner = inner.replacen(&from_literal, &to_literal, 1);
2257    let mut out = String::with_capacity(src.len());
2258    out.push_str(&src[..=open]);
2259    out.push_str(&new_inner);
2260    out.push_str(&src[close..]);
2261    Ok(out)
2262}
2263
2264fn rename_in_struct(src: &str, struct_name: &str, from: &str, to: &str) -> Result<String, String> {
2265    let (open, close) =
2266        find_struct_block(src, struct_name).ok_or_else(|| "struct block not found".to_string())?;
2267    let block = &src[open..=close];
2268    let from_pattern = format!("pub {from}:");
2269    let to_pattern = format!("pub {to}:");
2270    if !block.contains(&from_pattern) {
2271        return Err(format!(
2272            "struct {struct_name} does not declare `pub {from}:`"
2273        ));
2274    }
2275    let new_block = block.replacen(&from_pattern, &to_pattern, 1);
2276    let mut out = String::with_capacity(src.len());
2277    out.push_str(&src[..open]);
2278    out.push_str(&new_block);
2279    out.push_str(&src[close + 1..]);
2280    Ok(out)
2281}
2282
2283fn rename_in_from_row(src: &str, from: &str, to: &str) -> Result<String, String> {
2284    let fn_start = src
2285        .find("fn from_row(")
2286        .ok_or_else(|| "from_row not found".to_string())?;
2287    let ok_self_rel = src[fn_start..]
2288        .find("Ok(Self {")
2289        .ok_or_else(|| "Ok(Self not found".to_string())?;
2290    let ok_self_open = fn_start + ok_self_rel + "Ok(Self ".len();
2291    let ok_self_close = find_matching_brace(src, ok_self_open)
2292        .ok_or_else(|| "Ok(Self block is not closed".to_string())?;
2293    let block = &src[ok_self_open..=ok_self_close];
2294    // Match the full accessor line so `priority` doesn't collide with `priority_2`.
2295    // Pattern: `\n<ws><from>: row.get_*("<from>")?,`
2296    let from_lhs = format!("{from}:");
2297    let from_arg = format!("\"{from}\"");
2298    let to_lhs = format!("{to}:");
2299    let to_arg = format!("\"{to}\"");
2300    if !block.contains(&from_lhs) {
2301        return Err(format!(
2302            "from_row does not reference `{from}:`; rename cannot proceed"
2303        ));
2304    }
2305    let replaced = block
2306        .replacen(&from_lhs, &to_lhs, 1)
2307        .replacen(&from_arg, &to_arg, 1);
2308    let mut out = String::with_capacity(src.len());
2309    out.push_str(&src[..ok_self_open]);
2310    out.push_str(&replaced);
2311    out.push_str(&src[ok_self_close + 1..]);
2312    Ok(out)
2313}
2314
2315fn rename_in_insert_values(src: &str, from: &str, to: &str) -> Result<String, String> {
2316    let fn_start = src
2317        .find("fn insert_values(")
2318        .ok_or_else(|| "insert_values not found".to_string())?;
2319    let vec_rel = src[fn_start..]
2320        .find("vec![")
2321        .ok_or_else(|| "no `vec![` inside insert_values".to_string())?;
2322    let vec_open = fn_start + vec_rel + 4;
2323    let vec_close = find_matching_bracket(src, vec_open)
2324        .ok_or_else(|| "vec![ … ] is not closed".to_string())?;
2325    let block = &src[vec_open..=vec_close];
2326    let from_pattern = format!("self.{from}");
2327    let to_pattern = format!("self.{to}");
2328    if !block.contains(&from_pattern) {
2329        return Err(format!(
2330            "insert_values does not reference `self.{from}`; rename cannot proceed"
2331        ));
2332    }
2333    let replaced = block.replacen(&from_pattern, &to_pattern, 1);
2334    let mut out = String::with_capacity(src.len());
2335    out.push_str(&src[..vec_open]);
2336    out.push_str(&replaced);
2337    out.push_str(&src[vec_close + 1..]);
2338    Ok(out)
2339}
2340
2341/// True when `src` contains an actual `use chrono::…;` statement at the
2342/// start of a line. A docstring mentioning `chrono::DateTime` does not
2343/// count — only a real import satisfies the check.
2344fn has_chrono_use(src: &str) -> bool {
2345    src.lines()
2346        .any(|l| l.trim_start().starts_with("use chrono::"))
2347}
2348
2349fn insert_chrono_import(src: &str) -> String {
2350    // Put the use statement right after the last top-level `use` line.
2351    let mut last_use_end: Option<usize> = None;
2352    for (idx, line) in src.match_indices('\n') {
2353        // Re-use match_indices to walk line boundaries.
2354        let before_nl = &src[..idx];
2355        let line_start = before_nl.rfind('\n').map(|p| p + 1).unwrap_or(0);
2356        let line_txt = &src[line_start..idx];
2357        if line_txt.trim_start().starts_with("use ") {
2358            last_use_end = Some(idx);
2359        }
2360        let _ = line; // unused
2361    }
2362    match last_use_end {
2363        Some(end) => {
2364            let mut out = String::with_capacity(src.len() + 40);
2365            out.push_str(&src[..=end]);
2366            out.push_str("use chrono::{DateTime, Utc};\n");
2367            out.push_str(&src[end + 1..]);
2368            out
2369        }
2370        None => format!("use chrono::{{DateTime, Utc}};\n{src}"),
2371    }
2372}
2373
2374// --- per-type helpers -------------------------------------------------------
2375
2376fn rust_type_for(ty: &str, nullable: bool) -> String {
2377    let base = match ty {
2378        "i32" => "i32",
2379        "i64" => "i64",
2380        "String" => "String",
2381        "bool" => "bool",
2382        "DateTime" => "DateTime<Utc>",
2383        other => other,
2384    };
2385    if nullable {
2386        format!("Option<{base}>")
2387    } else {
2388        base.to_string()
2389    }
2390}
2391
2392fn row_accessor(ty: &str, nullable: bool) -> String {
2393    let suffix = match ty {
2394        "i32" => "i32",
2395        "i64" => "i64",
2396        "String" => "string",
2397        "bool" => "bool",
2398        "DateTime" => "datetime",
2399        _ => "string",
2400    };
2401    if nullable {
2402        format!("get_optional_{suffix}")
2403    } else {
2404        format!("get_{suffix}")
2405    }
2406}
2407
2408fn build_insert_values_line(field: &str, ty: &str, _nullable: bool) -> String {
2409    // `.clone()` is needed for non-`Copy` types so `insert_values(&self)`
2410    // doesn't move out of `self`. `String` (and `Option<String>`) are
2411    // the ones that matter today; every other supported primitive is
2412    // `Copy` (or converts from `Copy`). If more non-Copy types land,
2413    // extend this list explicitly rather than guessing.
2414    let call = if ty == "String" {
2415        format!("self.{field}.clone().into()")
2416    } else {
2417        format!("self.{field}.into()")
2418    };
2419    format!("            {call},\n")
2420}
2421
2422// --- project introspection --------------------------------------------------
2423
2424fn locate_model_file(
2425    project: &ProjectView,
2426    struct_name: &str,
2427) -> Result<(String, String), ExecutionError> {
2428    let mut matches: Vec<&str> = project
2429        .models_files
2430        .iter()
2431        .filter(|(_, f)| f.struct_names.iter().any(|s| s == struct_name))
2432        .map(|(app, _)| app.as_str())
2433        .collect();
2434    match matches.len() {
2435        0 => Err(ExecutionError::ProjectStructure(format!(
2436            "no apps/<app>/models.rs declares `pub struct {struct_name}`"
2437        ))),
2438        1 => {
2439            let app = matches.remove(0).to_string();
2440            let source = project.models_files[&app].source.clone();
2441            Ok((app, source))
2442        }
2443        _ => Err(ExecutionError::ProjectStructure(format!(
2444            "multiple apps declare `pub struct {struct_name}`: {}",
2445            matches.join(", ")
2446        ))),
2447    }
2448}
2449
2450fn find_table_for_struct(src: &str, struct_name: &str) -> Option<String> {
2451    // Extract the string value of `const TABLE: &'static str = "<name>";`
2452    // from the matching `impl Model for <struct>` block.
2453    //
2454    // 0.9.1: scoped by struct name. Older callers that want the
2455    // whole-file first match should pass an empty string — but no
2456    // in-tree site does. The previous "one Model impl per file"
2457    // assumption breaks on real projects like medflow that declare
2458    // several models per app.
2459    let impl_anchor = format!("impl Model for {struct_name}");
2460    let slice = if let Some(impl_start) = src.find(&impl_anchor) {
2461        // Restrict to the matching impl block so sibling models can't
2462        // shadow this one's `const TABLE`.
2463        let brace_rel = src[impl_start..].find('{')?;
2464        let open = impl_start + brace_rel;
2465        let close = find_matching_brace(src, open)?;
2466        &src[open..=close]
2467    } else {
2468        // Fallback (rare): no matching impl found — scan the whole file.
2469        // Preserves the 0.8.x behaviour for callers that rely on the
2470        // fallback path before the struct exists.
2471        src
2472    };
2473    let anchor = "const TABLE: &'static str = \"";
2474    let start = slice.find(anchor)? + anchor.len();
2475    let end = slice[start..].find('"')?;
2476    Some(slice[start..start + end].to_string())
2477}
2478
2479/// Snake-case derivation used when `const TABLE` can't be read
2480/// (shouldn't happen with scaffold output — defensive fallback).
2481fn fallback_table_name(struct_name: &str) -> Option<String> {
2482    let mut out = String::with_capacity(struct_name.len() + 4);
2483    for (i, ch) in struct_name.chars().enumerate() {
2484        if ch.is_ascii_uppercase() {
2485            if i > 0 {
2486                out.push('_');
2487            }
2488            out.extend(ch.to_lowercase());
2489        } else {
2490            out.push(ch);
2491        }
2492    }
2493    // Pluralise naively — matches what `rustio new app` does.
2494    if !out.ends_with('s') {
2495        out.push('s');
2496    }
2497    Some(out)
2498}
2499
2500fn next_migration_number(existing: &[String]) -> u32 {
2501    let mut max: u32 = 0;
2502    for name in existing {
2503        let Some(prefix) = name.split('_').next() else {
2504            continue;
2505        };
2506        if let Ok(n) = prefix.parse::<u32>() {
2507            if n > max {
2508                max = n;
2509            }
2510        }
2511    }
2512    max + 1
2513}
2514
2515fn new_migration_path(project: &ProjectView, number: u32, slug: &str) -> (PathBuf, String) {
2516    let filename = format!("{number:04}_{slug}.sql");
2517    (project.root.join("migrations").join(&filename), filename)
2518}
2519
2520pub(super) fn sql_for_add_field(table: &str, field: &FieldSpec) -> String {
2521    let sql_type = sql_type_for(&field.ty);
2522    if field.nullable {
2523        format!(
2524            "-- Generated by rustio ai apply. DO NOT EDIT.\n\
2525             ALTER TABLE {table} ADD COLUMN {name} {sql_type};\n",
2526            name = field.name,
2527        )
2528    } else {
2529        let default = safe_default_literal(&field.ty);
2530        format!(
2531            "-- Generated by rustio ai apply. DO NOT EDIT.\n\
2532             ALTER TABLE {table} ADD COLUMN {name} {sql_type} NOT NULL DEFAULT {default};\n",
2533            name = field.name,
2534        )
2535    }
2536}
2537
2538/// Phase 2: PostgreSQL native types. `i32` → `INTEGER`, `i64` → `BIGINT`
2539/// (separate from `INTEGER` unlike SQLite which collapses both). `bool`
2540/// is a real `BOOLEAN`, not 0/1. `DateTime` is `TIMESTAMPTZ` so the
2541/// stored value is timezone-aware.
2542fn sql_type_for(ty: &str) -> &'static str {
2543    match ty {
2544        "i32" => "INTEGER",
2545        "i64" => "BIGINT",
2546        "bool" => "BOOLEAN",
2547        "String" => "TEXT",
2548        "DateTime" => "TIMESTAMPTZ",
2549        _ => "TEXT",
2550    }
2551}
2552
2553/// Phase 2: PostgreSQL `NOT NULL DEFAULT` literals. `bool` defaults to
2554/// `FALSE` (not `0`); `DateTime` uses the same epoch literal SQLite did,
2555/// rendered with `+00` so PG parses it as `timestamptz` without a
2556/// "missing time zone" warning.
2557fn safe_default_literal(ty: &str) -> &'static str {
2558    match ty {
2559        "i32" | "i64" => "0",
2560        "bool" => "FALSE",
2561        "String" => "''",
2562        "DateTime" => "'1970-01-01 00:00:00+00'",
2563        _ => "''",
2564    }
2565}
2566
2567// ---------------------------------------------------------------------------
2568// Impure entry — reads project from disk, applies atomically
2569// ---------------------------------------------------------------------------
2570
2571impl ProjectView {
2572    /// Build a [`ProjectView`] by reading the project at `root`. Reads
2573    /// every `apps/*/models.rs` and lists `migrations/*`. Returns a
2574    /// [`ExecutionError::ProjectStructure`] if the scaffold isn't
2575    /// recognisable — the executor will not apply to a non-rustio
2576    /// directory.
2577    pub fn from_dir(root: &Path) -> Result<Self, ExecutionError> {
2578        let apps_dir = root.join("apps");
2579        let migrations_dir = root.join("migrations");
2580        if !apps_dir.is_dir() {
2581            return Err(ExecutionError::ProjectStructure(format!(
2582                "expected directory `apps/` at {}",
2583                root.display()
2584            )));
2585        }
2586        if !migrations_dir.is_dir() {
2587            return Err(ExecutionError::ProjectStructure(format!(
2588                "expected directory `migrations/` at {}",
2589                root.display()
2590            )));
2591        }
2592
2593        let mut models_files = BTreeMap::new();
2594        let entries = std::fs::read_dir(&apps_dir).map_err(|e| ExecutionError::IoError {
2595            path: apps_dir.display().to_string(),
2596            message: e.to_string(),
2597        })?;
2598        for entry in entries {
2599            let entry = entry.map_err(|e| ExecutionError::IoError {
2600                path: apps_dir.display().to_string(),
2601                message: e.to_string(),
2602            })?;
2603            let ty = entry.file_type().map_err(|e| ExecutionError::IoError {
2604                path: entry.path().display().to_string(),
2605                message: e.to_string(),
2606            })?;
2607            if !ty.is_dir() {
2608                continue;
2609            }
2610            let app_dir = entry.path();
2611            let app_name = app_dir
2612                .file_name()
2613                .and_then(|n| n.to_str())
2614                .map(String::from)
2615                .unwrap_or_default();
2616            if app_name.is_empty() {
2617                continue;
2618            }
2619            let models_path = app_dir.join("models.rs");
2620            if !models_path.is_file() {
2621                continue;
2622            }
2623            let source =
2624                std::fs::read_to_string(&models_path).map_err(|e| ExecutionError::IoError {
2625                    path: models_path.display().to_string(),
2626                    message: e.to_string(),
2627                })?;
2628            let struct_names = parse_struct_names(&source);
2629            models_files.insert(
2630                app_name,
2631                ParsedModelsFile {
2632                    path: models_path,
2633                    source,
2634                    struct_names,
2635                },
2636            );
2637        }
2638
2639        let mut existing_migrations = Vec::new();
2640        let mut migration_sources: BTreeMap<String, String> = BTreeMap::new();
2641        let entries = std::fs::read_dir(&migrations_dir).map_err(|e| ExecutionError::IoError {
2642            path: migrations_dir.display().to_string(),
2643            message: e.to_string(),
2644        })?;
2645        for entry in entries {
2646            let entry = entry.map_err(|e| ExecutionError::IoError {
2647                path: migrations_dir.display().to_string(),
2648                message: e.to_string(),
2649            })?;
2650            if let Some(name) = entry.file_name().to_str() {
2651                if name.ends_with(".sql") {
2652                    let path = entry.path();
2653                    let contents =
2654                        std::fs::read_to_string(&path).map_err(|e| ExecutionError::IoError {
2655                            path: path.display().to_string(),
2656                            message: e.to_string(),
2657                        })?;
2658                    migration_sources.insert(name.to_string(), contents);
2659                    existing_migrations.push(name.to_string());
2660                }
2661            }
2662        }
2663        existing_migrations.sort();
2664
2665        Ok(ProjectView {
2666            root: root.to_path_buf(),
2667            models_files,
2668            existing_migrations,
2669            migration_sources,
2670        })
2671    }
2672}
2673
2674fn parse_struct_names(source: &str) -> Vec<String> {
2675    let mut out: Vec<String> = Vec::new();
2676    for line in source.lines() {
2677        let t = line.trim_start();
2678        if let Some(rest) = t.strip_prefix("pub struct ") {
2679            // Name runs until whitespace, `{`, or `<`.
2680            let name: String = rest
2681                .chars()
2682                .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
2683                .collect();
2684            if !name.is_empty() {
2685                out.push(name);
2686            }
2687        }
2688    }
2689    out
2690}
2691
2692/// Run a plan against the project on disk.
2693///
2694/// Reads the schema at `<root>/rustio.schema.json`, builds a
2695/// [`ProjectView`], calls [`plan_execution`], verifies preconditions
2696/// against the live filesystem, and applies the change set atomically.
2697/// No migrations are executed — the user runs `rustio migrate apply`
2698/// afterwards.
2699pub fn execute_plan_document(
2700    project_root: &Path,
2701    doc: &PlanDocument,
2702    options: &ExecuteOptions,
2703    context: Option<&ContextConfig>,
2704) -> Result<ExecutionResult, ExecutionError> {
2705    let schema_path = project_root.join("rustio.schema.json");
2706    let schema_json =
2707        std::fs::read_to_string(&schema_path).map_err(|e| ExecutionError::IoError {
2708            path: schema_path.display().to_string(),
2709            message: e.to_string(),
2710        })?;
2711    let schema =
2712        Schema::parse(&schema_json).map_err(|e| ExecutionError::ValidationFailed(e.to_string()))?;
2713    let project = ProjectView::from_dir(project_root)?;
2714    let preview = plan_execution(&schema, &project, doc, options, context)?;
2715    commit_changes(&preview)?;
2716    let generated: Vec<String> = preview
2717        .file_changes
2718        .iter()
2719        .map(|c| display_path(project_root, &c.path))
2720        .collect();
2721    Ok(ExecutionResult {
2722        applied_steps: preview.applied_steps,
2723        generated_files: generated,
2724        summary: preview.summary,
2725    })
2726}
2727
2728/// Commit the preview to disk. Each target is written to a sibling
2729/// `.rustio_tmp` file first; only after every target has a tempfile
2730/// does the executor rename them into place. If any rename fails, the
2731/// already-renamed files are restored from their pre-apply content.
2732fn commit_changes(preview: &ExecutionPreview) -> Result<(), ExecutionError> {
2733    // 1. Conflict + precondition pass against the live filesystem.
2734    for change in &preview.file_changes {
2735        match change.kind {
2736            FileChangeKind::Create => {
2737                if change.path.exists() {
2738                    return Err(ExecutionError::FileConflict {
2739                        path: change.path.display().to_string(),
2740                        reason: "file already exists — refusing to overwrite".to_string(),
2741                    });
2742                }
2743                if let Some(parent) = change.path.parent() {
2744                    if !parent.is_dir() {
2745                        return Err(ExecutionError::ProjectStructure(format!(
2746                            "parent directory `{}` does not exist",
2747                            parent.display()
2748                        )));
2749                    }
2750                }
2751            }
2752            FileChangeKind::Update => {
2753                let actual =
2754                    std::fs::read_to_string(&change.path).map_err(|e| ExecutionError::IoError {
2755                        path: change.path.display().to_string(),
2756                        message: e.to_string(),
2757                    })?;
2758                if let Some(expected) = &change.expected_current_contents {
2759                    if &actual != expected {
2760                        return Err(ExecutionError::FileConflict {
2761                            path: change.path.display().to_string(),
2762                            reason: "file changed on disk after the plan was generated".to_string(),
2763                        });
2764                    }
2765                }
2766            }
2767        }
2768    }
2769
2770    // 2. Write each change to a .rustio_tmp sibling file.
2771    let mut tmp_paths: Vec<PathBuf> = Vec::with_capacity(preview.file_changes.len());
2772    for change in &preview.file_changes {
2773        let tmp = change.path.with_extension(match change.path.extension() {
2774            Some(e) => format!("{}.rustio_tmp", e.to_string_lossy()),
2775            None => "rustio_tmp".to_string(),
2776        });
2777        if let Err(e) = std::fs::write(&tmp, &change.new_contents) {
2778            cleanup_tmps(&tmp_paths);
2779            return Err(ExecutionError::IoError {
2780                path: tmp.display().to_string(),
2781                message: e.to_string(),
2782            });
2783        }
2784        tmp_paths.push(tmp);
2785    }
2786
2787    // 3. Rename .rustio_tmp → final path. Track (target, original) so
2788    // we can roll back if a later rename fails.
2789    let mut renamed: Vec<(PathBuf, Option<String>)> =
2790        Vec::with_capacity(preview.file_changes.len());
2791    for (i, change) in preview.file_changes.iter().enumerate() {
2792        let tmp = &tmp_paths[i];
2793        let original = match change.kind {
2794            FileChangeKind::Update => change.expected_current_contents.clone(),
2795            FileChangeKind::Create => None,
2796        };
2797        if let Err(e) = std::fs::rename(tmp, &change.path) {
2798            // Roll back: restore already-renamed targets, clean up
2799            // remaining tmps.
2800            rollback_renames(&renamed);
2801            cleanup_tmps(&tmp_paths[i..]);
2802            return Err(ExecutionError::IoError {
2803                path: change.path.display().to_string(),
2804                message: e.to_string(),
2805            });
2806        }
2807        renamed.push((change.path.clone(), original));
2808    }
2809    Ok(())
2810}
2811
2812fn cleanup_tmps(paths: &[PathBuf]) {
2813    for p in paths {
2814        let _ = std::fs::remove_file(p);
2815    }
2816}
2817
2818fn rollback_renames(renamed: &[(PathBuf, Option<String>)]) {
2819    for (path, original) in renamed.iter().rev() {
2820        match original {
2821            Some(contents) => {
2822                let _ = std::fs::write(path, contents);
2823            }
2824            None => {
2825                let _ = std::fs::remove_file(path);
2826            }
2827        }
2828    }
2829}
2830
2831fn display_path(root: &Path, absolute: &Path) -> String {
2832    absolute
2833        .strip_prefix(root)
2834        .ok()
2835        .and_then(|p| p.to_str())
2836        .map(String::from)
2837        .unwrap_or_else(|| absolute.display().to_string())
2838}
2839
2840// ---------------------------------------------------------------------------
2841// 0.9.0 — FK retrofit
2842// ---------------------------------------------------------------------------
2843
2844/// Result of scanning a schema for belongs_to relations that were
2845/// materialised before 0.9.0 (no `on_delete` / `required` metadata).
2846#[derive(Debug, Clone)]
2847pub struct RetrofitReport {
2848    /// `(model_name, field_name)` pairs the retrofit would upgrade.
2849    pub upgraded: Vec<(String, String)>,
2850    /// `(filename, sql)` pairs to write into the `migrations/` dir.
2851    /// Empty when nothing needs retrofitting.
2852    pub migrations: Vec<(String, String)>,
2853}
2854
2855/// Phase 2: generate a retrofit plan for every belongs_to relation
2856/// that lacks `on_delete` metadata. Returns a report the caller can
2857/// either print (dry-run) or materialise (write migrations + update
2858/// schema).
2859///
2860/// Postgres rewrite — no recreate-table dance. Each missing FK becomes
2861/// a single `ALTER TABLE … ADD CONSTRAINT … FOREIGN KEY … REFERENCES …
2862/// ON DELETE …`. PG validates the existing rows during the ALTER:
2863/// the migration refuses cleanly if any child row's FK column points
2864/// at a non-existent parent (operators clean orphans before retry).
2865///
2866/// One migration file per model that needs retrofitting; each file
2867/// contains one ALTER per upgraded relation, wrapped in BEGIN/COMMIT
2868/// for atomicity.
2869pub fn plan_retrofit_foreign_keys(schema: &crate::schema::Schema) -> RetrofitReport {
2870    use crate::schema::RelationKind;
2871
2872    let mut upgraded = Vec::new();
2873    let mut migrations = Vec::new();
2874
2875    let table_for = |model_name: &str| -> Option<String> {
2876        schema
2877            .models
2878            .iter()
2879            .find(|m| m.name == model_name)
2880            .and_then(|_| fallback_table_name(model_name))
2881    };
2882
2883    for model in &schema.models {
2884        let table = match fallback_table_name(&model.name) {
2885            Some(t) => t,
2886            None => continue,
2887        };
2888        // Collect the relations on this model that need retrofitting.
2889        let mut to_retrofit: Vec<(String, String, String)> = Vec::new(); // (via, parent_table, policy)
2890        for f in &model.fields {
2891            if let Some(rel) = &f.relation {
2892                if matches!(rel.kind, RelationKind::BelongsTo) && rel.on_delete.is_none() {
2893                    let parent_table = match table_for(&rel.model) {
2894                        Some(t) => t,
2895                        None => continue,
2896                    };
2897                    let policy = "RESTRICT".to_string(); // retrofit default
2898                    to_retrofit.push((f.name.clone(), parent_table, policy));
2899                    upgraded.push((model.name.clone(), f.name.clone()));
2900                }
2901            }
2902        }
2903        if to_retrofit.is_empty() {
2904            continue;
2905        }
2906
2907        // PG migration: one ALTER TABLE ADD CONSTRAINT per relation,
2908        // wrapped in BEGIN/COMMIT so the whole batch is atomic.
2909        let mut sql = String::new();
2910        sql.push_str("-- Generated by `rustio migrate add-fks` (Phase 2).\n");
2911        sql.push_str(
2912            "-- PostgreSQL retrofits FKs in place; no recreate-table needed.\n\
2913             -- The ALTER will refuse if any child row references a missing parent —\n\
2914             -- delete or repair orphans before re-running.\n",
2915        );
2916        sql.push_str("BEGIN;\n");
2917        for (via, parent_table, policy) in &to_retrofit {
2918            let constraint_name = format!("{table}_{via}_fk");
2919            sql.push_str(&format!(
2920                "ALTER TABLE {table}\n    \
2921                 ADD CONSTRAINT {constraint_name} \
2922                 FOREIGN KEY ({via}) REFERENCES {parent_table}(id) ON DELETE {policy};\n",
2923            ));
2924        }
2925        sql.push_str("COMMIT;\n");
2926
2927        migrations.push((format!("retrofit_fks_{table}"), sql));
2928    }
2929
2930    RetrofitReport {
2931        upgraded,
2932        migrations,
2933    }
2934}
2935
2936// ---------------------------------------------------------------------------
2937// Human-readable preview
2938// ---------------------------------------------------------------------------
2939
2940/// Render an [`ExecutionPreview`] as an operator-friendly block. The
2941/// CLI prints this before asking for confirmation.
2942pub fn render_preview_human(preview: &ExecutionPreview, risk: RiskLevel) -> String {
2943    let mut out = String::from("Plan to apply\n\n");
2944    out.push_str("Applying:\n");
2945    // Each summary line already carries its own glyph (`+` for add,
2946    // `~` for mutate, `-` for destructive; warning lines are indented
2947    // with four leading spaces). The renderer just reserves a two-
2948    // space indent for every line so the block is visually uniform.
2949    for line in preview.summary.lines() {
2950        out.push_str("  ");
2951        out.push_str(line);
2952        out.push('\n');
2953    }
2954    out.push_str("\nFiles to be written:\n");
2955    for change in &preview.file_changes {
2956        let kind = match change.kind {
2957            FileChangeKind::Create => "create",
2958            FileChangeKind::Update => "update",
2959        };
2960        out.push_str(&format!("  - {kind} {}\n", change.path.display()));
2961    }
2962    out.push_str(&format!("\nRisk:\n  {}\n", risk.as_str()));
2963    out
2964}