use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use super::planner::ContextConfig;
use super::review::{
review_plan, PlanDocument, RiskLevel, ValidationOutcome, PLAN_DOCUMENT_VERSION,
};
use super::{
AddField, AddRelation, ChangeFieldNullability, ChangeFieldType, FieldSpec, Primitive,
RemoveField, RemoveRelation, RenameField, RenameModel,
};
use crate::schema::{Schema, SchemaField};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExecutionResult {
pub applied_steps: usize,
pub generated_files: Vec<String>,
pub summary: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExecutionPreview {
pub applied_steps: usize,
pub file_changes: Vec<PlannedFileChange>,
pub summary: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlannedFileChange {
pub path: PathBuf,
pub kind: FileChangeKind,
pub new_contents: String,
pub expected_current_contents: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileChangeKind {
Create,
Update,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ExecuteOptions {
pub allow_destructive: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectView {
pub root: PathBuf,
pub models_files: BTreeMap<String, ParsedModelsFile>,
pub existing_migrations: Vec<String>,
pub migration_sources: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedModelsFile {
pub path: PathBuf,
pub source: String,
pub struct_names: Vec<String>,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExecutionError {
ValidationFailed(String),
CriticalRiskNotAllowed,
DeveloperOnlyForbidden,
SchemaMismatch(String),
FileConflict { path: String, reason: String },
UnsupportedPrimitive {
op: &'static str,
reason: &'static str,
},
DestructiveWithoutConfirmation { op: &'static str },
ProjectStructure(String),
IoError { path: String, message: String },
PolicyViolation { reason: String },
}
impl std::fmt::Display for ExecutionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ValidationFailed(msg) => write!(f, "plan failed validation: {msg}"),
Self::CriticalRiskNotAllowed => write!(
f,
"plan risk is Critical — the safe executor refuses to apply it"
),
Self::DeveloperOnlyForbidden => write!(
f,
"plan contains a developer-only primitive — the safe executor refuses to apply it"
),
Self::SchemaMismatch(msg) => write!(f, "plan is stale against the current schema: {msg}"),
Self::FileConflict { path, reason } => {
write!(f, "refusing to write `{path}`: {reason}")
}
Self::UnsupportedPrimitive { op, reason } => write!(
f,
"primitive `{op}` is not supported by the 0.5.2 safe executor: {reason}"
),
Self::DestructiveWithoutConfirmation { op } => write!(
f,
"primitive `{op}` is destructive — re-run `rustio ai apply` with `--force` to open the destructive gate"
),
Self::ProjectStructure(msg) => write!(f, "project layout: {msg}"),
Self::IoError { path, message } => {
write!(f, "i/o error on `{path}`: {message}")
}
Self::PolicyViolation { reason } => {
write!(f, "policy violation: {reason}")
}
}
}
}
impl std::error::Error for ExecutionError {}
pub fn plan_execution(
schema: &Schema,
project: &ProjectView,
doc: &PlanDocument,
options: &ExecuteOptions,
context: Option<&ContextConfig>,
) -> Result<ExecutionPreview, ExecutionError> {
if doc.version != PLAN_DOCUMENT_VERSION {
return Err(ExecutionError::ValidationFailed(format!(
"document version {} is not supported (this build reads version {})",
doc.version, PLAN_DOCUMENT_VERSION
)));
}
let review = review_plan(schema, &doc.plan, context)
.map_err(|e| ExecutionError::ValidationFailed(e.to_string()))?;
match &review.validation {
ValidationOutcome::Valid => {}
ValidationOutcome::Invalid { step, reason } => {
return Err(ExecutionError::SchemaMismatch(format!(
"plan invalid at step {step}: {reason}"
)));
}
}
if review.risk == RiskLevel::Critical {
return Err(ExecutionError::CriticalRiskNotAllowed);
}
for step in &doc.plan.steps {
if step.is_developer_only() {
return Err(ExecutionError::DeveloperOnlyForbidden);
}
}
if let Some(ctx) = context {
let pii = ctx.pii_fields();
for step in &doc.plan.steps {
if let Some(reason) = policy_violation_for(step, &pii, ctx) {
return Err(ExecutionError::PolicyViolation { reason });
}
}
}
let mut shadow: BTreeMap<String, String> = project
.models_files
.iter()
.map(|(app, file)| (app.clone(), file.source.clone()))
.collect();
let mut migration_counter = next_migration_number(&project.existing_migrations);
let mut file_changes: Vec<PlannedFileChange> = Vec::new();
let mut summary_lines: Vec<String> = Vec::new();
let mut schema_shadow = schema.clone();
for step in &doc.plan.steps {
let (mut new_changes, one_line) = simulate_step(
step,
project,
&mut shadow,
&mut migration_counter,
&schema_shadow,
options,
)?;
file_changes.append(&mut new_changes);
summary_lines.push(one_line);
apply_schema_shadow(step, &mut schema_shadow);
}
file_changes = collapse_duplicate_updates(file_changes);
Ok(ExecutionPreview {
applied_steps: doc.plan.steps.len(),
file_changes,
summary: summary_lines.join("\n"),
})
}
fn simulate_step(
step: &Primitive,
project: &ProjectView,
shadow: &mut BTreeMap<String, String>,
migration_counter: &mut u32,
schema: &Schema,
opts: &ExecuteOptions,
) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
match step {
Primitive::AddField(a) => apply_add_field(a, project, shadow, migration_counter),
Primitive::RenameField(r) => apply_rename_field(r, project, shadow, migration_counter),
Primitive::ChangeFieldType(c) => {
apply_change_field_type(c, schema, project, shadow, migration_counter)
}
Primitive::ChangeFieldNullability(c) => {
apply_change_field_nullability(c, schema, project, shadow, migration_counter)
}
Primitive::RenameModel(r) => apply_rename_model(r, project, shadow, migration_counter),
Primitive::AddModel(_) => Err(ExecutionError::UnsupportedPrimitive {
op: "add_model",
reason:
"model scaffolding lives with `rustio new app`; use that then let the AI add fields",
}),
Primitive::RemoveModel(_) => Err(ExecutionError::UnsupportedPrimitive {
op: "remove_model",
reason: "dropping a model + its admin registration + downstream FKs is scheduled for 0.9.2; use `rustio new app` / manual removal for now",
}),
Primitive::RemoveField(r) => {
if !opts.allow_destructive {
return Err(ExecutionError::DestructiveWithoutConfirmation {
op: "remove_field",
});
}
apply_remove_field(r, schema, project, shadow, migration_counter)
}
Primitive::AddRelation(r) => apply_add_relation(r, project, shadow, migration_counter),
Primitive::RemoveRelation(r) => {
if !opts.allow_destructive {
return Err(ExecutionError::DestructiveWithoutConfirmation {
op: "remove_relation",
});
}
apply_remove_relation(r, schema, project, shadow, migration_counter)
}
Primitive::UpdateAdmin(_) => Err(ExecutionError::UnsupportedPrimitive {
op: "update_admin",
reason: "admin-attribute edits are out of scope for 0.5.2",
}),
Primitive::CreateMigration(_) => Err(ExecutionError::DeveloperOnlyForbidden),
}
}
fn collapse_duplicate_updates(changes: Vec<PlannedFileChange>) -> Vec<PlannedFileChange> {
let mut out: Vec<PlannedFileChange> = Vec::with_capacity(changes.len());
for c in changes {
if let Some(existing) = out.iter_mut().rev().find(|e| {
e.path == c.path && e.kind == FileChangeKind::Update && c.kind == FileChangeKind::Update
}) {
existing.new_contents = c.new_contents;
continue;
}
out.push(c);
}
out
}
fn apply_add_field(
a: &AddField,
project: &ProjectView,
shadow: &mut BTreeMap<String, String>,
migration_counter: &mut u32,
) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
let (app, initial_source) = locate_model_file(project, &a.model)?;
let current = shadow
.get(&app)
.cloned()
.unwrap_or_else(|| initial_source.clone());
let struct_bounds = find_struct_block(¤t, &a.model).ok_or_else(|| {
ExecutionError::ProjectStructure(format!(
"apps/{app}/models.rs does not declare `pub struct {}`",
a.model
))
})?;
let inside_struct = ¤t[struct_bounds.0..=struct_bounds.1];
if struct_declares_field(inside_struct, &a.field.name) {
return Err(ExecutionError::FileConflict {
path: format!("apps/{app}/models.rs"),
reason: format!(
"struct {} already declares field `{}`; the plan appears to have been applied already",
a.model, a.field.name,
),
});
}
let patched = patch_models_for_add_field(¤t, &a.model, &a.field).map_err(|msg| {
ExecutionError::FileConflict {
path: format!("apps/{app}/models.rs"),
reason: msg,
}
})?;
shadow.insert(app.clone(), patched.clone());
let table = find_table_for_struct(¤t, &a.model)
.or_else(|| fallback_table_name(&a.model))
.ok_or_else(|| {
ExecutionError::ProjectStructure(format!(
"could not find `const TABLE` for struct `{}`",
a.model
))
})?;
let sql = sql_for_add_field(&table, &a.field);
let mig_name = format!("add_{}_to_{}", a.field.name, table);
let (mig_path, mig_filename) = new_migration_path(project, *migration_counter, &mig_name);
*migration_counter += 1;
let file_path = project.root.join("apps").join(&app).join("models.rs");
Ok((
vec![
PlannedFileChange {
path: file_path,
kind: FileChangeKind::Update,
new_contents: patched,
expected_current_contents: Some(initial_source),
},
PlannedFileChange {
path: mig_path,
kind: FileChangeKind::Create,
new_contents: sql,
expected_current_contents: None,
},
],
format!(
"+ Add field \"{}\" ({}{}) to model \"{}\" (migration {})",
a.field.name,
a.field.ty,
if a.field.nullable { ", nullable" } else { "" },
a.model,
mig_filename,
),
))
}
fn apply_add_relation(
r: &AddRelation,
project: &ProjectView,
shadow: &mut BTreeMap<String, String>,
migration_counter: &mut u32,
) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
use crate::ai::OnDelete;
use crate::schema::RelationKind;
match r.kind {
RelationKind::BelongsTo => {}
_ => {
return Err(ExecutionError::UnsupportedPrimitive {
op: "add_relation",
reason: "only `belongs_to` is materialised in 0.9.0 — `has_many` is a virtual accessor with no column change",
});
}
}
if r.required {
return Err(ExecutionError::UnsupportedPrimitive {
op: "add_relation",
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",
});
}
if matches!(r.on_delete, OnDelete::SetNull) && r.required {
return Err(ExecutionError::UnsupportedPrimitive {
op: "add_relation",
reason: "`on_delete: set_null` requires a nullable FK column",
});
}
let synthetic = AddField {
model: r.from.clone(),
field: FieldSpec {
name: r.via.clone(),
ty: "i64".to_string(),
nullable: true, editable: true,
},
};
let (mut changes, _) = apply_add_field(&synthetic, project, shadow, migration_counter)?;
let (child_app, _) = locate_model_file(project, &r.from)?;
let child_src = shadow
.get(&child_app)
.cloned()
.unwrap_or_else(|| project.models_files[&child_app].source.clone());
let child_table = find_table_for_struct(&child_src, &r.from)
.or_else(|| fallback_table_name(&r.from))
.ok_or_else(|| {
ExecutionError::ProjectStructure(format!(
"could not find `const TABLE` for child struct `{}`",
r.from
))
})?;
let parent_table = match locate_model_file(project, &r.to) {
Ok((parent_app, parent_source)) => {
let parent_src = shadow.get(&parent_app).cloned().unwrap_or(parent_source);
find_table_for_struct(&parent_src, &r.to)
.or_else(|| fallback_table_name(&r.to))
.ok_or_else(|| {
ExecutionError::ProjectStructure(format!(
"could not find `const TABLE` for parent struct `{}`",
r.to
))
})?
}
Err(_) => fallback_table_name(&r.to).ok_or_else(|| {
ExecutionError::ProjectStructure(format!(
"could not derive a table name for parent struct `{}`",
r.to
))
})?,
};
let fk_sql = sql_for_add_fk_column(&child_table, &r.via, &parent_table, r.on_delete);
let mig_filename = {
let create = changes
.iter_mut()
.find(|c| c.kind == FileChangeKind::Create)
.expect("apply_add_field always plans a Create for the migration");
create.new_contents = fk_sql;
create
.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string()
};
Ok((
changes,
format!(
"+ Add relation `{}` from \"{}\" to \"{}\" (belongs_to → {}, {}, migration {})",
r.via,
r.from,
r.to,
parent_table,
r.on_delete.as_str(),
mig_filename,
),
))
}
fn sql_for_add_fk_column(
child_table: &str,
via: &str,
parent_table: &str,
on_delete: crate::ai::OnDelete,
) -> String {
format!(
"-- Generated by rustio ai apply. DO NOT EDIT.\n\
ALTER TABLE {child} ADD COLUMN {via} BIGINT REFERENCES {parent}(id) {policy};\n",
child = child_table,
via = via,
parent = parent_table,
policy = on_delete.sql(),
)
}
fn apply_remove_field(
r: &RemoveField,
schema: &Schema,
project: &ProjectView,
shadow: &mut BTreeMap<String, String>,
migration_counter: &mut u32,
) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
let model = schema
.models
.iter()
.find(|m| m.name == r.model)
.ok_or_else(|| {
ExecutionError::SchemaMismatch(format!("model `{}` not in schema", r.model))
})?;
let field = model
.fields
.iter()
.find(|f| f.name == r.field)
.ok_or_else(|| {
ExecutionError::SchemaMismatch(format!("field `{}.{}` not in schema", r.model, r.field))
})?;
if r.field == "id" {
return Err(ExecutionError::UnsupportedPrimitive {
op: "remove_field",
reason: "cannot drop the `id` primary key; remove the model instead",
});
}
let (app, initial_source) = locate_model_file(project, &r.model)?;
let current = shadow
.get(&app)
.cloned()
.unwrap_or_else(|| initial_source.clone());
let table = find_table_for_struct(¤t, &r.model)
.or_else(|| fallback_table_name(&r.model))
.ok_or_else(|| {
ExecutionError::ProjectStructure(format!(
"could not find `const TABLE` for struct `{}`",
r.model
))
})?;
let patched =
patch_models_for_remove_field(¤t, &r.model, &r.field, &field.ty, field.nullable)
.map_err(|msg| ExecutionError::FileConflict {
path: format!("apps/{app}/models.rs"),
reason: msg,
})?;
shadow.insert(app.clone(), patched.clone());
let sql = format!(
"-- Generated by rustio ai apply. DO NOT EDIT.\n\
ALTER TABLE {table} DROP COLUMN {field} CASCADE;\n",
field = r.field,
);
let mig_name = format!("drop_{}_from_{}", r.field, table);
let (mig_path, mig_filename) = new_migration_path(project, *migration_counter, &mig_name);
*migration_counter += 1;
let file_path = project.root.join("apps").join(&app).join("models.rs");
let warn_line = format!(
" ⚠ Drops column `{}` from `{table}` (CASCADE — kills dependent FKs/indexes).",
r.field
);
Ok((
vec![
PlannedFileChange {
path: file_path,
kind: FileChangeKind::Update,
new_contents: patched,
expected_current_contents: Some(initial_source),
},
PlannedFileChange {
path: mig_path,
kind: FileChangeKind::Create,
new_contents: sql,
expected_current_contents: None,
},
],
format!(
"- Remove field `{}.{}` (migration {})\n{}",
r.model, r.field, mig_filename, warn_line,
),
))
}
fn apply_remove_relation(
r: &RemoveRelation,
schema: &Schema,
project: &ProjectView,
shadow: &mut BTreeMap<String, String>,
migration_counter: &mut u32,
) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
let synthetic = RemoveField {
model: r.from.clone(),
field: r.via.clone(),
};
let (changes, _) = apply_remove_field(&synthetic, schema, project, shadow, migration_counter)?;
let mig_filename = changes
.iter()
.find(|c| c.kind == FileChangeKind::Create)
.and_then(|c| c.path.file_name())
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
Ok((
changes,
format!(
"- Remove relation `{}.{}` (migration {})",
r.from, r.via, mig_filename,
),
))
}
fn apply_rename_field(
r: &RenameField,
project: &ProjectView,
shadow: &mut BTreeMap<String, String>,
migration_counter: &mut u32,
) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
let (app, initial_source) = locate_model_file(project, &r.model)?;
let current = shadow
.get(&app)
.cloned()
.unwrap_or_else(|| initial_source.clone());
let struct_bounds = find_struct_block(¤t, &r.model).ok_or_else(|| {
ExecutionError::ProjectStructure(format!(
"apps/{app}/models.rs does not declare `pub struct {}`",
r.model
))
})?;
let inside_struct = ¤t[struct_bounds.0..=struct_bounds.1];
if !struct_declares_field(inside_struct, &r.from) {
return Err(ExecutionError::FileConflict {
path: format!("apps/{app}/models.rs"),
reason: format!(
"struct {} does not declare `pub {}: …`; rename cannot proceed",
r.model, r.from,
),
});
}
if struct_declares_field(inside_struct, &r.to) {
return Err(ExecutionError::FileConflict {
path: format!("apps/{app}/models.rs"),
reason: format!(
"struct {} already has a field called `{}`; rename target is taken",
r.model, r.to,
),
});
}
let patched =
patch_models_for_rename_field(¤t, &r.model, &r.from, &r.to).map_err(|msg| {
ExecutionError::FileConflict {
path: format!("apps/{app}/models.rs"),
reason: msg,
}
})?;
shadow.insert(app.clone(), patched.clone());
let table = find_table_for_struct(¤t, &r.model)
.or_else(|| fallback_table_name(&r.model))
.ok_or_else(|| {
ExecutionError::ProjectStructure(format!(
"could not find `const TABLE` for struct `{}`",
r.model
))
})?;
let sql = format!(
"-- Generated by rustio ai apply (0.5.2). DO NOT EDIT.\n\
ALTER TABLE {table} RENAME COLUMN {from} TO {to};\n",
from = r.from,
to = r.to,
);
let mig_name = format!("rename_{}_to_{}_on_{}", r.from, r.to, table);
let (mig_path, mig_filename) = new_migration_path(project, *migration_counter, &mig_name);
*migration_counter += 1;
let file_path = project.root.join("apps").join(&app).join("models.rs");
Ok((
vec![
PlannedFileChange {
path: file_path,
kind: FileChangeKind::Update,
new_contents: patched,
expected_current_contents: Some(initial_source),
},
PlannedFileChange {
path: mig_path,
kind: FileChangeKind::Create,
new_contents: sql,
expected_current_contents: None,
},
],
format!(
"~ Rename field \"{}.{}\" to \"{}\" (migration {})",
r.model, r.from, r.to, mig_filename
),
))
}
fn apply_change_field_type(
c: &ChangeFieldType,
schema: &Schema,
project: &ProjectView,
shadow: &mut BTreeMap<String, String>,
migration_counter: &mut u32,
) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
let model = schema
.models
.iter()
.find(|m| m.name == c.model)
.ok_or_else(|| {
ExecutionError::SchemaMismatch(format!("model `{}` not in schema", c.model))
})?;
let field = model
.fields
.iter()
.find(|f| f.name == c.field)
.ok_or_else(|| {
ExecutionError::SchemaMismatch(format!("field `{}.{}` not in schema", c.model, c.field))
})?;
if field.ty == c.new_type {
return Err(ExecutionError::FileConflict {
path: format!("apps/?/{}.rs", c.model.to_lowercase()),
reason: format!(
"field `{}.{}` already has type `{}`; change appears applied",
c.model, c.field, c.new_type,
),
});
}
let cast_expr = cast_expression(&field.ty, &c.new_type, &c.field).ok_or(
ExecutionError::UnsupportedPrimitive {
op: "change_field_type",
reason: "this type conversion is not in the 0.5.3 safe-cast set",
},
)?;
let (app, initial_source) = locate_model_file(project, &c.model)?;
let current = shadow
.get(&app)
.cloned()
.unwrap_or_else(|| initial_source.clone());
let table = find_table_for_struct(¤t, &c.model)
.or_else(|| fallback_table_name(&c.model))
.ok_or_else(|| {
ExecutionError::ProjectStructure(format!(
"could not find `const TABLE` for struct `{}`",
c.model
))
})?;
let patched = patch_models_for_change_field_type(
¤t,
&c.model,
&c.field,
&field.ty,
&c.new_type,
field.nullable,
)
.map_err(|msg| ExecutionError::FileConflict {
path: format!("apps/{app}/models.rs"),
reason: msg,
})?;
shadow.insert(app.clone(), patched.clone());
let new_sql_type = sql_type_for(&c.new_type);
let sql = format!(
"-- Generated by rustio ai apply. DO NOT EDIT.\n\
ALTER TABLE {table} ALTER COLUMN {field} TYPE {new_sql_type} USING ({cast});\n",
field = c.field,
cast = cast_expr,
);
let mig_name = format!("change_{}_type_on_{}", c.field, table);
let (mig_path, mig_filename) = new_migration_path(project, *migration_counter, &mig_name);
*migration_counter += 1;
let file_path = project.root.join("apps").join(&app).join("models.rs");
let warn_line = format!(
" ⚠ Rewrites every row of `{table}.{}` in place. PG will refuse the \
migration if a stored value violates a dependent FK or check.",
c.field,
);
Ok((
vec![
PlannedFileChange {
path: file_path,
kind: FileChangeKind::Update,
new_contents: patched,
expected_current_contents: Some(initial_source),
},
PlannedFileChange {
path: mig_path,
kind: FileChangeKind::Create,
new_contents: sql,
expected_current_contents: None,
},
],
format!(
"~ Change type of {}.{} from {} to {} (migration {})\n{}",
c.model, c.field, field.ty, c.new_type, mig_filename, warn_line,
),
))
}
fn cast_expression(old_ty: &str, new_ty: &str, col_name: &str) -> Option<String> {
match (old_ty, new_ty) {
(a, b) if a == b => None,
("i32", "i64") => Some(format!("{col_name}::BIGINT")),
("i64", "i32") => Some(format!("{col_name}::INTEGER")),
("bool", "i32") => Some(format!("{col_name}::INTEGER")),
("bool", "i64") => Some(format!("{col_name}::BIGINT")),
("i32", "bool") | ("i64", "bool") => Some(format!("{col_name}::BOOLEAN")),
("DateTime", "String") => Some(format!("{col_name}::TEXT")),
("String", "DateTime") => Some(format!("{col_name}::TIMESTAMPTZ")),
("i32", "String") | ("i64", "String") | ("bool", "String") => {
Some(format!("{col_name}::TEXT"))
}
("String", "i32") => Some(format!("{col_name}::INTEGER")),
("String", "i64") => Some(format!("{col_name}::BIGINT")),
("String", "bool") => Some(format!("{col_name}::BOOLEAN")),
_ => None,
}
}
fn apply_change_field_nullability(
c: &ChangeFieldNullability,
schema: &Schema,
project: &ProjectView,
shadow: &mut BTreeMap<String, String>,
migration_counter: &mut u32,
) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
let model = schema
.models
.iter()
.find(|m| m.name == c.model)
.ok_or_else(|| {
ExecutionError::SchemaMismatch(format!("model `{}` not in schema", c.model))
})?;
let field = model
.fields
.iter()
.find(|f| f.name == c.field)
.ok_or_else(|| {
ExecutionError::SchemaMismatch(format!("field `{}.{}` not in schema", c.model, c.field))
})?;
if field.nullable == c.nullable {
return Err(ExecutionError::FileConflict {
path: format!("apps/?/{}.rs", c.model.to_lowercase()),
reason: format!(
"field `{}.{}` is already {}; change appears applied",
c.model,
c.field,
if c.nullable { "nullable" } else { "required" }
),
});
}
let (app, initial_source) = locate_model_file(project, &c.model)?;
let current = shadow
.get(&app)
.cloned()
.unwrap_or_else(|| initial_source.clone());
let table = find_table_for_struct(¤t, &c.model)
.or_else(|| fallback_table_name(&c.model))
.ok_or_else(|| {
ExecutionError::ProjectStructure(format!(
"could not find `const TABLE` for struct `{}`",
c.model
))
})?;
let patched = patch_models_for_change_nullability(
¤t,
&c.model,
&c.field,
&field.ty,
field.nullable,
c.nullable,
)
.map_err(|msg| ExecutionError::FileConflict {
path: format!("apps/{app}/models.rs"),
reason: msg,
})?;
shadow.insert(app.clone(), patched.clone());
let tightening = !c.nullable && field.nullable;
let sql = if tightening {
let dflt = safe_default_literal(&field.ty);
format!(
"-- Generated by rustio ai apply. DO NOT EDIT.\n\
UPDATE {table} SET {field} = {dflt} WHERE {field} IS NULL;\n\
ALTER TABLE {table} ALTER COLUMN {field} SET NOT NULL;\n",
field = c.field,
)
} else {
format!(
"-- Generated by rustio ai apply. DO NOT EDIT.\n\
ALTER TABLE {table} ALTER COLUMN {field} DROP NOT NULL;\n",
field = c.field,
)
};
let mig_name = format!("change_{}_nullability_on_{}", c.field, table);
let (mig_path, mig_filename) = new_migration_path(project, *migration_counter, &mig_name);
*migration_counter += 1;
let state = if c.nullable { "nullable" } else { "required" };
let warn_line = if tightening {
format!(
" ⚠ Backfills existing NULLs in `{table}.{}` with the type default ({}) before adding NOT NULL.",
c.field,
safe_default_literal(&field.ty),
)
} else {
format!(" ⚠ Drops the NOT NULL constraint on `{table}.{}`.", c.field)
};
let file_path = project.root.join("apps").join(&app).join("models.rs");
Ok((
vec![
PlannedFileChange {
path: file_path,
kind: FileChangeKind::Update,
new_contents: patched,
expected_current_contents: Some(initial_source),
},
PlannedFileChange {
path: mig_path,
kind: FileChangeKind::Create,
new_contents: sql,
expected_current_contents: None,
},
],
format!(
"~ Mark {}.{} as {} (migration {})\n{}",
c.model, c.field, state, mig_filename, warn_line
),
))
}
fn apply_rename_model(
r: &RenameModel,
project: &ProjectView,
shadow: &mut BTreeMap<String, String>,
migration_counter: &mut u32,
) -> Result<(Vec<PlannedFileChange>, String), ExecutionError> {
let (app, initial_source) = locate_model_file(project, &r.from)?;
let current = shadow
.get(&app)
.cloned()
.unwrap_or_else(|| initial_source.clone());
let struct_names = parse_struct_names(¤t);
if struct_names.iter().any(|n| n == &r.to) {
return Err(ExecutionError::FileConflict {
path: format!("apps/{app}/models.rs"),
reason: format!(
"struct `{}` already exists in this file; rename appears applied",
r.to
),
});
}
if !struct_names.iter().any(|n| n == &r.from) {
return Err(ExecutionError::FileConflict {
path: format!("apps/{app}/models.rs"),
reason: format!("struct `{}` not found — nothing to rename", r.from),
});
}
let old_table = find_table_for_struct(¤t, &r.from)
.or_else(|| fallback_table_name(&r.from))
.ok_or_else(|| {
ExecutionError::ProjectStructure(format!(
"could not find `const TABLE` for struct `{}`",
r.from
))
})?;
let new_table = fallback_table_name(&r.to).unwrap_or_else(|| old_table.clone());
let patched_models = patch_models_for_rename_model(
¤t, &r.from, &r.to, &old_table, &new_table,
)
.map_err(|msg| ExecutionError::FileConflict {
path: format!("apps/{app}/models.rs"),
reason: msg,
})?;
shadow.insert(app.clone(), patched_models.clone());
let admin_path = project.root.join("apps").join(&app).join("admin.rs");
let admin_source =
std::fs::read_to_string(&admin_path).map_err(|e| ExecutionError::IoError {
path: admin_path.display().to_string(),
message: e.to_string(),
})?;
let admin_patched =
patch_admin_for_rename_model(&admin_source, &r.from, &r.to).map_err(|msg| {
ExecutionError::FileConflict {
path: admin_path.display().to_string(),
reason: msg,
}
})?;
let views_path = project.root.join("apps").join(&app).join("views.rs");
let views_change: Option<PlannedFileChange> = if views_path.is_file() {
let views_source =
std::fs::read_to_string(&views_path).map_err(|e| ExecutionError::IoError {
path: views_path.display().to_string(),
message: e.to_string(),
})?;
let patched_views = rename_identifier_bounded(&views_source, &r.from, &r.to);
if patched_views != views_source {
Some(PlannedFileChange {
path: views_path,
kind: FileChangeKind::Update,
new_contents: patched_views,
expected_current_contents: Some(views_source),
})
} else {
None
}
} else {
None
};
let sql = format!(
"-- Generated by rustio ai apply (0.5.3). DO NOT EDIT.\n\
ALTER TABLE {old_table} RENAME TO {new_table};\n"
);
let mig_name = format!("rename_{old_table}_to_{new_table}");
let (mig_path, mig_filename) = new_migration_path(project, *migration_counter, &mig_name);
*migration_counter += 1;
let mut changes: Vec<PlannedFileChange> = vec![
PlannedFileChange {
path: project.root.join("apps").join(&app).join("models.rs"),
kind: FileChangeKind::Update,
new_contents: patched_models,
expected_current_contents: Some(initial_source),
},
PlannedFileChange {
path: admin_path,
kind: FileChangeKind::Update,
new_contents: admin_patched,
expected_current_contents: Some(admin_source),
},
];
if let Some(vc) = views_change {
changes.push(vc);
}
changes.push(PlannedFileChange {
path: mig_path,
kind: FileChangeKind::Create,
new_contents: sql,
expected_current_contents: None,
});
Ok((
changes,
format!(
"~ Rename model \"{from}\" to \"{to}\" (migration {mig})\n\
\x20 ⚠ Table renamed from `{old_table}` to `{new_table}`. User code using `{from}` outside apps/{app}/ must be updated manually.",
from = r.from,
to = r.to,
mig = mig_filename,
),
))
}
fn rename_identifier_bounded(src: &str, from: &str, to: &str) -> String {
let bytes = src.as_bytes();
let from_bytes = from.as_bytes();
let n = from_bytes.len();
if n == 0 {
return src.to_string();
}
let mut out = String::with_capacity(src.len());
let mut i = 0;
let mut last = 0;
while i + n <= bytes.len() {
if &bytes[i..i + n] == from_bytes {
let left_ok = i == 0 || !is_ident_byte(bytes[i - 1]);
let right_ok = i + n == bytes.len() || !is_ident_byte(bytes[i + n]);
if left_ok && right_ok {
out.push_str(&src[last..i]);
out.push_str(to);
i += n;
last = i;
continue;
}
}
i += 1;
}
out.push_str(&src[last..]);
out
}
fn is_ident_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
fn apply_schema_shadow(p: &Primitive, schema: &mut Schema) {
match p {
Primitive::AddField(a) => {
if let Some(m) = schema.models.iter_mut().find(|m| m.name == a.model) {
m.fields.push(SchemaField {
name: a.field.name.clone(),
ty: a.field.ty.clone(),
nullable: a.field.nullable,
editable: a.field.editable,
relation: None,
});
}
}
Primitive::RenameField(r) => {
if let Some(m) = schema.models.iter_mut().find(|m| m.name == r.model) {
if let Some(f) = m.fields.iter_mut().find(|f| f.name == r.from) {
f.name = r.to.clone();
}
}
}
Primitive::ChangeFieldType(c) => {
if let Some(m) = schema.models.iter_mut().find(|m| m.name == c.model) {
if let Some(f) = m.fields.iter_mut().find(|f| f.name == c.field) {
f.ty = c.new_type.clone();
}
}
}
Primitive::ChangeFieldNullability(c) => {
if let Some(m) = schema.models.iter_mut().find(|m| m.name == c.model) {
if let Some(f) = m.fields.iter_mut().find(|f| f.name == c.field) {
f.nullable = c.nullable;
}
}
}
Primitive::RenameModel(r) => {
if let Some(m) = schema.models.iter_mut().find(|m| m.name == r.from) {
m.name = r.to.clone();
}
}
Primitive::AddRelation(r) => {
use crate::schema::{Relation, RelationKind};
if !matches!(r.kind, RelationKind::BelongsTo) {
return;
}
if let Some(m) = schema.models.iter_mut().find(|m| m.name == r.from) {
if m.fields.iter().any(|f| f.name == r.via) {
return;
}
m.fields.push(SchemaField {
name: r.via.clone(),
ty: "i64".to_string(),
nullable: !r.required,
editable: true,
relation: Some(Relation {
model: r.to.clone(),
field: "id".to_string(),
kind: RelationKind::BelongsTo,
display_field: None,
required: Some(r.required),
on_delete: Some(r.on_delete.as_str().to_string()),
}),
});
}
}
_ => {}
}
}
fn policy_violation_for(step: &Primitive, pii: &[&str], ctx: &ContextConfig) -> Option<String> {
let ctx_tag = {
let mut parts: Vec<String> = Vec::new();
if let Some(c) = &ctx.country {
parts.push(format!("country={c}"));
}
if let Some(i) = &ctx.industry {
parts.push(format!("industry={i}"));
}
if ctx.requires_gdpr() {
parts.push("GDPR".to_string());
}
if parts.is_empty() {
String::new()
} else {
format!(" ({})", parts.join(", "))
}
};
match step {
Primitive::RemoveField(r) if pii.iter().any(|p| *p == r.field) => Some(format!(
"refusing to remove `{}.{}` — it is personally-identifying data under the project context{}. Change the context or update the plan by hand.",
r.model, r.field, ctx_tag,
)),
Primitive::ChangeFieldType(c) if pii.iter().any(|p| *p == c.field) => Some(format!(
"refusing to change the type of `{}.{}` — it is personally-identifying data under the project context{}; retention / hashing pipelines depend on the stored shape.",
c.model, c.field, ctx_tag,
)),
Primitive::RenameField(r) if pii.iter().any(|p| *p == r.from) => Some(format!(
"refusing to rename `{}.{}` — it is personally-identifying data under the project context{}; audit trails keyed on the old name would break.",
r.model, r.from, ctx_tag,
)),
_ => None,
}
}
fn patch_models_for_remove_field(
source: &str,
struct_name: &str,
field_name: &str,
field_ty: &str,
nullable: bool,
) -> Result<String, String> {
let mut out = source.to_string();
let rust_type = rust_type_for(field_ty, nullable);
let struct_line = format!(" pub {field_name}: {rust_type},\n");
out = replace_in_struct_literal(&out, struct_name, &struct_line, "")?;
out = remove_from_str_array_scoped(&out, struct_name, "COLUMNS", field_name)?;
if out.contains("const INSERT_COLUMNS") {
out = remove_from_str_array_scoped(&out, struct_name, "INSERT_COLUMNS", field_name)
.unwrap_or(out);
}
let accessor = row_accessor(field_ty, nullable);
let from_row_line = format!(" {field_name}: row.{accessor}(\"{field_name}\")?,\n",);
out = replace_in_impl_method_literal(
&out,
struct_name,
"fn from_row(",
"Ok(Self {",
&from_row_line,
"",
)?;
let insert_line = build_insert_values_line(field_name, field_ty, nullable);
out = replace_in_impl_method_literal(
&out,
struct_name,
"fn insert_values(",
"vec![",
&insert_line,
"",
)?;
Ok(out)
}
fn replace_in_impl_method_literal(
src: &str,
struct_name: &str,
fn_anchor: &str,
body_open: &str,
from: &str,
to: &str,
) -> Result<String, String> {
let impl_anchor = format!("impl Model for {struct_name}");
let impl_start = src
.find(&impl_anchor)
.ok_or_else(|| format!("could not find `{impl_anchor}`"))?;
let impl_brace_rel = src[impl_start..]
.find('{')
.ok_or_else(|| format!("`{impl_anchor}` has no opening brace"))?;
let impl_open = impl_start + impl_brace_rel;
let impl_close = find_matching_brace(src, impl_open)
.ok_or_else(|| format!("`{impl_anchor}` is not closed"))?;
let block = &src[impl_open..=impl_close];
let fn_rel = block
.find(fn_anchor)
.ok_or_else(|| format!("`{fn_anchor}` not found inside `{impl_anchor}`"))?;
let body_rel_in_fn = block[fn_rel..]
.find(body_open)
.ok_or_else(|| format!("`{body_open}` not found after `{fn_anchor}`"))?;
let body_open_abs = impl_open + fn_rel + body_rel_in_fn + body_open.len() - 1;
let body_close_abs = match src.as_bytes()[body_open_abs] {
b'{' => find_matching_brace(src, body_open_abs),
b'[' => find_matching_bracket(src, body_open_abs),
_ => None,
}
.ok_or_else(|| format!("unterminated body after `{body_open}`"))?;
let body = &src[body_open_abs + 1..body_close_abs];
if !body.contains(from) {
return Err(format!(
"{fn_anchor} body on `{struct_name}` does not contain `{from}`"
));
}
let new_body = body.replacen(from, to, 1);
let mut out = String::with_capacity(src.len());
out.push_str(&src[..=body_open_abs]);
out.push_str(&new_body);
out.push_str(&src[body_close_abs..]);
Ok(out)
}
fn remove_from_str_array_scoped(
src: &str,
struct_name: &str,
const_name: &str,
field: &str,
) -> Result<String, String> {
let impl_anchor = format!("impl Model for {struct_name}");
let impl_start = src
.find(&impl_anchor)
.ok_or_else(|| format!("could not find `{impl_anchor}`"))?;
let brace_rel = src[impl_start..]
.find('{')
.ok_or_else(|| format!("`{impl_anchor}` has no opening brace"))?;
let impl_open = impl_start + brace_rel;
let impl_close = find_matching_brace(src, impl_open)
.ok_or_else(|| format!("`{impl_anchor}` is not closed"))?;
let block = &src[impl_open..=impl_close];
let new_block = remove_from_str_array(block, const_name, field)?;
let mut out = String::with_capacity(src.len() + new_block.len() - block.len());
out.push_str(&src[..impl_open]);
out.push_str(&new_block);
out.push_str(&src[impl_close + 1..]);
Ok(out)
}
fn remove_from_str_array(src: &str, const_name: &str, field: &str) -> Result<String, String> {
let anchor = format!("const {const_name}");
let start = src
.find(&anchor)
.ok_or_else(|| format!("could not find `const {const_name}`"))?;
let rel_open = src[start..]
.find("= &[")
.ok_or_else(|| format!("`const {const_name}` does not use `= &[ … ]`"))?;
let open = start + rel_open + "= &".len();
let close = find_matching_bracket(src, open)
.ok_or_else(|| format!("`const {const_name}` array is not closed"))?;
let inner = &src[open + 1..close];
let literal = format!("\"{field}\"");
let literal_idx = inner
.find(&literal)
.ok_or_else(|| format!("`{const_name}` does not contain \"{field}\""))?;
let mut slice_start = literal_idx;
let mut slice_end = literal_idx + literal.len();
let after_literal = &inner.as_bytes()[slice_end..];
if let Some(comma_rel) = after_literal.iter().position(|&b| !b.is_ascii_whitespace()) {
if after_literal[comma_rel] == b',' {
slice_end += comma_rel + 1;
while slice_end < inner.len()
&& (inner.as_bytes()[slice_end] == b' ' || inner.as_bytes()[slice_end] == b'\t')
{
slice_end += 1;
}
if slice_end < inner.len() && inner.as_bytes()[slice_end] == b'\n' {
}
}
} else {
let before = &inner.as_bytes()[..slice_start];
if let Some(pos) = before.iter().rposition(|&b| !b.is_ascii_whitespace()) {
if before[pos] == b',' {
slice_start = pos;
}
}
}
let mut new_inner = String::with_capacity(inner.len());
new_inner.push_str(&inner[..slice_start]);
new_inner.push_str(&inner[slice_end..]);
let mut cursor = 0;
while let Some(rel) = new_inner[cursor..].find('\n') {
let pos = cursor + rel;
let after = &new_inner[pos + 1..];
let lead_ws = after
.bytes()
.take_while(|&b| b == b' ' || b == b'\t')
.count();
if after
.as_bytes()
.get(lead_ws)
.map(|&b| b == b'\n')
.unwrap_or(false)
{
let drain_end = pos + 1 + lead_ws + 1;
new_inner.drain(pos + 1..drain_end);
} else {
cursor = pos + 1;
}
}
let mut out = String::with_capacity(src.len());
out.push_str(&src[..=open]);
out.push_str(&new_inner);
out.push_str(&src[close..]);
Ok(out)
}
fn patch_models_for_add_field(
source: &str,
struct_name: &str,
field: &FieldSpec,
) -> Result<String, String> {
let rust_type = rust_type_for(&field.ty, field.nullable);
let mut out = source.to_string();
if field.ty == "DateTime" && !has_chrono_use(&out) {
out = insert_chrono_import(&out);
}
let field_line = format!(" pub {}: {},\n", field.name, rust_type);
out = insert_before_struct_close(&out, struct_name, &field_line)?;
out = insert_into_str_array(&out, "COLUMNS", &field.name)?;
if out.contains("const INSERT_COLUMNS") {
out = insert_into_str_array(&out, "INSERT_COLUMNS", &field.name)?;
}
let accessor = row_accessor(&field.ty, field.nullable);
let from_row_line = format!(
" {name}: row.{accessor}(\"{name}\")?,\n",
name = field.name,
accessor = accessor,
);
out = insert_before_ok_self_close(&out, &from_row_line)?;
let insert_line = build_insert_values_line(&field.name, &field.ty, field.nullable);
out = insert_before_vec_close(&out, &insert_line)?;
Ok(out)
}
fn patch_models_for_rename_field(
source: &str,
struct_name: &str,
from: &str,
to: &str,
) -> Result<String, String> {
let mut out = source.to_string();
out = rename_in_struct(&out, struct_name, from, to)?;
out = replace_in_str_array(&out, "COLUMNS", from, to)?;
if out.contains("const INSERT_COLUMNS") {
out = replace_in_str_array(&out, "INSERT_COLUMNS", from, to).unwrap_or(out);
}
out = rename_in_from_row(&out, from, to)?;
out = rename_in_insert_values(&out, from, to)?;
Ok(out)
}
fn patch_models_for_change_field_type(
source: &str,
struct_name: &str,
field_name: &str,
old_ty: &str,
new_ty: &str,
nullable: bool,
) -> Result<String, String> {
let mut out = source.to_string();
if (new_ty == "DateTime") && !has_chrono_use(&out) {
out = insert_chrono_import(&out);
}
let old_rust = rust_type_for(old_ty, nullable);
let new_rust = rust_type_for(new_ty, nullable);
out = replace_in_struct_literal(
&out,
struct_name,
&format!("pub {field_name}: {old_rust},"),
&format!("pub {field_name}: {new_rust},"),
)?;
let old_acc = row_accessor(old_ty, nullable);
let new_acc = row_accessor(new_ty, nullable);
if old_acc != new_acc {
out = replace_in_from_row_literal(
&out,
&format!("{field_name}: row.{old_acc}(\"{field_name}\")?,"),
&format!("{field_name}: row.{new_acc}(\"{field_name}\")?,"),
)?;
}
let old_line = build_insert_values_line(field_name, old_ty, nullable);
let new_line = build_insert_values_line(field_name, new_ty, nullable);
if old_line != new_line {
let old_trim = old_line.trim().to_string();
let new_trim = new_line.trim().to_string();
out = replace_in_insert_values_literal(&out, &old_trim, &new_trim)?;
}
Ok(out)
}
fn patch_models_for_change_nullability(
source: &str,
struct_name: &str,
field_name: &str,
ty: &str,
was_nullable: bool,
now_nullable: bool,
) -> Result<String, String> {
let mut out = source.to_string();
let old_rust = rust_type_for(ty, was_nullable);
let new_rust = rust_type_for(ty, now_nullable);
out = replace_in_struct_literal(
&out,
struct_name,
&format!("pub {field_name}: {old_rust},"),
&format!("pub {field_name}: {new_rust},"),
)?;
let old_acc = row_accessor(ty, was_nullable);
let new_acc = row_accessor(ty, now_nullable);
out = replace_in_from_row_literal(
&out,
&format!("{field_name}: row.{old_acc}(\"{field_name}\")?,"),
&format!("{field_name}: row.{new_acc}(\"{field_name}\")?,"),
)?;
Ok(out)
}
fn patch_models_for_rename_model(
source: &str,
old_struct: &str,
new_struct: &str,
old_table: &str,
new_table: &str,
) -> Result<String, String> {
let mut out = source.to_string();
let old_struct_decl = format!("pub struct {old_struct}");
let new_struct_decl = format!("pub struct {new_struct}");
if !out.contains(&old_struct_decl) {
return Err(format!("struct `{old_struct}` not found"));
}
out = out.replacen(&old_struct_decl, &new_struct_decl, 1);
let old_impl = format!("impl Model for {old_struct}");
let new_impl = format!("impl Model for {new_struct}");
if out.contains(&old_impl) {
out = out.replacen(&old_impl, &new_impl, 1);
}
let old_tbl = format!("const TABLE: &'static str = \"{old_table}\";");
let new_tbl = format!("const TABLE: &'static str = \"{new_table}\";");
if out.contains(&old_tbl) {
out = out.replacen(&old_tbl, &new_tbl, 1);
}
Ok(out)
}
fn patch_admin_for_rename_model(
source: &str,
old_struct: &str,
new_struct: &str,
) -> Result<String, String> {
let mut out = source.to_string();
let old_use = format!("use super::models::{old_struct};");
let new_use = format!("use super::models::{new_struct};");
if out.contains(&old_use) {
out = out.replacen(&old_use, &new_use, 1);
}
let old_call = format!("admin.model::<{old_struct}>()");
let new_call = format!("admin.model::<{new_struct}>()");
if !out.contains(&old_call) {
return Err(format!(
"`admin.rs` does not call `admin.model::<{old_struct}>()`"
));
}
out = out.replacen(&old_call, &new_call, 1);
Ok(out)
}
fn replace_in_struct_literal(
src: &str,
struct_name: &str,
from: &str,
to: &str,
) -> Result<String, String> {
let (open, close) = find_struct_block(src, struct_name)
.ok_or_else(|| format!("struct `{struct_name}` block not found"))?;
let block = &src[open..=close];
if !block.contains(from) {
return Err(format!("struct `{struct_name}` does not contain `{from}`"));
}
let new_block = block.replacen(from, to, 1);
let mut out = String::with_capacity(src.len());
out.push_str(&src[..open]);
out.push_str(&new_block);
out.push_str(&src[close + 1..]);
Ok(out)
}
fn replace_in_from_row_literal(src: &str, from: &str, to: &str) -> Result<String, String> {
let fn_start = src
.find("fn from_row(")
.ok_or_else(|| "`fn from_row(` not found".to_string())?;
let ok_self_rel = src[fn_start..]
.find("Ok(Self {")
.ok_or_else(|| "`Ok(Self {` not found".to_string())?;
let ok_self_open = fn_start + ok_self_rel + "Ok(Self ".len();
let ok_self_close = find_matching_brace(src, ok_self_open)
.ok_or_else(|| "`Ok(Self { … }` is not closed".to_string())?;
let block = &src[ok_self_open..=ok_self_close];
if !block.contains(from) {
return Err(format!("from_row does not contain `{from}`"));
}
let replaced = block.replacen(from, to, 1);
let mut out = String::with_capacity(src.len());
out.push_str(&src[..ok_self_open]);
out.push_str(&replaced);
out.push_str(&src[ok_self_close + 1..]);
Ok(out)
}
fn replace_in_insert_values_literal(src: &str, from: &str, to: &str) -> Result<String, String> {
let fn_start = src
.find("fn insert_values(")
.ok_or_else(|| "`fn insert_values(` not found".to_string())?;
let vec_rel = src[fn_start..]
.find("vec![")
.ok_or_else(|| "no `vec![` inside insert_values".to_string())?;
let vec_open = fn_start + vec_rel + 4;
let vec_close = find_matching_bracket(src, vec_open)
.ok_or_else(|| "`vec![ … ]` is not closed".to_string())?;
let block = &src[vec_open..=vec_close];
if !block.contains(from) {
return Err(format!("insert_values does not contain `{from}`"));
}
let replaced = block.replacen(from, to, 1);
let mut out = String::with_capacity(src.len());
out.push_str(&src[..vec_open]);
out.push_str(&replaced);
out.push_str(&src[vec_close + 1..]);
Ok(out)
}
fn find_struct_block(src: &str, name: &str) -> Option<(usize, usize)> {
let anchor = format!("pub struct {name}");
let start = src.find(&anchor)?;
let after_name = start + anchor.len();
match src.as_bytes().get(after_name)? {
b' ' | b'{' | b'\t' | b'\n' | b'<' => {}
_ => return None,
}
let open = start + src[start..].find('{')?;
let close = find_matching_brace(src, open)?;
Some((open, close))
}
fn find_matching_brace(src: &str, open_idx: usize) -> Option<usize> {
let bytes = src.as_bytes();
if *bytes.get(open_idx)? != b'{' {
return None;
}
let mut depth: i32 = 0;
let mut i = open_idx;
while i < bytes.len() {
match bytes[i] {
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
return Some(i);
}
}
_ => {}
}
i += 1;
}
None
}
fn struct_declares_field(inside_struct: &str, field_name: &str) -> bool {
for line in inside_struct.lines() {
let trimmed = line.trim_start();
if let Some(rest) = trimmed.strip_prefix("pub ") {
let rest = rest.trim_start();
let mut chars = rest.chars();
let mut ident = String::new();
for ch in chars.by_ref() {
if ch.is_ascii_alphanumeric() || ch == '_' {
ident.push(ch);
} else {
break;
}
}
if ident == field_name {
let rest = rest.trim_start_matches(&ident[..]).trim_start();
if rest.starts_with(':') {
return true;
}
}
}
}
false
}
fn insert_before_struct_close(
src: &str,
struct_name: &str,
new_line: &str,
) -> Result<String, String> {
let (_open, close) = find_struct_block(src, struct_name)
.ok_or_else(|| format!("could not locate `pub struct {struct_name}` block"))?;
insert_before_brace(src, close, new_line)
}
fn insert_before_ok_self_close(src: &str, new_line: &str) -> Result<String, String> {
let needle = "Ok(Self {";
let first = src
.find(needle)
.ok_or_else(|| "could not locate `Ok(Self {` in from_row".to_string())?;
if src[first + needle.len()..].contains(needle) {
return Err("multiple `Ok(Self {` in file; refusing to choose".into());
}
let open = first + needle.len() - 1; let close = find_matching_brace(src, open)
.ok_or_else(|| "`Ok(Self { … }` is not closed".to_string())?;
insert_before_brace(src, close, new_line)
}
fn insert_before_vec_close(src: &str, new_line: &str) -> Result<String, String> {
let fn_idx = src
.find("fn insert_values(")
.ok_or_else(|| "could not locate `fn insert_values(`".to_string())?;
let vec_rel = src[fn_idx..]
.find("vec![")
.ok_or_else(|| "no `vec![` inside `insert_values`".to_string())?;
let vec_open = fn_idx + vec_rel + 4; let close = find_matching_bracket(src, vec_open)
.ok_or_else(|| "`vec![ … ]` is not closed".to_string())?;
insert_before_bracket(src, close, new_line)
}
fn find_matching_bracket(src: &str, open_idx: usize) -> Option<usize> {
let bytes = src.as_bytes();
if *bytes.get(open_idx)? != b'[' {
return None;
}
let mut depth: i32 = 0;
let mut i = open_idx;
while i < bytes.len() {
match bytes[i] {
b'[' => depth += 1,
b']' => {
depth -= 1;
if depth == 0 {
return Some(i);
}
}
_ => {}
}
i += 1;
}
None
}
fn insert_before_brace(src: &str, close: usize, new_line: &str) -> Result<String, String> {
let before = &src[..close];
let last_nl = before.rfind('\n').ok_or_else(|| {
"refusing to patch single-line `{ … }`: file layout is outside the 0.5.2 safe subset"
.to_string()
})?;
let mut out = String::with_capacity(src.len() + new_line.len());
out.push_str(&src[..=last_nl]);
out.push_str(new_line);
if !new_line.ends_with('\n') {
out.push('\n');
}
out.push_str(&src[last_nl + 1..]);
Ok(out)
}
fn insert_before_bracket(src: &str, close: usize, new_line: &str) -> Result<String, String> {
let before = &src[..close];
let last_nl = before.rfind('\n').ok_or_else(|| {
"refusing to patch single-line `vec![ … ]`: outside the safe subset".to_string()
})?;
let mut out = String::with_capacity(src.len() + new_line.len());
out.push_str(&src[..=last_nl]);
out.push_str(new_line);
if !new_line.ends_with('\n') {
out.push('\n');
}
out.push_str(&src[last_nl + 1..]);
Ok(out)
}
fn insert_into_str_array(src: &str, const_name: &str, column: &str) -> Result<String, String> {
let anchor = format!("const {const_name}");
let start = src
.find(&anchor)
.ok_or_else(|| format!("could not find `const {const_name}`"))?;
let rel_open = src[start..]
.find("= &[")
.ok_or_else(|| format!("`const {const_name}` does not use the expected `= &[ … ]` form"))?;
let open = start + rel_open + "= &".len();
let close = find_matching_bracket(src, open)
.ok_or_else(|| format!("`const {const_name}` array is not closed"))?;
let inner = &src[open + 1..close];
if inner.contains(&format!("\"{column}\"")) {
return Err(format!(
"`{const_name}` already contains \"{column}\"; refusing to duplicate"
));
}
let trimmed = inner.trim_end_matches(|c: char| c.is_whitespace() || c == ',');
let addition = if trimmed.trim().is_empty() {
format!("\"{column}\"")
} else {
format!("{trimmed}, \"{column}\"")
};
let tail_ws_start = inner
.rfind(|c: char| !c.is_whitespace() && c != ',')
.map(|i| i + 1)
.unwrap_or(0);
let tail_ws = &inner[tail_ws_start..];
let mut out = String::with_capacity(src.len() + column.len() + 4);
out.push_str(&src[..=open]);
out.push_str(&addition);
out.push_str(tail_ws);
out.push_str(&src[close..]);
Ok(out)
}
fn replace_in_str_array(
src: &str,
const_name: &str,
from: &str,
to: &str,
) -> Result<String, String> {
let anchor = format!("const {const_name}");
let start = src
.find(&anchor)
.ok_or_else(|| format!("could not find `const {const_name}`"))?;
let rel_open = src[start..]
.find("= &[")
.ok_or_else(|| format!("`const {const_name}` does not use the expected `= &[ … ]` form"))?;
let open = start + rel_open + "= &".len();
let close = find_matching_bracket(src, open)
.ok_or_else(|| format!("`const {const_name}` array is not closed"))?;
let inner = &src[open + 1..close];
let from_literal = format!("\"{from}\"");
let to_literal = format!("\"{to}\"");
if !inner.contains(&from_literal) {
return Err(format!(
"`{const_name}` does not contain \"{from}\"; rename cannot proceed"
));
}
if inner.contains(&to_literal) {
return Err(format!(
"`{const_name}` already contains \"{to}\"; rename target is taken"
));
}
let new_inner = inner.replacen(&from_literal, &to_literal, 1);
let mut out = String::with_capacity(src.len());
out.push_str(&src[..=open]);
out.push_str(&new_inner);
out.push_str(&src[close..]);
Ok(out)
}
fn rename_in_struct(src: &str, struct_name: &str, from: &str, to: &str) -> Result<String, String> {
let (open, close) =
find_struct_block(src, struct_name).ok_or_else(|| "struct block not found".to_string())?;
let block = &src[open..=close];
let from_pattern = format!("pub {from}:");
let to_pattern = format!("pub {to}:");
if !block.contains(&from_pattern) {
return Err(format!(
"struct {struct_name} does not declare `pub {from}:`"
));
}
let new_block = block.replacen(&from_pattern, &to_pattern, 1);
let mut out = String::with_capacity(src.len());
out.push_str(&src[..open]);
out.push_str(&new_block);
out.push_str(&src[close + 1..]);
Ok(out)
}
fn rename_in_from_row(src: &str, from: &str, to: &str) -> Result<String, String> {
let fn_start = src
.find("fn from_row(")
.ok_or_else(|| "from_row not found".to_string())?;
let ok_self_rel = src[fn_start..]
.find("Ok(Self {")
.ok_or_else(|| "Ok(Self not found".to_string())?;
let ok_self_open = fn_start + ok_self_rel + "Ok(Self ".len();
let ok_self_close = find_matching_brace(src, ok_self_open)
.ok_or_else(|| "Ok(Self block is not closed".to_string())?;
let block = &src[ok_self_open..=ok_self_close];
let from_lhs = format!("{from}:");
let from_arg = format!("\"{from}\"");
let to_lhs = format!("{to}:");
let to_arg = format!("\"{to}\"");
if !block.contains(&from_lhs) {
return Err(format!(
"from_row does not reference `{from}:`; rename cannot proceed"
));
}
let replaced = block
.replacen(&from_lhs, &to_lhs, 1)
.replacen(&from_arg, &to_arg, 1);
let mut out = String::with_capacity(src.len());
out.push_str(&src[..ok_self_open]);
out.push_str(&replaced);
out.push_str(&src[ok_self_close + 1..]);
Ok(out)
}
fn rename_in_insert_values(src: &str, from: &str, to: &str) -> Result<String, String> {
let fn_start = src
.find("fn insert_values(")
.ok_or_else(|| "insert_values not found".to_string())?;
let vec_rel = src[fn_start..]
.find("vec![")
.ok_or_else(|| "no `vec![` inside insert_values".to_string())?;
let vec_open = fn_start + vec_rel + 4;
let vec_close = find_matching_bracket(src, vec_open)
.ok_or_else(|| "vec![ … ] is not closed".to_string())?;
let block = &src[vec_open..=vec_close];
let from_pattern = format!("self.{from}");
let to_pattern = format!("self.{to}");
if !block.contains(&from_pattern) {
return Err(format!(
"insert_values does not reference `self.{from}`; rename cannot proceed"
));
}
let replaced = block.replacen(&from_pattern, &to_pattern, 1);
let mut out = String::with_capacity(src.len());
out.push_str(&src[..vec_open]);
out.push_str(&replaced);
out.push_str(&src[vec_close + 1..]);
Ok(out)
}
fn has_chrono_use(src: &str) -> bool {
src.lines()
.any(|l| l.trim_start().starts_with("use chrono::"))
}
fn insert_chrono_import(src: &str) -> String {
let mut last_use_end: Option<usize> = None;
for (idx, line) in src.match_indices('\n') {
let before_nl = &src[..idx];
let line_start = before_nl.rfind('\n').map(|p| p + 1).unwrap_or(0);
let line_txt = &src[line_start..idx];
if line_txt.trim_start().starts_with("use ") {
last_use_end = Some(idx);
}
let _ = line; }
match last_use_end {
Some(end) => {
let mut out = String::with_capacity(src.len() + 40);
out.push_str(&src[..=end]);
out.push_str("use chrono::{DateTime, Utc};\n");
out.push_str(&src[end + 1..]);
out
}
None => format!("use chrono::{{DateTime, Utc}};\n{src}"),
}
}
fn rust_type_for(ty: &str, nullable: bool) -> String {
let base = match ty {
"i32" => "i32",
"i64" => "i64",
"String" => "String",
"bool" => "bool",
"DateTime" => "DateTime<Utc>",
other => other,
};
if nullable {
format!("Option<{base}>")
} else {
base.to_string()
}
}
fn row_accessor(ty: &str, nullable: bool) -> String {
let suffix = match ty {
"i32" => "i32",
"i64" => "i64",
"String" => "string",
"bool" => "bool",
"DateTime" => "datetime",
_ => "string",
};
if nullable {
format!("get_optional_{suffix}")
} else {
format!("get_{suffix}")
}
}
fn build_insert_values_line(field: &str, ty: &str, _nullable: bool) -> String {
let call = if ty == "String" {
format!("self.{field}.clone().into()")
} else {
format!("self.{field}.into()")
};
format!(" {call},\n")
}
fn locate_model_file(
project: &ProjectView,
struct_name: &str,
) -> Result<(String, String), ExecutionError> {
let mut matches: Vec<&str> = project
.models_files
.iter()
.filter(|(_, f)| f.struct_names.iter().any(|s| s == struct_name))
.map(|(app, _)| app.as_str())
.collect();
match matches.len() {
0 => Err(ExecutionError::ProjectStructure(format!(
"no apps/<app>/models.rs declares `pub struct {struct_name}`"
))),
1 => {
let app = matches.remove(0).to_string();
let source = project.models_files[&app].source.clone();
Ok((app, source))
}
_ => Err(ExecutionError::ProjectStructure(format!(
"multiple apps declare `pub struct {struct_name}`: {}",
matches.join(", ")
))),
}
}
fn find_table_for_struct(src: &str, struct_name: &str) -> Option<String> {
let impl_anchor = format!("impl Model for {struct_name}");
let slice = if let Some(impl_start) = src.find(&impl_anchor) {
let brace_rel = src[impl_start..].find('{')?;
let open = impl_start + brace_rel;
let close = find_matching_brace(src, open)?;
&src[open..=close]
} else {
src
};
let anchor = "const TABLE: &'static str = \"";
let start = slice.find(anchor)? + anchor.len();
let end = slice[start..].find('"')?;
Some(slice[start..start + end].to_string())
}
fn fallback_table_name(struct_name: &str) -> Option<String> {
let mut out = String::with_capacity(struct_name.len() + 4);
for (i, ch) in struct_name.chars().enumerate() {
if ch.is_ascii_uppercase() {
if i > 0 {
out.push('_');
}
out.extend(ch.to_lowercase());
} else {
out.push(ch);
}
}
if !out.ends_with('s') {
out.push('s');
}
Some(out)
}
fn next_migration_number(existing: &[String]) -> u32 {
let mut max: u32 = 0;
for name in existing {
let Some(prefix) = name.split('_').next() else {
continue;
};
if let Ok(n) = prefix.parse::<u32>() {
if n > max {
max = n;
}
}
}
max + 1
}
fn new_migration_path(project: &ProjectView, number: u32, slug: &str) -> (PathBuf, String) {
let filename = format!("{number:04}_{slug}.sql");
(project.root.join("migrations").join(&filename), filename)
}
pub(super) fn sql_for_add_field(table: &str, field: &FieldSpec) -> String {
let sql_type = sql_type_for(&field.ty);
if field.nullable {
format!(
"-- Generated by rustio ai apply. DO NOT EDIT.\n\
ALTER TABLE {table} ADD COLUMN {name} {sql_type};\n",
name = field.name,
)
} else {
let default = safe_default_literal(&field.ty);
format!(
"-- Generated by rustio ai apply. DO NOT EDIT.\n\
ALTER TABLE {table} ADD COLUMN {name} {sql_type} NOT NULL DEFAULT {default};\n",
name = field.name,
)
}
}
fn sql_type_for(ty: &str) -> &'static str {
match ty {
"i32" => "INTEGER",
"i64" => "BIGINT",
"bool" => "BOOLEAN",
"String" => "TEXT",
"DateTime" => "TIMESTAMPTZ",
_ => "TEXT",
}
}
fn safe_default_literal(ty: &str) -> &'static str {
match ty {
"i32" | "i64" => "0",
"bool" => "FALSE",
"String" => "''",
"DateTime" => "'1970-01-01 00:00:00+00'",
_ => "''",
}
}
impl ProjectView {
pub fn from_dir(root: &Path) -> Result<Self, ExecutionError> {
let apps_dir = root.join("apps");
let migrations_dir = root.join("migrations");
if !apps_dir.is_dir() {
return Err(ExecutionError::ProjectStructure(format!(
"expected directory `apps/` at {}",
root.display()
)));
}
if !migrations_dir.is_dir() {
return Err(ExecutionError::ProjectStructure(format!(
"expected directory `migrations/` at {}",
root.display()
)));
}
let mut models_files = BTreeMap::new();
let entries = std::fs::read_dir(&apps_dir).map_err(|e| ExecutionError::IoError {
path: apps_dir.display().to_string(),
message: e.to_string(),
})?;
for entry in entries {
let entry = entry.map_err(|e| ExecutionError::IoError {
path: apps_dir.display().to_string(),
message: e.to_string(),
})?;
let ty = entry.file_type().map_err(|e| ExecutionError::IoError {
path: entry.path().display().to_string(),
message: e.to_string(),
})?;
if !ty.is_dir() {
continue;
}
let app_dir = entry.path();
let app_name = app_dir
.file_name()
.and_then(|n| n.to_str())
.map(String::from)
.unwrap_or_default();
if app_name.is_empty() {
continue;
}
let models_path = app_dir.join("models.rs");
if !models_path.is_file() {
continue;
}
let source =
std::fs::read_to_string(&models_path).map_err(|e| ExecutionError::IoError {
path: models_path.display().to_string(),
message: e.to_string(),
})?;
let struct_names = parse_struct_names(&source);
models_files.insert(
app_name,
ParsedModelsFile {
path: models_path,
source,
struct_names,
},
);
}
let mut existing_migrations = Vec::new();
let mut migration_sources: BTreeMap<String, String> = BTreeMap::new();
let entries = std::fs::read_dir(&migrations_dir).map_err(|e| ExecutionError::IoError {
path: migrations_dir.display().to_string(),
message: e.to_string(),
})?;
for entry in entries {
let entry = entry.map_err(|e| ExecutionError::IoError {
path: migrations_dir.display().to_string(),
message: e.to_string(),
})?;
if let Some(name) = entry.file_name().to_str() {
if name.ends_with(".sql") {
let path = entry.path();
let contents =
std::fs::read_to_string(&path).map_err(|e| ExecutionError::IoError {
path: path.display().to_string(),
message: e.to_string(),
})?;
migration_sources.insert(name.to_string(), contents);
existing_migrations.push(name.to_string());
}
}
}
existing_migrations.sort();
Ok(ProjectView {
root: root.to_path_buf(),
models_files,
existing_migrations,
migration_sources,
})
}
}
fn parse_struct_names(source: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for line in source.lines() {
let t = line.trim_start();
if let Some(rest) = t.strip_prefix("pub struct ") {
let name: String = rest
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect();
if !name.is_empty() {
out.push(name);
}
}
}
out
}
pub fn execute_plan_document(
project_root: &Path,
doc: &PlanDocument,
options: &ExecuteOptions,
context: Option<&ContextConfig>,
) -> Result<ExecutionResult, ExecutionError> {
let schema_path = project_root.join("rustio.schema.json");
let schema_json =
std::fs::read_to_string(&schema_path).map_err(|e| ExecutionError::IoError {
path: schema_path.display().to_string(),
message: e.to_string(),
})?;
let schema =
Schema::parse(&schema_json).map_err(|e| ExecutionError::ValidationFailed(e.to_string()))?;
let project = ProjectView::from_dir(project_root)?;
let preview = plan_execution(&schema, &project, doc, options, context)?;
commit_changes(&preview)?;
let generated: Vec<String> = preview
.file_changes
.iter()
.map(|c| display_path(project_root, &c.path))
.collect();
Ok(ExecutionResult {
applied_steps: preview.applied_steps,
generated_files: generated,
summary: preview.summary,
})
}
fn commit_changes(preview: &ExecutionPreview) -> Result<(), ExecutionError> {
for change in &preview.file_changes {
match change.kind {
FileChangeKind::Create => {
if change.path.exists() {
return Err(ExecutionError::FileConflict {
path: change.path.display().to_string(),
reason: "file already exists — refusing to overwrite".to_string(),
});
}
if let Some(parent) = change.path.parent() {
if !parent.is_dir() {
return Err(ExecutionError::ProjectStructure(format!(
"parent directory `{}` does not exist",
parent.display()
)));
}
}
}
FileChangeKind::Update => {
let actual =
std::fs::read_to_string(&change.path).map_err(|e| ExecutionError::IoError {
path: change.path.display().to_string(),
message: e.to_string(),
})?;
if let Some(expected) = &change.expected_current_contents {
if &actual != expected {
return Err(ExecutionError::FileConflict {
path: change.path.display().to_string(),
reason: "file changed on disk after the plan was generated".to_string(),
});
}
}
}
}
}
let mut tmp_paths: Vec<PathBuf> = Vec::with_capacity(preview.file_changes.len());
for change in &preview.file_changes {
let tmp = change.path.with_extension(match change.path.extension() {
Some(e) => format!("{}.rustio_tmp", e.to_string_lossy()),
None => "rustio_tmp".to_string(),
});
if let Err(e) = std::fs::write(&tmp, &change.new_contents) {
cleanup_tmps(&tmp_paths);
return Err(ExecutionError::IoError {
path: tmp.display().to_string(),
message: e.to_string(),
});
}
tmp_paths.push(tmp);
}
let mut renamed: Vec<(PathBuf, Option<String>)> =
Vec::with_capacity(preview.file_changes.len());
for (i, change) in preview.file_changes.iter().enumerate() {
let tmp = &tmp_paths[i];
let original = match change.kind {
FileChangeKind::Update => change.expected_current_contents.clone(),
FileChangeKind::Create => None,
};
if let Err(e) = std::fs::rename(tmp, &change.path) {
rollback_renames(&renamed);
cleanup_tmps(&tmp_paths[i..]);
return Err(ExecutionError::IoError {
path: change.path.display().to_string(),
message: e.to_string(),
});
}
renamed.push((change.path.clone(), original));
}
Ok(())
}
fn cleanup_tmps(paths: &[PathBuf]) {
for p in paths {
let _ = std::fs::remove_file(p);
}
}
fn rollback_renames(renamed: &[(PathBuf, Option<String>)]) {
for (path, original) in renamed.iter().rev() {
match original {
Some(contents) => {
let _ = std::fs::write(path, contents);
}
None => {
let _ = std::fs::remove_file(path);
}
}
}
}
fn display_path(root: &Path, absolute: &Path) -> String {
absolute
.strip_prefix(root)
.ok()
.and_then(|p| p.to_str())
.map(String::from)
.unwrap_or_else(|| absolute.display().to_string())
}
#[derive(Debug, Clone)]
pub struct RetrofitReport {
pub upgraded: Vec<(String, String)>,
pub migrations: Vec<(String, String)>,
}
pub fn plan_retrofit_foreign_keys(schema: &crate::schema::Schema) -> RetrofitReport {
use crate::schema::RelationKind;
let mut upgraded = Vec::new();
let mut migrations = Vec::new();
let table_for = |model_name: &str| -> Option<String> {
schema
.models
.iter()
.find(|m| m.name == model_name)
.and_then(|_| fallback_table_name(model_name))
};
for model in &schema.models {
let table = match fallback_table_name(&model.name) {
Some(t) => t,
None => continue,
};
let mut to_retrofit: Vec<(String, String, String)> = Vec::new(); for f in &model.fields {
if let Some(rel) = &f.relation {
if matches!(rel.kind, RelationKind::BelongsTo) && rel.on_delete.is_none() {
let parent_table = match table_for(&rel.model) {
Some(t) => t,
None => continue,
};
let policy = "RESTRICT".to_string(); to_retrofit.push((f.name.clone(), parent_table, policy));
upgraded.push((model.name.clone(), f.name.clone()));
}
}
}
if to_retrofit.is_empty() {
continue;
}
let mut sql = String::new();
sql.push_str("-- Generated by `rustio migrate add-fks` (Phase 2).\n");
sql.push_str(
"-- PostgreSQL retrofits FKs in place; no recreate-table needed.\n\
-- The ALTER will refuse if any child row references a missing parent —\n\
-- delete or repair orphans before re-running.\n",
);
sql.push_str("BEGIN;\n");
for (via, parent_table, policy) in &to_retrofit {
let constraint_name = format!("{table}_{via}_fk");
sql.push_str(&format!(
"ALTER TABLE {table}\n \
ADD CONSTRAINT {constraint_name} \
FOREIGN KEY ({via}) REFERENCES {parent_table}(id) ON DELETE {policy};\n",
));
}
sql.push_str("COMMIT;\n");
migrations.push((format!("retrofit_fks_{table}"), sql));
}
RetrofitReport {
upgraded,
migrations,
}
}
pub fn render_preview_human(preview: &ExecutionPreview, risk: RiskLevel) -> String {
let mut out = String::from("Plan to apply\n\n");
out.push_str("Applying:\n");
for line in preview.summary.lines() {
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
out.push_str("\nFiles to be written:\n");
for change in &preview.file_changes {
let kind = match change.kind {
FileChangeKind::Create => "create",
FileChangeKind::Update => "update",
};
out.push_str(&format!(" - {kind} {}\n", change.path.display()));
}
out.push_str(&format!("\nRisk:\n {}\n", risk.as_str()));
out
}