1use 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#[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#[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#[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
118pub struct ExecuteOptions {
119 pub allow_destructive: bool,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct ProjectView {
137 pub root: PathBuf,
138 pub models_files: BTreeMap<String, ParsedModelsFile>,
140 pub existing_migrations: Vec<String>,
142 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 pub struct_names: Vec<String>,
157}
158
159#[non_exhaustive]
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub enum ExecutionError {
165 ValidationFailed(String),
168 CriticalRiskNotAllowed,
172 DeveloperOnlyForbidden,
175 SchemaMismatch(String),
178 FileConflict { path: String, reason: String },
182 UnsupportedPrimitive {
184 op: &'static str,
185 reason: &'static str,
186 },
187 DestructiveWithoutConfirmation { op: &'static str },
190 ProjectStructure(String),
193 IoError { path: String, message: String },
196 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
241pub fn plan_execution(
253 schema: &Schema,
254 project: &ProjectView,
255 doc: &PlanDocument,
256 options: &ExecuteOptions,
257 context: Option<&ContextConfig>,
258) -> Result<ExecutionPreview, ExecutionError> {
259 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 if review.risk == RiskLevel::Critical {
279 return Err(ExecutionError::CriticalRiskNotAllowed);
280 }
281
282 for step in &doc.plan.steps {
286 if step.is_developer_only() {
287 return Err(ExecutionError::DeveloperOnlyForbidden);
288 }
289 }
290
291 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 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 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 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 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 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
403fn 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 continue;
416 }
417 out.push(c);
418 }
419 out
420}
421
422fn 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 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 let struct_bounds = find_struct_block(¤t, &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 = ¤t[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 let patched = patch_models_for_add_field(¤t, &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 let table = find_table_for_struct(¤t, &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
507fn apply_add_relation(
534 r: &AddRelation,
535 project: &ProjectView,
536 shadow: &mut BTreeMap<String, String>,
537 migration_counter: &mut u32,
538) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
539 use crate::ai::OnDelete;
540 use crate::schema::RelationKind;
541 match r.kind {
542 RelationKind::BelongsTo => {}
543 _ => {
544 return Err(ExecutionError::UnsupportedPrimitive {
545 op: "add_relation",
546 reason: "only `belongs_to` is materialised in 0.9.0 — `has_many` is a virtual accessor with no column change",
547 });
548 }
549 }
550
551 if r.required {
557 return Err(ExecutionError::UnsupportedPrimitive {
558 op: "add_relation",
559 reason: "a required (NOT NULL) foreign key cannot be added via ALTER TABLE on SQLite; use `rustio migrate --add-fks` to retrofit via recreate-table",
560 });
561 }
562 if matches!(r.on_delete, OnDelete::SetNull) && r.required {
566 return Err(ExecutionError::UnsupportedPrimitive {
567 op: "add_relation",
568 reason: "`on_delete: set_null` requires a nullable FK column",
569 });
570 }
571
572 let synthetic = AddField {
578 model: r.from.clone(),
579 field: FieldSpec {
580 name: r.via.clone(),
581 ty: "i64".to_string(),
582 nullable: true, editable: true,
584 },
585 };
586 let (mut changes, _) = apply_add_field(&synthetic, project, shadow, migration_counter)?;
587
588 let (child_app, _) = locate_model_file(project, &r.from)?;
593 let child_src = shadow
594 .get(&child_app)
595 .cloned()
596 .unwrap_or_else(|| project.models_files[&child_app].source.clone());
597 let child_table = find_table_for_struct(&child_src, &r.from)
598 .or_else(|| fallback_table_name(&r.from))
599 .ok_or_else(|| {
600 ExecutionError::ProjectStructure(format!(
601 "could not find `const TABLE` for child struct `{}`",
602 r.from
603 ))
604 })?;
605
606 let parent_table = match locate_model_file(project, &r.to) {
611 Ok((parent_app, parent_source)) => {
612 let parent_src = shadow.get(&parent_app).cloned().unwrap_or(parent_source);
613 find_table_for_struct(&parent_src, &r.to)
614 .or_else(|| fallback_table_name(&r.to))
615 .ok_or_else(|| {
616 ExecutionError::ProjectStructure(format!(
617 "could not find `const TABLE` for parent struct `{}`",
618 r.to
619 ))
620 })?
621 }
622 Err(_) => fallback_table_name(&r.to).ok_or_else(|| {
623 ExecutionError::ProjectStructure(format!(
624 "could not derive a table name for parent struct `{}`",
625 r.to
626 ))
627 })?,
628 };
629
630 let fk_sql = sql_for_add_fk_column(&child_table, &r.via, &parent_table, r.on_delete);
631 let mig_filename = {
632 let create = changes
633 .iter_mut()
634 .find(|c| c.kind == FileChangeKind::Create)
635 .expect("apply_add_field always plans a Create for the migration");
636 create.new_contents = fk_sql;
637 create
638 .path
639 .file_name()
640 .and_then(|n| n.to_str())
641 .unwrap_or("")
642 .to_string()
643 };
644
645 Ok((
646 changes,
647 format!(
648 "+ Add relation `{}` from \"{}\" to \"{}\" (belongs_to → {}, {}, migration {})",
649 r.via,
650 r.from,
651 r.to,
652 parent_table,
653 r.on_delete.as_str(),
654 mig_filename,
655 ),
656 ))
657}
658
659fn sql_for_add_fk_column(
664 child_table: &str,
665 via: &str,
666 parent_table: &str,
667 on_delete: crate::ai::OnDelete,
668) -> String {
669 format!(
670 "-- Generated by rustio ai apply (0.9.0). DO NOT EDIT.\n\
671 PRAGMA foreign_keys = ON;\n\
672 ALTER TABLE {child} ADD COLUMN {via} INTEGER REFERENCES {parent}(id) {policy};\n",
673 child = child_table,
674 via = via,
675 parent = parent_table,
676 policy = on_delete.sql(),
677 )
678}
679
680fn apply_remove_field(
698 r: &RemoveField,
699 schema: &Schema,
700 project: &ProjectView,
701 shadow: &mut BTreeMap<String, String>,
702 migration_counter: &mut u32,
703) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
704 let model = schema
705 .models
706 .iter()
707 .find(|m| m.name == r.model)
708 .ok_or_else(|| {
709 ExecutionError::SchemaMismatch(format!("model `{}` not in schema", r.model))
710 })?;
711 let field = model
712 .fields
713 .iter()
714 .find(|f| f.name == r.field)
715 .ok_or_else(|| {
716 ExecutionError::SchemaMismatch(format!("field `{}.{}` not in schema", r.model, r.field))
717 })?;
718 if r.field == "id" {
719 return Err(ExecutionError::UnsupportedPrimitive {
720 op: "remove_field",
721 reason: "cannot drop the `id` primary key; remove the model instead",
722 });
723 }
724
725 let (app, initial_source) = locate_model_file(project, &r.model)?;
726 let current = shadow
727 .get(&app)
728 .cloned()
729 .unwrap_or_else(|| initial_source.clone());
730 let table = find_table_for_struct(¤t, &r.model)
731 .or_else(|| fallback_table_name(&r.model))
732 .ok_or_else(|| {
733 ExecutionError::ProjectStructure(format!(
734 "could not find `const TABLE` for struct `{}`",
735 r.model
736 ))
737 })?;
738
739 let patched =
741 patch_models_for_remove_field(¤t, &r.model, &r.field, &field.ty, field.nullable)
742 .map_err(|msg| ExecutionError::FileConflict {
743 path: format!("apps/{app}/models.rs"),
744 reason: msg,
745 })?;
746 shadow.insert(app.clone(), patched.clone());
747
748 let remaining: Vec<SchemaField> = model
750 .fields
751 .iter()
752 .filter(|f| f.name != r.field)
753 .cloned()
754 .collect();
755 let table_for = |target: &str| -> Option<String> {
757 schema
758 .models
759 .iter()
760 .find(|m| m.name == target)
761 .and_then(|_| fallback_table_name(target))
762 };
763 let sql = generate_sqlite_recreate_table_migration_fk_aware(
764 &table,
765 &remaining,
766 &BTreeMap::new(), &table_for,
768 );
769
770 let mig_name = format!("drop_{}_from_{}", r.field, table);
771 let (mig_path, mig_filename) = new_migration_path(project, *migration_counter, &mig_name);
772 *migration_counter += 1;
773
774 let file_path = project.root.join("apps").join(&app).join("models.rs");
775 let warn_line = format!(
776 " ⚠ This rewrites `{table}`. Data in `{}` is lost.",
777 r.field
778 );
779 Ok((
780 vec![
781 PlannedFileChange {
782 path: file_path,
783 kind: FileChangeKind::Update,
784 new_contents: patched,
785 expected_current_contents: Some(initial_source),
786 },
787 PlannedFileChange {
788 path: mig_path,
789 kind: FileChangeKind::Create,
790 new_contents: sql,
791 expected_current_contents: None,
792 },
793 ],
794 format!(
795 "- Remove field `{}.{}` (migration {})\n{}",
796 r.model, r.field, mig_filename, warn_line,
797 ),
798 ))
799}
800
801fn apply_remove_relation(
809 r: &RemoveRelation,
810 schema: &Schema,
811 project: &ProjectView,
812 shadow: &mut BTreeMap<String, String>,
813 migration_counter: &mut u32,
814) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
815 let synthetic = RemoveField {
817 model: r.from.clone(),
818 field: r.via.clone(),
819 };
820 let (changes, _) = apply_remove_field(&synthetic, schema, project, shadow, migration_counter)?;
821 let mig_filename = changes
822 .iter()
823 .find(|c| c.kind == FileChangeKind::Create)
824 .and_then(|c| c.path.file_name())
825 .and_then(|n| n.to_str())
826 .unwrap_or("")
827 .to_string();
828 Ok((
829 changes,
830 format!(
831 "- Remove relation `{}.{}` (migration {})",
832 r.from, r.via, mig_filename,
833 ),
834 ))
835}
836
837fn generate_sqlite_recreate_table_migration_fk_aware(
841 table: &str,
842 new_fields: &[SchemaField],
843 source_exprs: &BTreeMap<String, String>,
844 table_for: &impl Fn(&str) -> Option<String>,
845) -> String {
846 let new_table = format!("{table}__new");
847 let mut out = String::new();
848 out.push_str("-- Generated by rustio ai apply (0.9.1). DO NOT EDIT.\n");
849 out.push_str("-- SQLite recreate-table, FK-aware: surviving foreign keys are\n");
850 out.push_str("-- re-emitted on the rebuilt table.\n");
851 out.push_str("PRAGMA foreign_keys = OFF;\n");
852 out.push_str("BEGIN;\n\n");
853 out.push_str(&format!("CREATE TABLE {new_table} (\n"));
854 for (i, f) in new_fields.iter().enumerate() {
855 out.push_str(" ");
856 out.push_str(&column_def_with_relation_context(f, table_for));
857 if i + 1 < new_fields.len() {
858 out.push(',');
859 }
860 out.push('\n');
861 }
862 out.push_str(");\n\n");
863 let col_list = new_fields
864 .iter()
865 .map(|f| f.name.clone())
866 .collect::<Vec<_>>()
867 .join(", ");
868 let expr_list = new_fields
869 .iter()
870 .map(|f| {
871 source_exprs
872 .get(&f.name)
873 .cloned()
874 .unwrap_or_else(|| f.name.clone())
875 })
876 .collect::<Vec<_>>()
877 .join(", ");
878 out.push_str(&format!(
879 "INSERT INTO {new_table} ({col_list}) SELECT {expr_list} FROM {table};\n\n"
880 ));
881 out.push_str(&format!("DROP TABLE {table};\n"));
882 out.push_str(&format!("ALTER TABLE {new_table} RENAME TO {table};\n\n"));
883 out.push_str("COMMIT;\n");
884 out.push_str("PRAGMA foreign_keys = ON;\n");
885 out
886}
887
888fn apply_rename_field(
889 r: &RenameField,
890 project: &ProjectView,
891 shadow: &mut BTreeMap<String, String>,
892 migration_counter: &mut u32,
893) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
894 let (app, initial_source) = locate_model_file(project, &r.model)?;
895 let current = shadow
896 .get(&app)
897 .cloned()
898 .unwrap_or_else(|| initial_source.clone());
899
900 let struct_bounds = find_struct_block(¤t, &r.model).ok_or_else(|| {
901 ExecutionError::ProjectStructure(format!(
902 "apps/{app}/models.rs does not declare `pub struct {}`",
903 r.model
904 ))
905 })?;
906 let inside_struct = ¤t[struct_bounds.0..=struct_bounds.1];
907 if !struct_declares_field(inside_struct, &r.from) {
908 return Err(ExecutionError::FileConflict {
909 path: format!("apps/{app}/models.rs"),
910 reason: format!(
911 "struct {} does not declare `pub {}: …`; rename cannot proceed",
912 r.model, r.from,
913 ),
914 });
915 }
916 if struct_declares_field(inside_struct, &r.to) {
917 return Err(ExecutionError::FileConflict {
918 path: format!("apps/{app}/models.rs"),
919 reason: format!(
920 "struct {} already has a field called `{}`; rename target is taken",
921 r.model, r.to,
922 ),
923 });
924 }
925
926 let patched =
927 patch_models_for_rename_field(¤t, &r.model, &r.from, &r.to).map_err(|msg| {
928 ExecutionError::FileConflict {
929 path: format!("apps/{app}/models.rs"),
930 reason: msg,
931 }
932 })?;
933 shadow.insert(app.clone(), patched.clone());
934
935 let table = find_table_for_struct(¤t, &r.model)
936 .or_else(|| fallback_table_name(&r.model))
937 .ok_or_else(|| {
938 ExecutionError::ProjectStructure(format!(
939 "could not find `const TABLE` for struct `{}`",
940 r.model
941 ))
942 })?;
943 let sql = format!(
944 "-- Generated by rustio ai apply (0.5.2). DO NOT EDIT.\n\
945 ALTER TABLE {table} RENAME COLUMN {from} TO {to};\n",
946 from = r.from,
947 to = r.to,
948 );
949 let mig_name = format!("rename_{}_to_{}_on_{}", r.from, r.to, table);
950 let (mig_path, mig_filename) = new_migration_path(project, *migration_counter, &mig_name);
951 *migration_counter += 1;
952
953 let file_path = project.root.join("apps").join(&app).join("models.rs");
954 Ok((
955 vec![
956 PlannedFileChange {
957 path: file_path,
958 kind: FileChangeKind::Update,
959 new_contents: patched,
960 expected_current_contents: Some(initial_source),
961 },
962 PlannedFileChange {
963 path: mig_path,
964 kind: FileChangeKind::Create,
965 new_contents: sql,
966 expected_current_contents: None,
967 },
968 ],
969 format!(
970 "~ Rename field \"{}.{}\" to \"{}\" (migration {})",
971 r.model, r.from, r.to, mig_filename
972 ),
973 ))
974}
975
976fn apply_change_field_type(
981 c: &ChangeFieldType,
982 schema: &Schema,
983 project: &ProjectView,
984 shadow: &mut BTreeMap<String, String>,
985 migration_counter: &mut u32,
986) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
987 let model = schema
988 .models
989 .iter()
990 .find(|m| m.name == c.model)
991 .ok_or_else(|| {
992 ExecutionError::SchemaMismatch(format!("model `{}` not in schema", c.model))
993 })?;
994 let field = model
995 .fields
996 .iter()
997 .find(|f| f.name == c.field)
998 .ok_or_else(|| {
999 ExecutionError::SchemaMismatch(format!("field `{}.{}` not in schema", c.model, c.field))
1000 })?;
1001
1002 if field.ty == c.new_type {
1004 return Err(ExecutionError::FileConflict {
1005 path: format!("apps/?/{}.rs", c.model.to_lowercase()),
1006 reason: format!(
1007 "field `{}.{}` already has type `{}`; change appears applied",
1008 c.model, c.field, c.new_type,
1009 ),
1010 });
1011 }
1012
1013 let cast_expr = cast_expression(&field.ty, &c.new_type, &c.field).ok_or(
1015 ExecutionError::UnsupportedPrimitive {
1016 op: "change_field_type",
1017 reason: "this type conversion is not in the 0.5.3 safe-cast set",
1018 },
1019 )?;
1020
1021 let (app, initial_source) = locate_model_file(project, &c.model)?;
1022 let current = shadow
1023 .get(&app)
1024 .cloned()
1025 .unwrap_or_else(|| initial_source.clone());
1026 let table = find_table_for_struct(¤t, &c.model)
1027 .or_else(|| fallback_table_name(&c.model))
1028 .ok_or_else(|| {
1029 ExecutionError::ProjectStructure(format!(
1030 "could not find `const TABLE` for struct `{}`",
1031 c.model
1032 ))
1033 })?;
1034
1035 if table_has_any_foreign_key(project, &table) {
1036 return Err(ExecutionError::UnsupportedPrimitive {
1037 op: "change_field_type",
1038 reason:
1039 "table has foreign-key constraints (incoming or outgoing); SQLite recreate-table would break them — scheduled for 0.6.0",
1040 });
1041 }
1042
1043 let patched = patch_models_for_change_field_type(
1044 ¤t,
1045 &c.model,
1046 &c.field,
1047 &field.ty,
1048 &c.new_type,
1049 field.nullable,
1050 )
1051 .map_err(|msg| ExecutionError::FileConflict {
1052 path: format!("apps/{app}/models.rs"),
1053 reason: msg,
1054 })?;
1055 shadow.insert(app.clone(), patched.clone());
1056
1057 let new_fields: Vec<SchemaField> = model
1059 .fields
1060 .iter()
1061 .map(|f| {
1062 if f.name == c.field {
1063 SchemaField {
1064 ty: c.new_type.clone(),
1065 ..f.clone()
1066 }
1067 } else {
1068 f.clone()
1069 }
1070 })
1071 .collect();
1072
1073 let mut source_exprs: BTreeMap<String, String> = BTreeMap::new();
1074 source_exprs.insert(c.field.clone(), cast_expr);
1075 let sql = generate_sqlite_recreate_table_migration(&table, &new_fields, &source_exprs);
1076
1077 let mig_name = format!("change_{}_type_on_{}", c.field, table);
1078 let (mig_path, mig_filename) = new_migration_path(project, *migration_counter, &mig_name);
1079 *migration_counter += 1;
1080
1081 let file_path = project.root.join("apps").join(&app).join("models.rs");
1082 let warn_line =
1083 format!(" ⚠ This rewrites the entire `{table}` table. Large tables may cause downtime.");
1084 Ok((
1085 vec![
1086 PlannedFileChange {
1087 path: file_path,
1088 kind: FileChangeKind::Update,
1089 new_contents: patched,
1090 expected_current_contents: Some(initial_source),
1091 },
1092 PlannedFileChange {
1093 path: mig_path,
1094 kind: FileChangeKind::Create,
1095 new_contents: sql,
1096 expected_current_contents: None,
1097 },
1098 ],
1099 format!(
1100 "~ Change type of {}.{} from {} to {} (migration {})\n{}",
1101 c.model, c.field, field.ty, c.new_type, mig_filename, warn_line,
1102 ),
1103 ))
1104}
1105
1106fn cast_expression(old_ty: &str, new_ty: &str, col_name: &str) -> Option<String> {
1111 match (old_ty, new_ty) {
1112 (a, b) if a == b => None,
1114 ("i32", "i64") | ("i64", "i32") => Some(col_name.to_string()),
1116 ("bool", "i32") | ("bool", "i64") | ("i32", "bool") | ("i64", "bool") => {
1117 Some(col_name.to_string())
1118 }
1119 ("DateTime", "String") | ("String", "DateTime") => Some(col_name.to_string()),
1121 ("i32", "String") | ("i64", "String") | ("bool", "String") => {
1123 Some(format!("CAST({col_name} AS TEXT)"))
1124 }
1125 ("String", "i32") | ("String", "i64") | ("String", "bool") => {
1128 Some(format!("CAST({col_name} AS INTEGER)"))
1129 }
1130 _ => None,
1131 }
1132}
1133
1134fn apply_change_field_nullability(
1139 c: &ChangeFieldNullability,
1140 schema: &Schema,
1141 project: &ProjectView,
1142 shadow: &mut BTreeMap<String, String>,
1143 migration_counter: &mut u32,
1144) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
1145 let model = schema
1146 .models
1147 .iter()
1148 .find(|m| m.name == c.model)
1149 .ok_or_else(|| {
1150 ExecutionError::SchemaMismatch(format!("model `{}` not in schema", c.model))
1151 })?;
1152 let field = model
1153 .fields
1154 .iter()
1155 .find(|f| f.name == c.field)
1156 .ok_or_else(|| {
1157 ExecutionError::SchemaMismatch(format!("field `{}.{}` not in schema", c.model, c.field))
1158 })?;
1159
1160 if field.nullable == c.nullable {
1161 return Err(ExecutionError::FileConflict {
1162 path: format!("apps/?/{}.rs", c.model.to_lowercase()),
1163 reason: format!(
1164 "field `{}.{}` is already {}; change appears applied",
1165 c.model,
1166 c.field,
1167 if c.nullable { "nullable" } else { "required" }
1168 ),
1169 });
1170 }
1171
1172 let (app, initial_source) = locate_model_file(project, &c.model)?;
1173 let current = shadow
1174 .get(&app)
1175 .cloned()
1176 .unwrap_or_else(|| initial_source.clone());
1177 let table = find_table_for_struct(¤t, &c.model)
1178 .or_else(|| fallback_table_name(&c.model))
1179 .ok_or_else(|| {
1180 ExecutionError::ProjectStructure(format!(
1181 "could not find `const TABLE` for struct `{}`",
1182 c.model
1183 ))
1184 })?;
1185 if table_has_any_foreign_key(project, &table) {
1186 return Err(ExecutionError::UnsupportedPrimitive {
1187 op: "change_field_nullability",
1188 reason:
1189 "table has foreign-key constraints; SQLite recreate-table would break them — scheduled for 0.6.0",
1190 });
1191 }
1192
1193 let patched = patch_models_for_change_nullability(
1194 ¤t,
1195 &c.model,
1196 &c.field,
1197 &field.ty,
1198 field.nullable,
1199 c.nullable,
1200 )
1201 .map_err(|msg| ExecutionError::FileConflict {
1202 path: format!("apps/{app}/models.rs"),
1203 reason: msg,
1204 })?;
1205 shadow.insert(app.clone(), patched.clone());
1206
1207 let new_fields: Vec<SchemaField> = model
1208 .fields
1209 .iter()
1210 .map(|f| {
1211 if f.name == c.field {
1212 SchemaField {
1213 nullable: c.nullable,
1214 ..f.clone()
1215 }
1216 } else {
1217 f.clone()
1218 }
1219 })
1220 .collect();
1221
1222 let mut source_exprs: BTreeMap<String, String> = BTreeMap::new();
1225 let tightening = !c.nullable && field.nullable;
1226 if tightening {
1227 source_exprs.insert(
1228 c.field.clone(),
1229 format!(
1230 "COALESCE({col}, {dflt})",
1231 col = c.field,
1232 dflt = safe_default_literal(&field.ty)
1233 ),
1234 );
1235 }
1236 let sql = generate_sqlite_recreate_table_migration(&table, &new_fields, &source_exprs);
1237
1238 let mig_name = format!("change_{}_nullability_on_{}", c.field, table);
1239 let (mig_path, mig_filename) = new_migration_path(project, *migration_counter, &mig_name);
1240 *migration_counter += 1;
1241
1242 let state = if c.nullable { "nullable" } else { "required" };
1243 let warn_line = if tightening {
1244 format!(
1245 " ⚠ This rewrites `{table}` and substitutes existing NULLs with the type default ({}).",
1246 safe_default_literal(&field.ty)
1247 )
1248 } else {
1249 format!(" ⚠ This rewrites the entire `{table}` table. Large tables may cause downtime.")
1250 };
1251
1252 let file_path = project.root.join("apps").join(&app).join("models.rs");
1253 Ok((
1254 vec![
1255 PlannedFileChange {
1256 path: file_path,
1257 kind: FileChangeKind::Update,
1258 new_contents: patched,
1259 expected_current_contents: Some(initial_source),
1260 },
1261 PlannedFileChange {
1262 path: mig_path,
1263 kind: FileChangeKind::Create,
1264 new_contents: sql,
1265 expected_current_contents: None,
1266 },
1267 ],
1268 format!(
1269 "~ Mark {}.{} as {} (migration {})\n{}",
1270 c.model, c.field, state, mig_filename, warn_line
1271 ),
1272 ))
1273}
1274
1275fn apply_rename_model(
1280 r: &RenameModel,
1281 project: &ProjectView,
1282 shadow: &mut BTreeMap<String, String>,
1283 migration_counter: &mut u32,
1284) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
1285 let (app, initial_source) = locate_model_file(project, &r.from)?;
1286 let current = shadow
1287 .get(&app)
1288 .cloned()
1289 .unwrap_or_else(|| initial_source.clone());
1290
1291 let struct_names = parse_struct_names(¤t);
1293 if struct_names.iter().any(|n| n == &r.to) {
1294 return Err(ExecutionError::FileConflict {
1295 path: format!("apps/{app}/models.rs"),
1296 reason: format!(
1297 "struct `{}` already exists in this file; rename appears applied",
1298 r.to
1299 ),
1300 });
1301 }
1302 if !struct_names.iter().any(|n| n == &r.from) {
1303 return Err(ExecutionError::FileConflict {
1304 path: format!("apps/{app}/models.rs"),
1305 reason: format!("struct `{}` not found — nothing to rename", r.from),
1306 });
1307 }
1308
1309 let old_table = find_table_for_struct(¤t, &r.from)
1310 .or_else(|| fallback_table_name(&r.from))
1311 .ok_or_else(|| {
1312 ExecutionError::ProjectStructure(format!(
1313 "could not find `const TABLE` for struct `{}`",
1314 r.from
1315 ))
1316 })?;
1317 let new_table = fallback_table_name(&r.to).unwrap_or_else(|| old_table.clone());
1318
1319 if table_has_any_foreign_key(project, &old_table) {
1320 return Err(ExecutionError::UnsupportedPrimitive {
1321 op: "rename_model",
1322 reason:
1323 "table has foreign-key constraints (incoming or outgoing); FK rewriting is scheduled for 0.6.0",
1324 });
1325 }
1326
1327 let patched_models = patch_models_for_rename_model(
1329 ¤t, &r.from, &r.to, &old_table, &new_table,
1330 )
1331 .map_err(|msg| ExecutionError::FileConflict {
1332 path: format!("apps/{app}/models.rs"),
1333 reason: msg,
1334 })?;
1335 shadow.insert(app.clone(), patched_models.clone());
1336
1337 let admin_path = project.root.join("apps").join(&app).join("admin.rs");
1339 let admin_source =
1340 std::fs::read_to_string(&admin_path).map_err(|e| ExecutionError::IoError {
1341 path: admin_path.display().to_string(),
1342 message: e.to_string(),
1343 })?;
1344 let admin_patched =
1345 patch_admin_for_rename_model(&admin_source, &r.from, &r.to).map_err(|msg| {
1346 ExecutionError::FileConflict {
1347 path: admin_path.display().to_string(),
1348 reason: msg,
1349 }
1350 })?;
1351
1352 let views_path = project.root.join("apps").join(&app).join("views.rs");
1356 let views_change: Option<PlannedFileChange> = if views_path.is_file() {
1357 let views_source =
1358 std::fs::read_to_string(&views_path).map_err(|e| ExecutionError::IoError {
1359 path: views_path.display().to_string(),
1360 message: e.to_string(),
1361 })?;
1362 let patched_views = rename_identifier_bounded(&views_source, &r.from, &r.to);
1363 if patched_views != views_source {
1364 Some(PlannedFileChange {
1365 path: views_path,
1366 kind: FileChangeKind::Update,
1367 new_contents: patched_views,
1368 expected_current_contents: Some(views_source),
1369 })
1370 } else {
1371 None
1372 }
1373 } else {
1374 None
1375 };
1376
1377 let sql = format!(
1378 "-- Generated by rustio ai apply (0.5.3). DO NOT EDIT.\n\
1379 ALTER TABLE {old_table} RENAME TO {new_table};\n"
1380 );
1381 let mig_name = format!("rename_{old_table}_to_{new_table}");
1382 let (mig_path, mig_filename) = new_migration_path(project, *migration_counter, &mig_name);
1383 *migration_counter += 1;
1384
1385 let mut changes: Vec<PlannedFileChange> = vec![
1386 PlannedFileChange {
1387 path: project.root.join("apps").join(&app).join("models.rs"),
1388 kind: FileChangeKind::Update,
1389 new_contents: patched_models,
1390 expected_current_contents: Some(initial_source),
1391 },
1392 PlannedFileChange {
1393 path: admin_path,
1394 kind: FileChangeKind::Update,
1395 new_contents: admin_patched,
1396 expected_current_contents: Some(admin_source),
1397 },
1398 ];
1399 if let Some(vc) = views_change {
1400 changes.push(vc);
1401 }
1402 changes.push(PlannedFileChange {
1403 path: mig_path,
1404 kind: FileChangeKind::Create,
1405 new_contents: sql,
1406 expected_current_contents: None,
1407 });
1408
1409 Ok((
1410 changes,
1411 format!(
1412 "~ Rename model \"{from}\" to \"{to}\" (migration {mig})\n\
1413 \x20 ⚠ Table renamed from `{old_table}` to `{new_table}`. User code using `{from}` outside apps/{app}/ must be updated manually.",
1414 from = r.from,
1415 to = r.to,
1416 mig = mig_filename,
1417 ),
1418 ))
1419}
1420
1421fn generate_sqlite_recreate_table_migration(
1436 table: &str,
1437 new_fields: &[SchemaField],
1438 source_exprs: &BTreeMap<String, String>,
1439) -> String {
1440 let new_table = format!("{table}__new");
1441 let mut out = String::new();
1442 out.push_str("-- Generated by rustio ai apply (0.5.3). DO NOT EDIT.\n");
1443 out.push_str("-- SQLite recreate-table pattern: SQLite cannot ALTER COLUMN in place.\n");
1444 out.push_str(&format!("CREATE TABLE {new_table} (\n"));
1445 for (i, f) in new_fields.iter().enumerate() {
1446 out.push_str(" ");
1447 out.push_str(&column_def(f));
1448 if i + 1 < new_fields.len() {
1449 out.push(',');
1450 }
1451 out.push('\n');
1452 }
1453 out.push_str(");\n\n");
1454
1455 let col_list = new_fields
1456 .iter()
1457 .map(|f| f.name.clone())
1458 .collect::<Vec<_>>()
1459 .join(", ");
1460 let expr_list = new_fields
1461 .iter()
1462 .map(|f| {
1463 source_exprs
1464 .get(&f.name)
1465 .cloned()
1466 .unwrap_or_else(|| f.name.clone())
1467 })
1468 .collect::<Vec<_>>()
1469 .join(", ");
1470 out.push_str(&format!(
1471 "INSERT INTO {new_table} ({col_list})\nSELECT {expr_list}\nFROM {table};\n\n"
1472 ));
1473 out.push_str(&format!("DROP TABLE {table};\n\n"));
1474 out.push_str(&format!("ALTER TABLE {new_table} RENAME TO {table};\n"));
1475 out
1476}
1477
1478fn column_def(f: &SchemaField) -> String {
1488 column_def_with_relation_context(f, fallback_table_name)
1489}
1490
1491fn column_def_with_relation_context(
1492 f: &SchemaField,
1493 resolve_parent_table: impl Fn(&str) -> Option<String>,
1494) -> String {
1495 let sql_ty = sql_type_for(&f.ty);
1496 if f.name == "id" && f.ty == "i64" && !f.nullable {
1497 return "id INTEGER PRIMARY KEY AUTOINCREMENT".to_string();
1498 }
1499 let suffix = if f.nullable {
1500 String::new()
1501 } else {
1502 format!(" NOT NULL DEFAULT {}", safe_default_literal(&f.ty))
1503 };
1504 let fk_clause = f
1505 .relation
1506 .as_ref()
1507 .and_then(|rel| {
1508 let policy = rel.on_delete.as_deref()?;
1509 let parent_table = resolve_parent_table(&rel.model)?;
1510 Some(format!(
1511 " REFERENCES {parent}({target_col}) ON DELETE {policy}",
1512 parent = parent_table,
1513 target_col = if rel.field.is_empty() {
1514 "id"
1515 } else {
1516 &rel.field
1517 },
1518 policy = policy.to_uppercase().replace('_', " "),
1519 ))
1520 })
1521 .unwrap_or_default();
1522 format!("{} {}{}{}", f.name, sql_ty, suffix, fk_clause)
1523}
1524
1525fn table_has_any_foreign_key(project: &ProjectView, table: &str) -> bool {
1531 let lt = table.to_lowercase();
1532 for contents in project.migration_sources.values() {
1533 let c = contents.to_lowercase();
1534 if c.contains(&format!("references {lt}")) || c.contains(&format!("references {lt}(")) {
1536 return true;
1537 }
1538 let create_needles = [
1540 format!("create table {lt} ("),
1541 format!("create table if not exists {lt} ("),
1542 ];
1543 for needle in &create_needles {
1544 if let Some(start) = c.find(needle) {
1545 let tail = &c[start..];
1546 if let Some(end) = tail.find(");") {
1547 if tail[..end].contains("foreign key") {
1548 return true;
1549 }
1550 }
1551 }
1552 }
1553 }
1554 false
1555}
1556
1557fn rename_identifier_bounded(src: &str, from: &str, to: &str) -> String {
1562 let bytes = src.as_bytes();
1563 let from_bytes = from.as_bytes();
1564 let n = from_bytes.len();
1565 if n == 0 {
1566 return src.to_string();
1567 }
1568 let mut out = String::with_capacity(src.len());
1569 let mut i = 0;
1570 let mut last = 0;
1571 while i + n <= bytes.len() {
1572 if &bytes[i..i + n] == from_bytes {
1573 let left_ok = i == 0 || !is_ident_byte(bytes[i - 1]);
1574 let right_ok = i + n == bytes.len() || !is_ident_byte(bytes[i + n]);
1575 if left_ok && right_ok {
1576 out.push_str(&src[last..i]);
1577 out.push_str(to);
1578 i += n;
1579 last = i;
1580 continue;
1581 }
1582 }
1583 i += 1;
1584 }
1585 out.push_str(&src[last..]);
1586 out
1587}
1588
1589fn is_ident_byte(b: u8) -> bool {
1590 b.is_ascii_alphanumeric() || b == b'_'
1591}
1592
1593fn apply_schema_shadow(p: &Primitive, schema: &mut Schema) {
1598 match p {
1599 Primitive::AddField(a) => {
1600 if let Some(m) = schema.models.iter_mut().find(|m| m.name == a.model) {
1601 m.fields.push(SchemaField {
1602 name: a.field.name.clone(),
1603 ty: a.field.ty.clone(),
1604 nullable: a.field.nullable,
1605 editable: a.field.editable,
1606 relation: None,
1607 });
1608 }
1609 }
1610 Primitive::RenameField(r) => {
1611 if let Some(m) = schema.models.iter_mut().find(|m| m.name == r.model) {
1612 if let Some(f) = m.fields.iter_mut().find(|f| f.name == r.from) {
1613 f.name = r.to.clone();
1614 }
1615 }
1616 }
1617 Primitive::ChangeFieldType(c) => {
1618 if let Some(m) = schema.models.iter_mut().find(|m| m.name == c.model) {
1619 if let Some(f) = m.fields.iter_mut().find(|f| f.name == c.field) {
1620 f.ty = c.new_type.clone();
1621 }
1622 }
1623 }
1624 Primitive::ChangeFieldNullability(c) => {
1625 if let Some(m) = schema.models.iter_mut().find(|m| m.name == c.model) {
1626 if let Some(f) = m.fields.iter_mut().find(|f| f.name == c.field) {
1627 f.nullable = c.nullable;
1628 }
1629 }
1630 }
1631 Primitive::RenameModel(r) => {
1632 if let Some(m) = schema.models.iter_mut().find(|m| m.name == r.from) {
1633 m.name = r.to.clone();
1634 }
1635 }
1636 Primitive::AddRelation(r) => {
1637 use crate::schema::{Relation, RelationKind};
1638 if !matches!(r.kind, RelationKind::BelongsTo) {
1639 return;
1640 }
1641 if let Some(m) = schema.models.iter_mut().find(|m| m.name == r.from) {
1642 if m.fields.iter().any(|f| f.name == r.via) {
1643 return;
1644 }
1645 m.fields.push(SchemaField {
1646 name: r.via.clone(),
1647 ty: "i64".to_string(),
1648 nullable: !r.required,
1649 editable: true,
1650 relation: Some(Relation {
1651 model: r.to.clone(),
1652 field: "id".to_string(),
1653 kind: RelationKind::BelongsTo,
1654 display_field: None,
1658 required: Some(r.required),
1662 on_delete: Some(r.on_delete.as_str().to_string()),
1663 }),
1664 });
1665 }
1666 }
1667 _ => {}
1668 }
1669}
1670
1671fn policy_violation_for(step: &Primitive, pii: &[&str], ctx: &ContextConfig) -> Option<String> {
1676 let ctx_tag = {
1677 let mut parts: Vec<String> = Vec::new();
1678 if let Some(c) = &ctx.country {
1679 parts.push(format!("country={c}"));
1680 }
1681 if let Some(i) = &ctx.industry {
1682 parts.push(format!("industry={i}"));
1683 }
1684 if ctx.requires_gdpr() {
1685 parts.push("GDPR".to_string());
1686 }
1687 if parts.is_empty() {
1688 String::new()
1689 } else {
1690 format!(" ({})", parts.join(", "))
1691 }
1692 };
1693 match step {
1694 Primitive::RemoveField(r) if pii.iter().any(|p| *p == r.field) => Some(format!(
1695 "refusing to remove `{}.{}` — it is personally-identifying data under the project context{}. Change the context or update the plan by hand.",
1696 r.model, r.field, ctx_tag,
1697 )),
1698 Primitive::ChangeFieldType(c) if pii.iter().any(|p| *p == c.field) => Some(format!(
1699 "refusing to change the type of `{}.{}` — it is personally-identifying data under the project context{}; retention / hashing pipelines depend on the stored shape.",
1700 c.model, c.field, ctx_tag,
1701 )),
1702 Primitive::RenameField(r) if pii.iter().any(|p| *p == r.from) => Some(format!(
1703 "refusing to rename `{}.{}` — it is personally-identifying data under the project context{}; audit trails keyed on the old name would break.",
1704 r.model, r.from, ctx_tag,
1705 )),
1706 _ => None,
1707 }
1708}
1709
1710fn patch_models_for_remove_field(
1722 source: &str,
1723 struct_name: &str,
1724 field_name: &str,
1725 field_ty: &str,
1726 nullable: bool,
1727) -> Result<String, String> {
1728 let mut out = source.to_string();
1729
1730 let rust_type = rust_type_for(field_ty, nullable);
1732 let struct_line = format!(" pub {field_name}: {rust_type},\n");
1733 out = replace_in_struct_literal(&out, struct_name, &struct_line, "")?;
1734
1735 out = remove_from_str_array_scoped(&out, struct_name, "COLUMNS", field_name)?;
1738
1739 if out.contains("const INSERT_COLUMNS") {
1742 out = remove_from_str_array_scoped(&out, struct_name, "INSERT_COLUMNS", field_name)
1743 .unwrap_or(out);
1744 }
1745
1746 let accessor = row_accessor(field_ty, nullable);
1749 let from_row_line = format!(" {field_name}: row.{accessor}(\"{field_name}\")?,\n",);
1750 out = replace_in_impl_method_literal(
1751 &out,
1752 struct_name,
1753 "fn from_row(",
1754 "Ok(Self {",
1755 &from_row_line,
1756 "",
1757 )?;
1758
1759 let insert_line = build_insert_values_line(field_name, field_ty, nullable);
1761 out = replace_in_impl_method_literal(
1762 &out,
1763 struct_name,
1764 "fn insert_values(",
1765 "vec![",
1766 &insert_line,
1767 "",
1768 )?;
1769
1770 Ok(out)
1771}
1772
1773fn replace_in_impl_method_literal(
1780 src: &str,
1781 struct_name: &str,
1782 fn_anchor: &str,
1783 body_open: &str,
1784 from: &str,
1785 to: &str,
1786) -> Result<String, String> {
1787 let impl_anchor = format!("impl Model for {struct_name}");
1788 let impl_start = src
1789 .find(&impl_anchor)
1790 .ok_or_else(|| format!("could not find `{impl_anchor}`"))?;
1791 let impl_brace_rel = src[impl_start..]
1792 .find('{')
1793 .ok_or_else(|| format!("`{impl_anchor}` has no opening brace"))?;
1794 let impl_open = impl_start + impl_brace_rel;
1795 let impl_close = find_matching_brace(src, impl_open)
1796 .ok_or_else(|| format!("`{impl_anchor}` is not closed"))?;
1797
1798 let block = &src[impl_open..=impl_close];
1799 let fn_rel = block
1800 .find(fn_anchor)
1801 .ok_or_else(|| format!("`{fn_anchor}` not found inside `{impl_anchor}`"))?;
1802 let body_rel_in_fn = block[fn_rel..]
1803 .find(body_open)
1804 .ok_or_else(|| format!("`{body_open}` not found after `{fn_anchor}`"))?;
1805 let body_open_abs = impl_open + fn_rel + body_rel_in_fn + body_open.len() - 1;
1806 let body_close_abs = match src.as_bytes()[body_open_abs] {
1808 b'{' => find_matching_brace(src, body_open_abs),
1809 b'[' => find_matching_bracket(src, body_open_abs),
1810 _ => None,
1811 }
1812 .ok_or_else(|| format!("unterminated body after `{body_open}`"))?;
1813
1814 let body = &src[body_open_abs + 1..body_close_abs];
1815 if !body.contains(from) {
1816 return Err(format!(
1817 "{fn_anchor} body on `{struct_name}` does not contain `{from}`"
1818 ));
1819 }
1820 let new_body = body.replacen(from, to, 1);
1821 let mut out = String::with_capacity(src.len());
1822 out.push_str(&src[..=body_open_abs]);
1823 out.push_str(&new_body);
1824 out.push_str(&src[body_close_abs..]);
1825 Ok(out)
1826}
1827
1828fn remove_from_str_array_scoped(
1832 src: &str,
1833 struct_name: &str,
1834 const_name: &str,
1835 field: &str,
1836) -> Result<String, String> {
1837 let impl_anchor = format!("impl Model for {struct_name}");
1838 let impl_start = src
1839 .find(&impl_anchor)
1840 .ok_or_else(|| format!("could not find `{impl_anchor}`"))?;
1841 let brace_rel = src[impl_start..]
1842 .find('{')
1843 .ok_or_else(|| format!("`{impl_anchor}` has no opening brace"))?;
1844 let impl_open = impl_start + brace_rel;
1845 let impl_close = find_matching_brace(src, impl_open)
1846 .ok_or_else(|| format!("`{impl_anchor}` is not closed"))?;
1847
1848 let block = &src[impl_open..=impl_close];
1851 let new_block = remove_from_str_array(block, const_name, field)?;
1852 let mut out = String::with_capacity(src.len() + new_block.len() - block.len());
1853 out.push_str(&src[..impl_open]);
1854 out.push_str(&new_block);
1855 out.push_str(&src[impl_close + 1..]);
1856 Ok(out)
1857}
1858
1859fn remove_from_str_array(src: &str, const_name: &str, field: &str) -> Result<String, String> {
1866 let anchor = format!("const {const_name}");
1867 let start = src
1868 .find(&anchor)
1869 .ok_or_else(|| format!("could not find `const {const_name}`"))?;
1870 let rel_open = src[start..]
1871 .find("= &[")
1872 .ok_or_else(|| format!("`const {const_name}` does not use `= &[ … ]`"))?;
1873 let open = start + rel_open + "= &".len();
1874 let close = find_matching_bracket(src, open)
1875 .ok_or_else(|| format!("`const {const_name}` array is not closed"))?;
1876 let inner = &src[open + 1..close];
1877 let literal = format!("\"{field}\"");
1878 let literal_idx = inner
1879 .find(&literal)
1880 .ok_or_else(|| format!("`{const_name}` does not contain \"{field}\""))?;
1881
1882 let mut slice_start = literal_idx;
1892 let mut slice_end = literal_idx + literal.len();
1893
1894 let after_literal = &inner.as_bytes()[slice_end..];
1896 if let Some(comma_rel) = after_literal.iter().position(|&b| !b.is_ascii_whitespace()) {
1897 if after_literal[comma_rel] == b',' {
1898 slice_end += comma_rel + 1;
1899 while slice_end < inner.len()
1902 && (inner.as_bytes()[slice_end] == b' ' || inner.as_bytes()[slice_end] == b'\t')
1903 {
1904 slice_end += 1;
1905 }
1906 if slice_end < inner.len() && inner.as_bytes()[slice_end] == b'\n' {
1907 }
1912 }
1913 } else {
1914 let before = &inner.as_bytes()[..slice_start];
1917 if let Some(pos) = before.iter().rposition(|&b| !b.is_ascii_whitespace()) {
1918 if before[pos] == b',' {
1919 slice_start = pos;
1920 }
1921 }
1922 }
1923
1924 let mut new_inner = String::with_capacity(inner.len());
1925 new_inner.push_str(&inner[..slice_start]);
1926 new_inner.push_str(&inner[slice_end..]);
1927
1928 let mut cursor = 0;
1934 while let Some(rel) = new_inner[cursor..].find('\n') {
1935 let pos = cursor + rel;
1936 let after = &new_inner[pos + 1..];
1937 let lead_ws = after
1938 .bytes()
1939 .take_while(|&b| b == b' ' || b == b'\t')
1940 .count();
1941 if after
1942 .as_bytes()
1943 .get(lead_ws)
1944 .map(|&b| b == b'\n')
1945 .unwrap_or(false)
1946 {
1947 let drain_end = pos + 1 + lead_ws + 1;
1948 new_inner.drain(pos + 1..drain_end);
1949 } else {
1952 cursor = pos + 1;
1953 }
1954 }
1955
1956 let mut out = String::with_capacity(src.len());
1957 out.push_str(&src[..=open]);
1958 out.push_str(&new_inner);
1959 out.push_str(&src[close..]);
1960 Ok(out)
1961}
1962
1963fn patch_models_for_add_field(
1964 source: &str,
1965 struct_name: &str,
1966 field: &FieldSpec,
1967) -> Result<String, String> {
1968 let rust_type = rust_type_for(&field.ty, field.nullable);
1969 let mut out = source.to_string();
1970
1971 if field.ty == "DateTime" && !has_chrono_use(&out) {
1977 out = insert_chrono_import(&out);
1978 }
1979
1980 let field_line = format!(" pub {}: {},\n", field.name, rust_type);
1982 out = insert_before_struct_close(&out, struct_name, &field_line)?;
1983
1984 out = insert_into_str_array(&out, "COLUMNS", &field.name)?;
1986 if out.contains("const INSERT_COLUMNS") {
1989 out = insert_into_str_array(&out, "INSERT_COLUMNS", &field.name)?;
1990 }
1991
1992 let accessor = row_accessor(&field.ty, field.nullable);
1994 let from_row_line = format!(
1995 " {name}: row.{accessor}(\"{name}\")?,\n",
1996 name = field.name,
1997 accessor = accessor,
1998 );
1999 out = insert_before_ok_self_close(&out, &from_row_line)?;
2000
2001 let insert_line = build_insert_values_line(&field.name, &field.ty, field.nullable);
2003 out = insert_before_vec_close(&out, &insert_line)?;
2004
2005 Ok(out)
2006}
2007
2008fn patch_models_for_rename_field(
2009 source: &str,
2010 struct_name: &str,
2011 from: &str,
2012 to: &str,
2013) -> Result<String, String> {
2014 let mut out = source.to_string();
2015
2016 out = rename_in_struct(&out, struct_name, from, to)?;
2018
2019 out = replace_in_str_array(&out, "COLUMNS", from, to)?;
2021 if out.contains("const INSERT_COLUMNS") {
2022 out = replace_in_str_array(&out, "INSERT_COLUMNS", from, to).unwrap_or(out);
2025 }
2026
2027 out = rename_in_from_row(&out, from, to)?;
2029
2030 out = rename_in_insert_values(&out, from, to)?;
2032
2033 Ok(out)
2034}
2035
2036fn patch_models_for_change_field_type(
2040 source: &str,
2041 struct_name: &str,
2042 field_name: &str,
2043 old_ty: &str,
2044 new_ty: &str,
2045 nullable: bool,
2046) -> Result<String, String> {
2047 let mut out = source.to_string();
2048 if (new_ty == "DateTime") && !has_chrono_use(&out) {
2050 out = insert_chrono_import(&out);
2051 }
2052 let old_rust = rust_type_for(old_ty, nullable);
2054 let new_rust = rust_type_for(new_ty, nullable);
2055 out = replace_in_struct_literal(
2056 &out,
2057 struct_name,
2058 &format!("pub {field_name}: {old_rust},"),
2059 &format!("pub {field_name}: {new_rust},"),
2060 )?;
2061 let old_acc = row_accessor(old_ty, nullable);
2063 let new_acc = row_accessor(new_ty, nullable);
2064 if old_acc != new_acc {
2065 out = replace_in_from_row_literal(
2066 &out,
2067 &format!("{field_name}: row.{old_acc}(\"{field_name}\")?,"),
2068 &format!("{field_name}: row.{new_acc}(\"{field_name}\")?,"),
2069 )?;
2070 }
2071 let old_line = build_insert_values_line(field_name, old_ty, nullable);
2074 let new_line = build_insert_values_line(field_name, new_ty, nullable);
2075 if old_line != new_line {
2076 let old_trim = old_line.trim().to_string();
2077 let new_trim = new_line.trim().to_string();
2078 out = replace_in_insert_values_literal(&out, &old_trim, &new_trim)?;
2079 }
2080 Ok(out)
2081}
2082
2083fn patch_models_for_change_nullability(
2088 source: &str,
2089 struct_name: &str,
2090 field_name: &str,
2091 ty: &str,
2092 was_nullable: bool,
2093 now_nullable: bool,
2094) -> Result<String, String> {
2095 let mut out = source.to_string();
2096 let old_rust = rust_type_for(ty, was_nullable);
2097 let new_rust = rust_type_for(ty, now_nullable);
2098 out = replace_in_struct_literal(
2099 &out,
2100 struct_name,
2101 &format!("pub {field_name}: {old_rust},"),
2102 &format!("pub {field_name}: {new_rust},"),
2103 )?;
2104 let old_acc = row_accessor(ty, was_nullable);
2105 let new_acc = row_accessor(ty, now_nullable);
2106 out = replace_in_from_row_literal(
2107 &out,
2108 &format!("{field_name}: row.{old_acc}(\"{field_name}\")?,"),
2109 &format!("{field_name}: row.{new_acc}(\"{field_name}\")?,"),
2110 )?;
2111 Ok(out)
2112}
2113
2114fn patch_models_for_rename_model(
2117 source: &str,
2118 old_struct: &str,
2119 new_struct: &str,
2120 old_table: &str,
2121 new_table: &str,
2122) -> Result<String, String> {
2123 let mut out = source.to_string();
2124
2125 let old_struct_decl = format!("pub struct {old_struct}");
2126 let new_struct_decl = format!("pub struct {new_struct}");
2127 if !out.contains(&old_struct_decl) {
2128 return Err(format!("struct `{old_struct}` not found"));
2129 }
2130 out = out.replacen(&old_struct_decl, &new_struct_decl, 1);
2131
2132 let old_impl = format!("impl Model for {old_struct}");
2133 let new_impl = format!("impl Model for {new_struct}");
2134 if out.contains(&old_impl) {
2135 out = out.replacen(&old_impl, &new_impl, 1);
2136 }
2137
2138 let old_tbl = format!("const TABLE: &'static str = \"{old_table}\";");
2139 let new_tbl = format!("const TABLE: &'static str = \"{new_table}\";");
2140 if out.contains(&old_tbl) {
2141 out = out.replacen(&old_tbl, &new_tbl, 1);
2142 }
2143 Ok(out)
2144}
2145
2146fn patch_admin_for_rename_model(
2149 source: &str,
2150 old_struct: &str,
2151 new_struct: &str,
2152) -> Result<String, String> {
2153 let mut out = source.to_string();
2154 let old_use = format!("use super::models::{old_struct};");
2155 let new_use = format!("use super::models::{new_struct};");
2156 if out.contains(&old_use) {
2157 out = out.replacen(&old_use, &new_use, 1);
2158 }
2159 let old_call = format!("admin.model::<{old_struct}>()");
2160 let new_call = format!("admin.model::<{new_struct}>()");
2161 if !out.contains(&old_call) {
2162 return Err(format!(
2163 "`admin.rs` does not call `admin.model::<{old_struct}>()`"
2164 ));
2165 }
2166 out = out.replacen(&old_call, &new_call, 1);
2167 Ok(out)
2168}
2169
2170fn replace_in_struct_literal(
2176 src: &str,
2177 struct_name: &str,
2178 from: &str,
2179 to: &str,
2180) -> Result<String, String> {
2181 let (open, close) = find_struct_block(src, struct_name)
2182 .ok_or_else(|| format!("struct `{struct_name}` block not found"))?;
2183 let block = &src[open..=close];
2184 if !block.contains(from) {
2185 return Err(format!("struct `{struct_name}` does not contain `{from}`"));
2186 }
2187 let new_block = block.replacen(from, to, 1);
2188 let mut out = String::with_capacity(src.len());
2189 out.push_str(&src[..open]);
2190 out.push_str(&new_block);
2191 out.push_str(&src[close + 1..]);
2192 Ok(out)
2193}
2194
2195fn replace_in_from_row_literal(src: &str, from: &str, to: &str) -> Result<String, String> {
2196 let fn_start = src
2197 .find("fn from_row(")
2198 .ok_or_else(|| "`fn from_row(` not found".to_string())?;
2199 let ok_self_rel = src[fn_start..]
2200 .find("Ok(Self {")
2201 .ok_or_else(|| "`Ok(Self {` not found".to_string())?;
2202 let ok_self_open = fn_start + ok_self_rel + "Ok(Self ".len();
2203 let ok_self_close = find_matching_brace(src, ok_self_open)
2204 .ok_or_else(|| "`Ok(Self { … }` is not closed".to_string())?;
2205 let block = &src[ok_self_open..=ok_self_close];
2206 if !block.contains(from) {
2207 return Err(format!("from_row does not contain `{from}`"));
2208 }
2209 let replaced = block.replacen(from, to, 1);
2210 let mut out = String::with_capacity(src.len());
2211 out.push_str(&src[..ok_self_open]);
2212 out.push_str(&replaced);
2213 out.push_str(&src[ok_self_close + 1..]);
2214 Ok(out)
2215}
2216
2217fn replace_in_insert_values_literal(src: &str, from: &str, to: &str) -> Result<String, String> {
2218 let fn_start = src
2219 .find("fn insert_values(")
2220 .ok_or_else(|| "`fn insert_values(` not found".to_string())?;
2221 let vec_rel = src[fn_start..]
2222 .find("vec![")
2223 .ok_or_else(|| "no `vec![` inside insert_values".to_string())?;
2224 let vec_open = fn_start + vec_rel + 4;
2225 let vec_close = find_matching_bracket(src, vec_open)
2226 .ok_or_else(|| "`vec![ … ]` is not closed".to_string())?;
2227 let block = &src[vec_open..=vec_close];
2228 if !block.contains(from) {
2229 return Err(format!("insert_values does not contain `{from}`"));
2230 }
2231 let replaced = block.replacen(from, to, 1);
2232 let mut out = String::with_capacity(src.len());
2233 out.push_str(&src[..vec_open]);
2234 out.push_str(&replaced);
2235 out.push_str(&src[vec_close + 1..]);
2236 Ok(out)
2237}
2238
2239fn find_struct_block(src: &str, name: &str) -> Option<(usize, usize)> {
2240 let anchor = format!("pub struct {name}");
2241 let start = src.find(&anchor)?;
2242 let after_name = start + anchor.len();
2245 match src.as_bytes().get(after_name)? {
2246 b' ' | b'{' | b'\t' | b'\n' | b'<' => {}
2247 _ => return None,
2248 }
2249 let open = start + src[start..].find('{')?;
2250 let close = find_matching_brace(src, open)?;
2251 Some((open, close))
2252}
2253
2254fn find_matching_brace(src: &str, open_idx: usize) -> Option<usize> {
2255 let bytes = src.as_bytes();
2256 if *bytes.get(open_idx)? != b'{' {
2257 return None;
2258 }
2259 let mut depth: i32 = 0;
2260 let mut i = open_idx;
2261 while i < bytes.len() {
2262 match bytes[i] {
2263 b'{' => depth += 1,
2264 b'}' => {
2265 depth -= 1;
2266 if depth == 0 {
2267 return Some(i);
2268 }
2269 }
2270 _ => {}
2271 }
2272 i += 1;
2273 }
2274 None
2275}
2276
2277fn struct_declares_field(inside_struct: &str, field_name: &str) -> bool {
2278 for line in inside_struct.lines() {
2280 let trimmed = line.trim_start();
2281 if let Some(rest) = trimmed.strip_prefix("pub ") {
2282 let rest = rest.trim_start();
2283 let mut chars = rest.chars();
2285 let mut ident = String::new();
2286 for ch in chars.by_ref() {
2287 if ch.is_ascii_alphanumeric() || ch == '_' {
2288 ident.push(ch);
2289 } else {
2290 break;
2291 }
2292 }
2293 if ident == field_name {
2294 let rest = rest.trim_start_matches(&ident[..]).trim_start();
2295 if rest.starts_with(':') {
2296 return true;
2297 }
2298 }
2299 }
2300 }
2301 false
2302}
2303
2304fn insert_before_struct_close(
2305 src: &str,
2306 struct_name: &str,
2307 new_line: &str,
2308) -> Result<String, String> {
2309 let (_open, close) = find_struct_block(src, struct_name)
2310 .ok_or_else(|| format!("could not locate `pub struct {struct_name}` block"))?;
2311 insert_before_brace(src, close, new_line)
2312}
2313
2314fn insert_before_ok_self_close(src: &str, new_line: &str) -> Result<String, String> {
2315 let needle = "Ok(Self {";
2319 let first = src
2320 .find(needle)
2321 .ok_or_else(|| "could not locate `Ok(Self {` in from_row".to_string())?;
2322 if src[first + needle.len()..].contains(needle) {
2323 return Err("multiple `Ok(Self {` in file; refusing to choose".into());
2324 }
2325 let open = first + needle.len() - 1; let close = find_matching_brace(src, open)
2327 .ok_or_else(|| "`Ok(Self { … }` is not closed".to_string())?;
2328 insert_before_brace(src, close, new_line)
2329}
2330
2331fn insert_before_vec_close(src: &str, new_line: &str) -> Result<String, String> {
2332 let fn_idx = src
2334 .find("fn insert_values(")
2335 .ok_or_else(|| "could not locate `fn insert_values(`".to_string())?;
2336 let vec_rel = src[fn_idx..]
2337 .find("vec![")
2338 .ok_or_else(|| "no `vec![` inside `insert_values`".to_string())?;
2339 let vec_open = fn_idx + vec_rel + 4; let close = find_matching_bracket(src, vec_open)
2341 .ok_or_else(|| "`vec![ … ]` is not closed".to_string())?;
2342 insert_before_bracket(src, close, new_line)
2343}
2344
2345fn find_matching_bracket(src: &str, open_idx: usize) -> Option<usize> {
2346 let bytes = src.as_bytes();
2347 if *bytes.get(open_idx)? != b'[' {
2348 return None;
2349 }
2350 let mut depth: i32 = 0;
2351 let mut i = open_idx;
2352 while i < bytes.len() {
2353 match bytes[i] {
2354 b'[' => depth += 1,
2355 b']' => {
2356 depth -= 1;
2357 if depth == 0 {
2358 return Some(i);
2359 }
2360 }
2361 _ => {}
2362 }
2363 i += 1;
2364 }
2365 None
2366}
2367
2368fn insert_before_brace(src: &str, close: usize, new_line: &str) -> Result<String, String> {
2369 let before = &src[..close];
2370 let last_nl = before.rfind('\n').ok_or_else(|| {
2371 "refusing to patch single-line `{ … }`: file layout is outside the 0.5.2 safe subset"
2372 .to_string()
2373 })?;
2374 let mut out = String::with_capacity(src.len() + new_line.len());
2375 out.push_str(&src[..=last_nl]);
2376 out.push_str(new_line);
2377 if !new_line.ends_with('\n') {
2378 out.push('\n');
2379 }
2380 out.push_str(&src[last_nl + 1..]);
2381 Ok(out)
2382}
2383
2384fn insert_before_bracket(src: &str, close: usize, new_line: &str) -> Result<String, String> {
2385 let before = &src[..close];
2386 let last_nl = before.rfind('\n').ok_or_else(|| {
2387 "refusing to patch single-line `vec![ … ]`: outside the safe subset".to_string()
2388 })?;
2389 let mut out = String::with_capacity(src.len() + new_line.len());
2390 out.push_str(&src[..=last_nl]);
2391 out.push_str(new_line);
2392 if !new_line.ends_with('\n') {
2393 out.push('\n');
2394 }
2395 out.push_str(&src[last_nl + 1..]);
2396 Ok(out)
2397}
2398
2399fn insert_into_str_array(src: &str, const_name: &str, column: &str) -> Result<String, String> {
2400 let anchor = format!("const {const_name}");
2401 let start = src
2402 .find(&anchor)
2403 .ok_or_else(|| format!("could not find `const {const_name}`"))?;
2404 let rel_open = src[start..]
2408 .find("= &[")
2409 .ok_or_else(|| format!("`const {const_name}` does not use the expected `= &[ … ]` form"))?;
2410 let open = start + rel_open + "= &".len();
2411 let close = find_matching_bracket(src, open)
2412 .ok_or_else(|| format!("`const {const_name}` array is not closed"))?;
2413 let inner = &src[open + 1..close];
2414 if inner.contains(&format!("\"{column}\"")) {
2415 return Err(format!(
2416 "`{const_name}` already contains \"{column}\"; refusing to duplicate"
2417 ));
2418 }
2419 let trimmed = inner.trim_end_matches(|c: char| c.is_whitespace() || c == ',');
2421 let addition = if trimmed.trim().is_empty() {
2422 format!("\"{column}\"")
2423 } else {
2424 format!("{trimmed}, \"{column}\"")
2425 };
2426 let tail_ws_start = inner
2429 .rfind(|c: char| !c.is_whitespace() && c != ',')
2430 .map(|i| i + 1)
2431 .unwrap_or(0);
2432 let tail_ws = &inner[tail_ws_start..];
2433 let mut out = String::with_capacity(src.len() + column.len() + 4);
2434 out.push_str(&src[..=open]);
2435 out.push_str(&addition);
2436 out.push_str(tail_ws);
2437 out.push_str(&src[close..]);
2438 Ok(out)
2439}
2440
2441fn replace_in_str_array(
2442 src: &str,
2443 const_name: &str,
2444 from: &str,
2445 to: &str,
2446) -> Result<String, String> {
2447 let anchor = format!("const {const_name}");
2448 let start = src
2449 .find(&anchor)
2450 .ok_or_else(|| format!("could not find `const {const_name}`"))?;
2451 let rel_open = src[start..]
2452 .find("= &[")
2453 .ok_or_else(|| format!("`const {const_name}` does not use the expected `= &[ … ]` form"))?;
2454 let open = start + rel_open + "= &".len();
2455 let close = find_matching_bracket(src, open)
2456 .ok_or_else(|| format!("`const {const_name}` array is not closed"))?;
2457 let inner = &src[open + 1..close];
2458 let from_literal = format!("\"{from}\"");
2459 let to_literal = format!("\"{to}\"");
2460 if !inner.contains(&from_literal) {
2461 return Err(format!(
2462 "`{const_name}` does not contain \"{from}\"; rename cannot proceed"
2463 ));
2464 }
2465 if inner.contains(&to_literal) {
2466 return Err(format!(
2467 "`{const_name}` already contains \"{to}\"; rename target is taken"
2468 ));
2469 }
2470 let new_inner = inner.replacen(&from_literal, &to_literal, 1);
2473 let mut out = String::with_capacity(src.len());
2474 out.push_str(&src[..=open]);
2475 out.push_str(&new_inner);
2476 out.push_str(&src[close..]);
2477 Ok(out)
2478}
2479
2480fn rename_in_struct(src: &str, struct_name: &str, from: &str, to: &str) -> Result<String, String> {
2481 let (open, close) =
2482 find_struct_block(src, struct_name).ok_or_else(|| "struct block not found".to_string())?;
2483 let block = &src[open..=close];
2484 let from_pattern = format!("pub {from}:");
2485 let to_pattern = format!("pub {to}:");
2486 if !block.contains(&from_pattern) {
2487 return Err(format!(
2488 "struct {struct_name} does not declare `pub {from}:`"
2489 ));
2490 }
2491 let new_block = block.replacen(&from_pattern, &to_pattern, 1);
2492 let mut out = String::with_capacity(src.len());
2493 out.push_str(&src[..open]);
2494 out.push_str(&new_block);
2495 out.push_str(&src[close + 1..]);
2496 Ok(out)
2497}
2498
2499fn rename_in_from_row(src: &str, from: &str, to: &str) -> Result<String, String> {
2500 let fn_start = src
2501 .find("fn from_row(")
2502 .ok_or_else(|| "from_row not found".to_string())?;
2503 let ok_self_rel = src[fn_start..]
2504 .find("Ok(Self {")
2505 .ok_or_else(|| "Ok(Self not found".to_string())?;
2506 let ok_self_open = fn_start + ok_self_rel + "Ok(Self ".len();
2507 let ok_self_close = find_matching_brace(src, ok_self_open)
2508 .ok_or_else(|| "Ok(Self block is not closed".to_string())?;
2509 let block = &src[ok_self_open..=ok_self_close];
2510 let from_lhs = format!("{from}:");
2513 let from_arg = format!("\"{from}\"");
2514 let to_lhs = format!("{to}:");
2515 let to_arg = format!("\"{to}\"");
2516 if !block.contains(&from_lhs) {
2517 return Err(format!(
2518 "from_row does not reference `{from}:`; rename cannot proceed"
2519 ));
2520 }
2521 let replaced = block
2522 .replacen(&from_lhs, &to_lhs, 1)
2523 .replacen(&from_arg, &to_arg, 1);
2524 let mut out = String::with_capacity(src.len());
2525 out.push_str(&src[..ok_self_open]);
2526 out.push_str(&replaced);
2527 out.push_str(&src[ok_self_close + 1..]);
2528 Ok(out)
2529}
2530
2531fn rename_in_insert_values(src: &str, from: &str, to: &str) -> Result<String, String> {
2532 let fn_start = src
2533 .find("fn insert_values(")
2534 .ok_or_else(|| "insert_values not found".to_string())?;
2535 let vec_rel = src[fn_start..]
2536 .find("vec![")
2537 .ok_or_else(|| "no `vec![` inside insert_values".to_string())?;
2538 let vec_open = fn_start + vec_rel + 4;
2539 let vec_close = find_matching_bracket(src, vec_open)
2540 .ok_or_else(|| "vec![ … ] is not closed".to_string())?;
2541 let block = &src[vec_open..=vec_close];
2542 let from_pattern = format!("self.{from}");
2543 let to_pattern = format!("self.{to}");
2544 if !block.contains(&from_pattern) {
2545 return Err(format!(
2546 "insert_values does not reference `self.{from}`; rename cannot proceed"
2547 ));
2548 }
2549 let replaced = block.replacen(&from_pattern, &to_pattern, 1);
2550 let mut out = String::with_capacity(src.len());
2551 out.push_str(&src[..vec_open]);
2552 out.push_str(&replaced);
2553 out.push_str(&src[vec_close + 1..]);
2554 Ok(out)
2555}
2556
2557fn has_chrono_use(src: &str) -> bool {
2561 src.lines()
2562 .any(|l| l.trim_start().starts_with("use chrono::"))
2563}
2564
2565fn insert_chrono_import(src: &str) -> String {
2566 let mut last_use_end: Option<usize> = None;
2568 for (idx, line) in src.match_indices('\n') {
2569 let before_nl = &src[..idx];
2571 let line_start = before_nl.rfind('\n').map(|p| p + 1).unwrap_or(0);
2572 let line_txt = &src[line_start..idx];
2573 if line_txt.trim_start().starts_with("use ") {
2574 last_use_end = Some(idx);
2575 }
2576 let _ = line; }
2578 match last_use_end {
2579 Some(end) => {
2580 let mut out = String::with_capacity(src.len() + 40);
2581 out.push_str(&src[..=end]);
2582 out.push_str("use chrono::{DateTime, Utc};\n");
2583 out.push_str(&src[end + 1..]);
2584 out
2585 }
2586 None => format!("use chrono::{{DateTime, Utc}};\n{src}"),
2587 }
2588}
2589
2590fn rust_type_for(ty: &str, nullable: bool) -> String {
2593 let base = match ty {
2594 "i32" => "i32",
2595 "i64" => "i64",
2596 "String" => "String",
2597 "bool" => "bool",
2598 "DateTime" => "DateTime<Utc>",
2599 other => other,
2600 };
2601 if nullable {
2602 format!("Option<{base}>")
2603 } else {
2604 base.to_string()
2605 }
2606}
2607
2608fn row_accessor(ty: &str, nullable: bool) -> String {
2609 let suffix = match ty {
2610 "i32" => "i32",
2611 "i64" => "i64",
2612 "String" => "string",
2613 "bool" => "bool",
2614 "DateTime" => "datetime",
2615 _ => "string",
2616 };
2617 if nullable {
2618 format!("get_optional_{suffix}")
2619 } else {
2620 format!("get_{suffix}")
2621 }
2622}
2623
2624fn build_insert_values_line(field: &str, ty: &str, _nullable: bool) -> String {
2625 let call = if ty == "String" {
2631 format!("self.{field}.clone().into()")
2632 } else {
2633 format!("self.{field}.into()")
2634 };
2635 format!(" {call},\n")
2636}
2637
2638fn locate_model_file(
2641 project: &ProjectView,
2642 struct_name: &str,
2643) -> Result<(String, String), ExecutionError> {
2644 let mut matches: Vec<&str> = project
2645 .models_files
2646 .iter()
2647 .filter(|(_, f)| f.struct_names.iter().any(|s| s == struct_name))
2648 .map(|(app, _)| app.as_str())
2649 .collect();
2650 match matches.len() {
2651 0 => Err(ExecutionError::ProjectStructure(format!(
2652 "no apps/<app>/models.rs declares `pub struct {struct_name}`"
2653 ))),
2654 1 => {
2655 let app = matches.remove(0).to_string();
2656 let source = project.models_files[&app].source.clone();
2657 Ok((app, source))
2658 }
2659 _ => Err(ExecutionError::ProjectStructure(format!(
2660 "multiple apps declare `pub struct {struct_name}`: {}",
2661 matches.join(", ")
2662 ))),
2663 }
2664}
2665
2666fn find_table_for_struct(src: &str, struct_name: &str) -> Option<String> {
2667 let impl_anchor = format!("impl Model for {struct_name}");
2676 let slice = if let Some(impl_start) = src.find(&impl_anchor) {
2677 let brace_rel = src[impl_start..].find('{')?;
2680 let open = impl_start + brace_rel;
2681 let close = find_matching_brace(src, open)?;
2682 &src[open..=close]
2683 } else {
2684 src
2688 };
2689 let anchor = "const TABLE: &'static str = \"";
2690 let start = slice.find(anchor)? + anchor.len();
2691 let end = slice[start..].find('"')?;
2692 Some(slice[start..start + end].to_string())
2693}
2694
2695fn fallback_table_name(struct_name: &str) -> Option<String> {
2698 let mut out = String::with_capacity(struct_name.len() + 4);
2699 for (i, ch) in struct_name.chars().enumerate() {
2700 if ch.is_ascii_uppercase() {
2701 if i > 0 {
2702 out.push('_');
2703 }
2704 out.extend(ch.to_lowercase());
2705 } else {
2706 out.push(ch);
2707 }
2708 }
2709 if !out.ends_with('s') {
2711 out.push('s');
2712 }
2713 Some(out)
2714}
2715
2716fn next_migration_number(existing: &[String]) -> u32 {
2717 let mut max: u32 = 0;
2718 for name in existing {
2719 let Some(prefix) = name.split('_').next() else {
2720 continue;
2721 };
2722 if let Ok(n) = prefix.parse::<u32>() {
2723 if n > max {
2724 max = n;
2725 }
2726 }
2727 }
2728 max + 1
2729}
2730
2731fn new_migration_path(project: &ProjectView, number: u32, slug: &str) -> (PathBuf, String) {
2732 let filename = format!("{number:04}_{slug}.sql");
2733 (project.root.join("migrations").join(&filename), filename)
2734}
2735
2736fn sql_for_add_field(table: &str, field: &FieldSpec) -> String {
2737 let sql_type = sql_type_for(&field.ty);
2738 if field.nullable {
2739 format!(
2740 "-- Generated by rustio ai apply (0.5.2). DO NOT EDIT.\n\
2741 ALTER TABLE {table} ADD COLUMN {name} {sql_type};\n",
2742 name = field.name,
2743 )
2744 } else {
2745 let default = safe_default_literal(&field.ty);
2746 format!(
2747 "-- Generated by rustio ai apply (0.5.2). DO NOT EDIT.\n\
2748 ALTER TABLE {table} ADD COLUMN {name} {sql_type} NOT NULL DEFAULT {default};\n",
2749 name = field.name,
2750 )
2751 }
2752}
2753
2754fn sql_type_for(ty: &str) -> &'static str {
2755 match ty {
2756 "i32" | "i64" | "bool" => "INTEGER",
2757 "String" => "TEXT",
2758 "DateTime" => "TEXT",
2759 _ => "TEXT",
2760 }
2761}
2762
2763fn safe_default_literal(ty: &str) -> &'static str {
2764 match ty {
2765 "i32" | "i64" | "bool" => "0",
2766 "String" => "''",
2767 "DateTime" => "'1970-01-01 00:00:00'",
2771 _ => "''",
2772 }
2773}
2774
2775impl ProjectView {
2780 pub fn from_dir(root: &Path) -> Result<Self, ExecutionError> {
2786 let apps_dir = root.join("apps");
2787 let migrations_dir = root.join("migrations");
2788 if !apps_dir.is_dir() {
2789 return Err(ExecutionError::ProjectStructure(format!(
2790 "expected directory `apps/` at {}",
2791 root.display()
2792 )));
2793 }
2794 if !migrations_dir.is_dir() {
2795 return Err(ExecutionError::ProjectStructure(format!(
2796 "expected directory `migrations/` at {}",
2797 root.display()
2798 )));
2799 }
2800
2801 let mut models_files = BTreeMap::new();
2802 let entries = std::fs::read_dir(&apps_dir).map_err(|e| ExecutionError::IoError {
2803 path: apps_dir.display().to_string(),
2804 message: e.to_string(),
2805 })?;
2806 for entry in entries {
2807 let entry = entry.map_err(|e| ExecutionError::IoError {
2808 path: apps_dir.display().to_string(),
2809 message: e.to_string(),
2810 })?;
2811 let ty = entry.file_type().map_err(|e| ExecutionError::IoError {
2812 path: entry.path().display().to_string(),
2813 message: e.to_string(),
2814 })?;
2815 if !ty.is_dir() {
2816 continue;
2817 }
2818 let app_dir = entry.path();
2819 let app_name = app_dir
2820 .file_name()
2821 .and_then(|n| n.to_str())
2822 .map(String::from)
2823 .unwrap_or_default();
2824 if app_name.is_empty() {
2825 continue;
2826 }
2827 let models_path = app_dir.join("models.rs");
2828 if !models_path.is_file() {
2829 continue;
2830 }
2831 let source =
2832 std::fs::read_to_string(&models_path).map_err(|e| ExecutionError::IoError {
2833 path: models_path.display().to_string(),
2834 message: e.to_string(),
2835 })?;
2836 let struct_names = parse_struct_names(&source);
2837 models_files.insert(
2838 app_name,
2839 ParsedModelsFile {
2840 path: models_path,
2841 source,
2842 struct_names,
2843 },
2844 );
2845 }
2846
2847 let mut existing_migrations = Vec::new();
2848 let mut migration_sources: BTreeMap<String, String> = BTreeMap::new();
2849 let entries = std::fs::read_dir(&migrations_dir).map_err(|e| ExecutionError::IoError {
2850 path: migrations_dir.display().to_string(),
2851 message: e.to_string(),
2852 })?;
2853 for entry in entries {
2854 let entry = entry.map_err(|e| ExecutionError::IoError {
2855 path: migrations_dir.display().to_string(),
2856 message: e.to_string(),
2857 })?;
2858 if let Some(name) = entry.file_name().to_str() {
2859 if name.ends_with(".sql") {
2860 let path = entry.path();
2861 let contents =
2862 std::fs::read_to_string(&path).map_err(|e| ExecutionError::IoError {
2863 path: path.display().to_string(),
2864 message: e.to_string(),
2865 })?;
2866 migration_sources.insert(name.to_string(), contents);
2867 existing_migrations.push(name.to_string());
2868 }
2869 }
2870 }
2871 existing_migrations.sort();
2872
2873 Ok(ProjectView {
2874 root: root.to_path_buf(),
2875 models_files,
2876 existing_migrations,
2877 migration_sources,
2878 })
2879 }
2880}
2881
2882fn parse_struct_names(source: &str) -> Vec<String> {
2883 let mut out: Vec<String> = Vec::new();
2884 for line in source.lines() {
2885 let t = line.trim_start();
2886 if let Some(rest) = t.strip_prefix("pub struct ") {
2887 let name: String = rest
2889 .chars()
2890 .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
2891 .collect();
2892 if !name.is_empty() {
2893 out.push(name);
2894 }
2895 }
2896 }
2897 out
2898}
2899
2900pub fn execute_plan_document(
2908 project_root: &Path,
2909 doc: &PlanDocument,
2910 options: &ExecuteOptions,
2911 context: Option<&ContextConfig>,
2912) -> Result<ExecutionResult, ExecutionError> {
2913 let schema_path = project_root.join("rustio.schema.json");
2914 let schema_json =
2915 std::fs::read_to_string(&schema_path).map_err(|e| ExecutionError::IoError {
2916 path: schema_path.display().to_string(),
2917 message: e.to_string(),
2918 })?;
2919 let schema =
2920 Schema::parse(&schema_json).map_err(|e| ExecutionError::ValidationFailed(e.to_string()))?;
2921 let project = ProjectView::from_dir(project_root)?;
2922 let preview = plan_execution(&schema, &project, doc, options, context)?;
2923 commit_changes(&preview)?;
2924 let generated: Vec<String> = preview
2925 .file_changes
2926 .iter()
2927 .map(|c| display_path(project_root, &c.path))
2928 .collect();
2929 Ok(ExecutionResult {
2930 applied_steps: preview.applied_steps,
2931 generated_files: generated,
2932 summary: preview.summary,
2933 })
2934}
2935
2936fn commit_changes(preview: &ExecutionPreview) -> Result<(), ExecutionError> {
2941 for change in &preview.file_changes {
2943 match change.kind {
2944 FileChangeKind::Create => {
2945 if change.path.exists() {
2946 return Err(ExecutionError::FileConflict {
2947 path: change.path.display().to_string(),
2948 reason: "file already exists — refusing to overwrite".to_string(),
2949 });
2950 }
2951 if let Some(parent) = change.path.parent() {
2952 if !parent.is_dir() {
2953 return Err(ExecutionError::ProjectStructure(format!(
2954 "parent directory `{}` does not exist",
2955 parent.display()
2956 )));
2957 }
2958 }
2959 }
2960 FileChangeKind::Update => {
2961 let actual =
2962 std::fs::read_to_string(&change.path).map_err(|e| ExecutionError::IoError {
2963 path: change.path.display().to_string(),
2964 message: e.to_string(),
2965 })?;
2966 if let Some(expected) = &change.expected_current_contents {
2967 if &actual != expected {
2968 return Err(ExecutionError::FileConflict {
2969 path: change.path.display().to_string(),
2970 reason: "file changed on disk after the plan was generated".to_string(),
2971 });
2972 }
2973 }
2974 }
2975 }
2976 }
2977
2978 let mut tmp_paths: Vec<PathBuf> = Vec::with_capacity(preview.file_changes.len());
2980 for change in &preview.file_changes {
2981 let tmp = change.path.with_extension(match change.path.extension() {
2982 Some(e) => format!("{}.rustio_tmp", e.to_string_lossy()),
2983 None => "rustio_tmp".to_string(),
2984 });
2985 if let Err(e) = std::fs::write(&tmp, &change.new_contents) {
2986 cleanup_tmps(&tmp_paths);
2987 return Err(ExecutionError::IoError {
2988 path: tmp.display().to_string(),
2989 message: e.to_string(),
2990 });
2991 }
2992 tmp_paths.push(tmp);
2993 }
2994
2995 let mut renamed: Vec<(PathBuf, Option<String>)> =
2998 Vec::with_capacity(preview.file_changes.len());
2999 for (i, change) in preview.file_changes.iter().enumerate() {
3000 let tmp = &tmp_paths[i];
3001 let original = match change.kind {
3002 FileChangeKind::Update => change.expected_current_contents.clone(),
3003 FileChangeKind::Create => None,
3004 };
3005 if let Err(e) = std::fs::rename(tmp, &change.path) {
3006 rollback_renames(&renamed);
3009 cleanup_tmps(&tmp_paths[i..]);
3010 return Err(ExecutionError::IoError {
3011 path: change.path.display().to_string(),
3012 message: e.to_string(),
3013 });
3014 }
3015 renamed.push((change.path.clone(), original));
3016 }
3017 Ok(())
3018}
3019
3020fn cleanup_tmps(paths: &[PathBuf]) {
3021 for p in paths {
3022 let _ = std::fs::remove_file(p);
3023 }
3024}
3025
3026fn rollback_renames(renamed: &[(PathBuf, Option<String>)]) {
3027 for (path, original) in renamed.iter().rev() {
3028 match original {
3029 Some(contents) => {
3030 let _ = std::fs::write(path, contents);
3031 }
3032 None => {
3033 let _ = std::fs::remove_file(path);
3034 }
3035 }
3036 }
3037}
3038
3039fn display_path(root: &Path, absolute: &Path) -> String {
3040 absolute
3041 .strip_prefix(root)
3042 .ok()
3043 .and_then(|p| p.to_str())
3044 .map(String::from)
3045 .unwrap_or_else(|| absolute.display().to_string())
3046}
3047
3048#[derive(Debug, Clone)]
3055pub struct RetrofitReport {
3056 pub upgraded: Vec<(String, String)>,
3058 pub migrations: Vec<(String, String)>,
3061}
3062
3063pub fn plan_retrofit_foreign_keys(schema: &crate::schema::Schema) -> RetrofitReport {
3073 use crate::schema::{RelationKind, SchemaField};
3074
3075 let mut upgraded = Vec::new();
3076 let mut migrations = Vec::new();
3077
3078 let table_for = |model_name: &str| -> Option<String> {
3082 schema
3083 .models
3084 .iter()
3085 .find(|m| m.name == model_name)
3086 .and_then(|m| {
3087 fallback_table_name(&m.name)
3091 })
3092 };
3093
3094 for model in &schema.models {
3095 let table = match fallback_table_name(&model.name) {
3096 Some(t) => t,
3097 None => continue,
3098 };
3099 let mut touched_this_model = false;
3102 let upgraded_fields: Vec<SchemaField> = model
3103 .fields
3104 .iter()
3105 .map(|f| {
3106 let mut f = f.clone();
3107 if let Some(rel) = f.relation.as_mut() {
3108 if matches!(rel.kind, RelationKind::BelongsTo) && rel.on_delete.is_none() {
3109 rel.on_delete = Some("restrict".to_string());
3110 rel.required = Some(!f.nullable);
3111 upgraded.push((model.name.clone(), f.name.clone()));
3112 touched_this_model = true;
3113 }
3114 }
3115 f
3116 })
3117 .collect();
3118
3119 if !touched_this_model {
3120 continue;
3121 }
3122
3123 let mut sql = String::new();
3125 sql.push_str("-- Generated by `rustio migrate add-fks` (0.9.0).\n");
3126 sql.push_str("-- Review before running: the recreate-table pattern drops and\n");
3127 sql.push_str("-- rebuilds the table. Columns or indexes added outside of\n");
3128 sql.push_str("-- rustio's schema.json will be lost if not reflected here.\n");
3129 sql.push_str("PRAGMA foreign_keys = OFF;\n");
3130 sql.push_str("BEGIN;\n\n");
3131 let new_table = format!("{table}__new");
3132 sql.push_str(&format!("CREATE TABLE {new_table} (\n"));
3133 for (i, f) in upgraded_fields.iter().enumerate() {
3134 sql.push_str(" ");
3135 sql.push_str(&column_def_with_relation_context(f, |target| {
3136 table_for(target)
3137 }));
3138 if i + 1 < upgraded_fields.len() {
3139 sql.push(',');
3140 }
3141 sql.push('\n');
3142 }
3143 sql.push_str(");\n\n");
3144 let col_list = upgraded_fields
3145 .iter()
3146 .map(|f| f.name.clone())
3147 .collect::<Vec<_>>()
3148 .join(", ");
3149 sql.push_str(&format!(
3150 "INSERT INTO {new_table} ({col_list}) SELECT {col_list} FROM {table};\n\n"
3151 ));
3152 sql.push_str(&format!("DROP TABLE {table};\n"));
3153 sql.push_str(&format!("ALTER TABLE {new_table} RENAME TO {table};\n\n"));
3154 sql.push_str("COMMIT;\n");
3155 sql.push_str("PRAGMA foreign_keys = ON;\n");
3156
3157 migrations.push((format!("retrofit_fks_{table}"), sql));
3158 }
3159
3160 RetrofitReport {
3161 upgraded,
3162 migrations,
3163 }
3164}
3165
3166pub fn render_preview_human(preview: &ExecutionPreview, risk: RiskLevel) -> String {
3173 let mut out = String::from("Plan to apply\n\n");
3174 out.push_str("Applying:\n");
3175 for line in preview.summary.lines() {
3180 out.push_str(" ");
3181 out.push_str(line);
3182 out.push('\n');
3183 }
3184 out.push_str("\nFiles to be written:\n");
3185 for change in &preview.file_changes {
3186 let kind = match change.kind {
3187 FileChangeKind::Create => "create",
3188 FileChangeKind::Update => "update",
3189 };
3190 out.push_str(&format!(" - {kind} {}\n", change.path.display()));
3191 }
3192 out.push_str(&format!("\nRisk:\n {}\n", risk.as_str()));
3193 out
3194}