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(
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 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 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 let synthetic = AddField {
570 model: r.from.clone(),
571 field: FieldSpec {
572 name: r.via.clone(),
573 ty: "i64".to_string(),
574 nullable: true, editable: true,
576 },
577 };
578 let (mut changes, _) = apply_add_field(&synthetic, project, shadow, migration_counter)?;
579
580 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 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
651fn 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
671fn 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(¤t, &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 let patched =
734 patch_models_for_remove_field(¤t, &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 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
782fn 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 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(¤t, &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 = ¤t[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(¤t, &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(¤t, &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
906fn 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 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 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(¤t, &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 let patched = patch_models_for_change_field_type(
972 ¤t,
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
1025fn 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 ("i32", "i64") => Some(format!("{col_name}::BIGINT")),
1040 ("i64", "i32") => Some(format!("{col_name}::INTEGER")),
1041 ("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 ("DateTime", "String") => Some(format!("{col_name}::TEXT")),
1047 ("String", "DateTime") => Some(format!("{col_name}::TIMESTAMPTZ")),
1048 ("i32", "String") | ("i64", "String") | ("bool", "String") => {
1050 Some(format!("{col_name}::TEXT"))
1051 }
1052 ("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
1061fn 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(¤t, &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 let patched = patch_models_for_change_nullability(
1116 ¤t,
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 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
1187fn 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 let struct_names = parse_struct_names(¤t);
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(¤t, &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 let patched_models = patch_models_for_rename_model(
1236 ¤t, &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 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 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
1328fn 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
1377fn 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 display_field: None,
1442 required: Some(r.required),
1446 on_delete: Some(r.on_delete.as_str().to_string()),
1447 }),
1448 });
1449 }
1450 }
1451 _ => {}
1452 }
1453}
1454
1455fn 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
1494fn 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 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 out = remove_from_str_array_scoped(&out, struct_name, "COLUMNS", field_name)?;
1522
1523 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 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 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
1557fn 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 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
1612fn 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 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
1643fn 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 let mut slice_start = literal_idx;
1676 let mut slice_end = literal_idx + literal.len();
1677
1678 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 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 }
1696 }
1697 } else {
1698 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 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 } 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 if field.ty == "DateTime" && !has_chrono_use(&out) {
1761 out = insert_chrono_import(&out);
1762 }
1763
1764 let field_line = format!(" pub {}: {},\n", field.name, rust_type);
1766 out = insert_before_struct_close(&out, struct_name, &field_line)?;
1767
1768 out = insert_into_str_array(&out, "COLUMNS", &field.name)?;
1770 if out.contains("const INSERT_COLUMNS") {
1773 out = insert_into_str_array(&out, "INSERT_COLUMNS", &field.name)?;
1774 }
1775
1776 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 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 out = rename_in_struct(&out, struct_name, from, to)?;
1802
1803 out = replace_in_str_array(&out, "COLUMNS", from, to)?;
1805 if out.contains("const INSERT_COLUMNS") {
1806 out = replace_in_str_array(&out, "INSERT_COLUMNS", from, to).unwrap_or(out);
1809 }
1810
1811 out = rename_in_from_row(&out, from, to)?;
1813
1814 out = rename_in_insert_values(&out, from, to)?;
1816
1817 Ok(out)
1818}
1819
1820fn 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 if (new_ty == "DateTime") && !has_chrono_use(&out) {
1834 out = insert_chrono_import(&out);
1835 }
1836 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 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 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
1867fn 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
1898fn 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
1930fn 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
1954fn 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 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 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 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 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; 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 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; 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 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 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 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 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 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
2341fn 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 let mut last_use_end: Option<usize> = None;
2352 for (idx, line) in src.match_indices('\n') {
2353 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; }
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
2374fn 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 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
2422fn 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 let impl_anchor = format!("impl Model for {struct_name}");
2460 let slice = if let Some(impl_start) = src.find(&impl_anchor) {
2461 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 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
2479fn 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 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
2538fn 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
2553fn 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
2567impl ProjectView {
2572 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 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
2692pub 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
2728fn commit_changes(preview: &ExecutionPreview) -> Result<(), ExecutionError> {
2733 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 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 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 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#[derive(Debug, Clone)]
2847pub struct RetrofitReport {
2848 pub upgraded: Vec<(String, String)>,
2850 pub migrations: Vec<(String, String)>,
2853}
2854
2855pub 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 let mut to_retrofit: Vec<(String, String, String)> = Vec::new(); 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(); 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 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
2936pub 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 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}