1use chrono::{DateTime, SecondsFormat, Utc};
53use serde::{Deserialize, Serialize};
54
55use super::planner::{ContextConfig, PlanResult};
56use super::{validate_against, Plan, Primitive, PrimitiveError};
57use crate::schema::{Schema, SchemaModel};
58
59pub const PLAN_DOCUMENT_VERSION: u32 = 1;
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum RiskLevel {
78 Low,
81 Medium,
84 High,
88 Critical,
92}
93
94impl RiskLevel {
95 pub fn as_str(self) -> &'static str {
96 match self {
97 RiskLevel::Low => "Low",
98 RiskLevel::Medium => "Medium",
99 RiskLevel::High => "High",
100 RiskLevel::Critical => "Critical",
101 }
102 }
103}
104
105#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(deny_unknown_fields)]
110pub struct PlanImpact {
111 pub adds_fields: usize,
112 pub removes_fields: usize,
113 pub renames: usize,
114 pub type_changes: usize,
115 pub nullability_changes: usize,
116 pub touches_core_models: bool,
121 pub destructive: bool,
126}
127
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
140#[serde(deny_unknown_fields)]
141pub struct PlanDocument {
142 pub version: u32,
143 pub created_at: String,
147 pub prompt: String,
148 pub explanation: String,
149 pub risk: RiskLevel,
150 pub impact: PlanImpact,
151 pub plan: Plan,
152}
153
154#[derive(Debug, Clone, PartialEq)]
158pub enum LoadedPlan {
159 Document(PlanDocument),
160 RawPlan(Plan),
161}
162
163impl LoadedPlan {
164 pub fn plan(&self) -> &Plan {
165 match self {
166 LoadedPlan::Document(d) => &d.plan,
167 LoadedPlan::RawPlan(p) => p,
168 }
169 }
170}
171
172#[derive(Debug, Clone, PartialEq)]
176pub struct PlanReview {
177 pub plan: Plan,
178 pub impact: PlanImpact,
179 pub risk: RiskLevel,
180 pub warnings: Vec<String>,
181 pub validation: ValidationOutcome,
182}
183
184#[derive(Debug, Clone, PartialEq)]
191pub enum ValidationOutcome {
192 Valid,
193 Invalid { step: usize, reason: PrimitiveError },
194}
195
196impl ValidationOutcome {
197 pub fn is_valid(&self) -> bool {
198 matches!(self, ValidationOutcome::Valid)
199 }
200}
201
202#[non_exhaustive]
207#[derive(Debug, Clone, PartialEq)]
208pub enum ReviewError {
209 Parse(String),
212 UnknownVersion { found: u32, expected: u32 },
216 InvalidPlan(PrimitiveError),
220}
221
222impl std::fmt::Display for ReviewError {
223 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
224 match self {
225 Self::Parse(msg) => write!(f, "plan review: parse error: {msg}"),
226 Self::UnknownVersion { found, expected } => write!(
227 f,
228 "plan review: unsupported document version {found} (this build reads version {expected})"
229 ),
230 Self::InvalidPlan(e) => write!(f, "plan review: invalid plan: {e}"),
231 }
232 }
233}
234
235impl std::error::Error for ReviewError {}
236
237pub fn build_plan_document(
248 schema: &Schema,
249 prompt: &str,
250 result: &PlanResult,
251 context: Option<&ContextConfig>,
252) -> Result<PlanDocument, ReviewError> {
253 build_plan_document_with_timestamp(schema, prompt, result, Utc::now(), context)
254}
255
256pub fn build_plan_document_with_timestamp(
261 schema: &Schema,
262 prompt: &str,
263 result: &PlanResult,
264 timestamp: DateTime<Utc>,
265 context: Option<&ContextConfig>,
266) -> Result<PlanDocument, ReviewError> {
267 result
271 .plan
272 .validate(schema)
273 .map_err(ReviewError::InvalidPlan)?;
274
275 let impact = compute_impact(&result.plan, schema);
276 let risk = classify_risk(&result.plan, &impact, &ValidationOutcome::Valid, context);
277 Ok(PlanDocument {
278 version: PLAN_DOCUMENT_VERSION,
279 created_at: timestamp.to_rfc3339_opts(SecondsFormat::Secs, true),
280 prompt: prompt.to_string(),
281 explanation: result.explanation.clone(),
282 risk,
283 impact,
284 plan: result.plan.clone(),
285 })
286}
287
288pub fn review_plan(
298 schema: &Schema,
299 plan: &Plan,
300 context: Option<&ContextConfig>,
301) -> Result<PlanReview, ReviewError> {
302 let validation = match simulate_plan(plan, schema) {
303 Ok(()) => ValidationOutcome::Valid,
304 Err((step, reason)) => ValidationOutcome::Invalid { step, reason },
305 };
306 let impact = compute_impact(plan, schema);
307 let risk = classify_risk(plan, &impact, &validation, context);
308 let mut warnings = warnings_for(plan, context);
309 warnings.extend(relation_warnings_for(plan, schema, context));
314 Ok(PlanReview {
315 plan: plan.clone(),
316 impact,
317 risk,
318 warnings,
319 validation,
320 })
321}
322
323fn relation_warnings_for(
328 plan: &Plan,
329 schema: &Schema,
330 context: Option<&ContextConfig>,
331) -> Vec<String> {
332 let mut out: Vec<String> = Vec::new();
333 let pii: Vec<&str> = context.map(|c| c.pii_fields()).unwrap_or_default();
334 for step in &plan.steps {
335 let Primitive::AddRelation(r) = step else {
336 continue;
337 };
338 out.push(format!(
339 "Relation `{}.{}` → `{}` is recorded without a SQL foreign-key constraint in 0.8.x. Orphan rows are possible if the target is deleted — referential integrity lands in 0.9.0.",
340 r.from, r.via, r.to,
341 ));
342 if !pii.is_empty() {
343 if let Some(target) = schema.models.iter().find(|m| m.name == r.to) {
346 let pii_hits: Vec<&str> = target
347 .fields
348 .iter()
349 .filter_map(|f| pii.iter().copied().find(|p| *p == f.name))
350 .collect();
351 if !pii_hits.is_empty() {
352 out.push(format!(
353 "Linking `{}` to `{}` creates a path to personally-identifying fields on the target ({}). Review GDPR minimisation / purpose-limitation before applying.",
354 r.from,
355 r.to,
356 pii_hits.join(", "),
357 ));
358 }
359 }
360 }
361 }
362 out
363}
364
365pub fn load_plan(json: &str) -> Result<LoadedPlan, ReviewError> {
373 if let Ok(doc) = serde_json::from_str::<PlanDocument>(json) {
376 if doc.version != PLAN_DOCUMENT_VERSION {
377 return Err(ReviewError::UnknownVersion {
378 found: doc.version,
379 expected: PLAN_DOCUMENT_VERSION,
380 });
381 }
382 return Ok(LoadedPlan::Document(doc));
383 }
384 match serde_json::from_str::<Plan>(json) {
386 Ok(plan) => Ok(LoadedPlan::RawPlan(plan)),
387 Err(e) => Err(ReviewError::Parse(e.to_string())),
388 }
389}
390
391pub fn compute_impact(plan: &Plan, schema: &Schema) -> PlanImpact {
394 let mut out = PlanImpact::default();
395 for step in &plan.steps {
396 match step {
397 Primitive::AddField(_) => out.adds_fields += 1,
398 Primitive::RemoveField(_) => {
399 out.removes_fields += 1;
400 out.destructive = true;
401 }
402 Primitive::RenameField(_) | Primitive::RenameModel(_) => out.renames += 1,
403 Primitive::ChangeFieldType(_) => out.type_changes += 1,
404 Primitive::ChangeFieldNullability(_) => out.nullability_changes += 1,
405 Primitive::RemoveModel(_) | Primitive::RemoveRelation(_) => {
406 out.destructive = true;
407 }
408 _ => {}
409 }
410 if touches_core_model(step, schema) {
411 out.touches_core_models = true;
412 }
413 }
414 out
415}
416
417pub fn classify_risk(
429 plan: &Plan,
430 impact: &PlanImpact,
431 validation: &ValidationOutcome,
432 context: Option<&ContextConfig>,
433) -> RiskLevel {
434 if !validation.is_valid() {
435 return RiskLevel::Critical;
436 }
437 if impact.touches_core_models {
438 return RiskLevel::Critical;
439 }
440 if plan.steps.iter().any(|s| s.is_developer_only()) {
441 return RiskLevel::Critical;
442 }
443
444 if let Some(ctx) = context {
448 let pii = ctx.pii_fields();
449 for step in &plan.steps {
450 match step {
451 Primitive::RemoveField(r) if pii.iter().any(|p| *p == r.field) => {
452 return RiskLevel::Critical;
453 }
454 Primitive::RenameField(r) if pii.iter().any(|p| *p == r.from) => {
455 return RiskLevel::Critical;
456 }
457 Primitive::ChangeFieldType(c) if pii.iter().any(|p| *p == c.field) => {
458 return RiskLevel::Critical;
459 }
460 _ => {}
461 }
462 }
463 }
464
465 let mut max = RiskLevel::Low;
466 for step in &plan.steps {
467 let r = per_step_risk(step);
468 if r > max {
469 max = r;
470 }
471 }
472 let mixes_add_and_remove = impact.adds_fields > 0 && impact.removes_fields > 0;
474 if mixes_add_and_remove && max < RiskLevel::High {
475 max = RiskLevel::High;
476 }
477 max
478}
479
480pub fn warnings_for(plan: &Plan, context: Option<&ContextConfig>) -> Vec<String> {
489 use crate::ai::OnDelete;
490 let mut out: Vec<String> = Vec::new();
491 let mut has_remove = false;
492 let mut has_rename_model = false;
493 let mut has_rename_field = false;
494 let mut has_type_change = false;
495 let mut has_require = false;
496 let mut has_remove_model = false;
497 let mut has_dev_only = false;
498
499 for step in &plan.steps {
500 match step {
501 Primitive::RemoveField(_) => has_remove = true,
502 Primitive::RenameModel(_) => has_rename_model = true,
503 Primitive::RenameField(_) => has_rename_field = true,
504 Primitive::ChangeFieldType(_) => has_type_change = true,
505 Primitive::ChangeFieldNullability(c) if !c.nullable => has_require = true,
506 Primitive::RemoveModel(_) => has_remove_model = true,
507 Primitive::AddRelation(r) => {
510 if r.required {
511 out.push(format!(
512 "Relation `{model}.{via}` → `{to}` is required (NOT NULL FK). \
513 Existing rows with no matching parent will prevent the \
514 migration; use `rustio migrate add-fks --write` to retrofit \
515 via recreate-table instead of ALTER TABLE.",
516 model = r.from,
517 via = r.via,
518 to = r.to,
519 ));
520 }
521 if matches!(r.on_delete, OnDelete::Cascade) {
522 out.push(format!(
523 "Relation `{model}.{via}` uses ON DELETE CASCADE: deleting a \
524 single `{to}` row will delete every `{model}` row that \
525 points at it. Review the blast radius before execution.",
526 model = r.from,
527 via = r.via,
528 to = r.to,
529 ));
530 }
531 }
532 _ => {}
533 }
534 if step.is_developer_only() {
535 has_dev_only = true;
536 }
537 }
538 if has_remove {
539 out.push("This plan removes a field. Existing data in that column may become inaccessible after execution.".into());
540 }
541 if has_remove_model {
542 out.push("This plan removes a model. Every row, foreign-key reference, and admin route for that model will be dropped.".into());
543 }
544 if has_rename_model {
545 out.push("This plan renames a model. Downstream code, admin URLs, and external integrations that hard-code the old name will break.".into());
546 }
547 if has_rename_field {
548 out.push("This plan renames a field. Queries, serialised payloads, and UI references using the old name will break.".into());
549 }
550 if has_require {
551 out.push("This plan changes a field from nullable to required. Rows with a NULL in that column will fail to load after execution.".into());
552 }
553 if has_type_change {
554 out.push("This plan changes a field's type. The executor may refuse conversions it considers lossy.".into());
555 }
556 if has_type_change || has_require {
557 out.push("This operation rewrites the entire table. Large tables may cause downtime during execution.".into());
559 }
560 if plan.steps.len() > 1 {
561 out.push(format!(
562 "This plan performs {n} operations. Review each step individually.",
563 n = plan.steps.len(),
564 ));
565 }
566 if has_dev_only {
567 out.push("This plan contains a developer-only primitive. It must never be executed from an AI pipeline.".into());
568 }
569
570 if let Some(ctx) = context {
573 let pii = ctx.pii_fields();
574 for step in &plan.steps {
575 match step {
576 Primitive::RemoveField(r) if pii.iter().any(|p| *p == r.field) => {
577 out.push(format!(
578 "Field `{}.{}` is considered sensitive personal data under the project's context{}. Removing it is irreversible — review retention obligations first.",
579 r.model,
580 r.field,
581 describe_context(ctx),
582 ));
583 }
584 Primitive::RenameField(r) if pii.iter().any(|p| *p == r.from) => {
585 out.push(format!(
586 "Field `{}.{}` is sensitive personal data; renaming it invalidates any existing access-log / audit trail keyed on the old name.",
587 r.model, r.from,
588 ));
589 }
590 Primitive::ChangeFieldType(c) if pii.iter().any(|p| *p == c.field) => {
591 out.push(format!(
592 "Field `{}.{}` is sensitive personal data; type changes may affect hashing, masking, or retention pipelines keyed on its storage shape.",
593 c.model, c.field,
594 ));
595 }
596 _ => {}
597 }
598 }
599 if let Some(schema) = ctx.industry_schema() {
602 for step in &plan.steps {
603 if let Primitive::RemoveField(r) = step {
604 if schema.required_fields.iter().any(|f| f == &r.field) {
605 out.push(format!(
606 "Field `{}.{}` is a standard convention for the `{}` industry. Removing it will break downstream integrations that assume it exists.",
607 r.model,
608 r.field,
609 ctx.industry.as_deref().unwrap_or(""),
610 ));
611 }
612 }
613 }
614 }
615 }
616
617 out
618}
619
620fn describe_context(ctx: &ContextConfig) -> String {
623 let mut parts: Vec<String> = Vec::new();
624 if let Some(c) = &ctx.country {
625 parts.push(format!("country={c}"));
626 }
627 if let Some(i) = &ctx.industry {
628 parts.push(format!("industry={i}"));
629 }
630 if ctx.requires_gdpr() {
631 parts.push("GDPR".to_string());
632 }
633 if parts.is_empty() {
634 String::new()
635 } else {
636 format!(" ({})", parts.join(", "))
637 }
638}
639
640pub fn render_review_human(review: &PlanReview, header: Option<&ReviewHeader>) -> String {
646 let mut out = String::new();
647 out.push_str("Plan review\n");
648 if let Some(h) = header {
649 if let Some(p) = &h.prompt {
650 out.push_str(&format!("\nPrompt:\n {p}\n"));
651 }
652 if let Some(e) = &h.explanation {
653 out.push_str(&format!("\nExplanation:\n {e}\n"));
654 }
655 if let Some(src) = &h.source {
656 out.push_str(&format!("\nSource:\n {src}\n"));
657 }
658 }
659 out.push_str(&format!("\nRisk:\n {}\n", review.risk.as_str()));
660 out.push_str("\nImpact:\n");
661 for line in render_impact_lines(&review.impact) {
662 out.push_str(" - ");
663 out.push_str(&line);
664 out.push('\n');
665 }
666 out.push_str("\nPlanned changes:\n");
667 if review.plan.steps.is_empty() {
668 out.push_str(" - (none)\n");
669 } else {
670 for step in &review.plan.steps {
671 out.push_str(" - ");
672 out.push_str(&summarise_primitive(step));
673 out.push('\n');
674 }
675 }
676 out.push_str("\nValidation:\n");
677 match &review.validation {
678 ValidationOutcome::Valid => out.push_str(" - Passes against the current schema.\n"),
679 ValidationOutcome::Invalid { step, reason } => {
680 out.push_str(&format!(
681 " - FAILS at step {step}: {reason}\n",
682 step = step,
683 reason = reason,
684 ));
685 out.push_str(" - The plan is stale or invalid for the current schema. Regenerate it before executing.\n");
686 }
687 }
688 out.push_str("\nWarnings:\n");
689 if review.warnings.is_empty() {
690 out.push_str(" - None\n");
691 } else {
692 for w in &review.warnings {
693 out.push_str(" - ");
694 out.push_str(w);
695 out.push('\n');
696 }
697 }
698 out
699}
700
701#[derive(Debug, Default, Clone)]
705pub struct ReviewHeader {
706 pub prompt: Option<String>,
707 pub explanation: Option<String>,
708 pub source: Option<String>,
709}
710
711pub fn render_plan_document_json(doc: &PlanDocument) -> Result<String, ReviewError> {
716 let mut out =
717 serde_json::to_string_pretty(doc).map_err(|e| ReviewError::Parse(e.to_string()))?;
718 out.push('\n');
719 Ok(out)
720}
721
722fn simulate_plan(plan: &Plan, schema: &Schema) -> Result<(), (usize, PrimitiveError)> {
730 let mut state = schema.clone();
731 for (idx, step) in plan.steps.iter().enumerate() {
732 if step.is_developer_only() {
733 return Err((
734 idx,
735 PrimitiveError::DeveloperOnlyNotAllowedInPlan { op: step.op_name() },
736 ));
737 }
738 if let Err(e) = super::validate_primitive(step) {
739 return Err((idx, e));
740 }
741 if let Err(e) = validate_against(step, &state) {
742 return Err((idx, e));
743 }
744 apply_shadow_for_review(step, &mut state);
745 }
746 Ok(())
747}
748
749fn apply_shadow_for_review(p: &Primitive, schema: &mut Schema) {
755 use crate::schema::{SchemaField, SchemaRelation};
756 match p {
757 Primitive::AddModel(m) => {
758 let mut fields: Vec<SchemaField> = m
759 .fields
760 .iter()
761 .map(|f| SchemaField {
762 name: f.name.clone(),
763 ty: f.ty.clone(),
764 nullable: f.nullable,
765 editable: f.editable,
766 relation: None,
767 })
768 .collect();
769 fields.sort_by(|a, b| a.name.cmp(&b.name));
770 schema.models.push(SchemaModel {
771 name: m.name.clone(),
772 table: m.table.clone(),
773 admin_name: m.table.clone(),
774 display_name: m.name.clone(),
775 singular_name: m.name.clone(),
776 fields,
777 relations: Vec::new(),
778 core: false,
779 });
780 schema.models.sort_by(|a, b| a.name.cmp(&b.name));
781 }
782 Primitive::RemoveModel(m) => schema.models.retain(|x| x.name != m.name),
783 Primitive::AddField(af) => {
784 if let Some(model) = schema.models.iter_mut().find(|m| m.name == af.model) {
785 model.fields.push(SchemaField {
786 name: af.field.name.clone(),
787 ty: af.field.ty.clone(),
788 nullable: af.field.nullable,
789 editable: af.field.editable,
790 relation: None,
791 });
792 model.fields.sort_by(|a, b| a.name.cmp(&b.name));
793 }
794 }
795 Primitive::RemoveField(rf) => {
796 if let Some(model) = schema.models.iter_mut().find(|m| m.name == rf.model) {
797 model.fields.retain(|f| f.name != rf.field);
798 }
799 }
800 Primitive::RenameModel(rm) => {
801 if let Some(model) = schema.models.iter_mut().find(|m| m.name == rm.from) {
802 model.name = rm.to.clone();
803 model.singular_name = rm.to.clone();
804 }
805 schema.models.sort_by(|a, b| a.name.cmp(&b.name));
806 }
807 Primitive::RenameField(rf) => {
808 if let Some(model) = schema.models.iter_mut().find(|m| m.name == rf.model) {
809 if let Some(field) = model.fields.iter_mut().find(|f| f.name == rf.from) {
810 field.name = rf.to.clone();
811 }
812 model.fields.sort_by(|a, b| a.name.cmp(&b.name));
813 }
814 }
815 Primitive::ChangeFieldType(c) => {
816 if let Some(model) = schema.models.iter_mut().find(|m| m.name == c.model) {
817 if let Some(field) = model.fields.iter_mut().find(|f| f.name == c.field) {
818 field.ty = c.new_type.clone();
819 }
820 }
821 }
822 Primitive::ChangeFieldNullability(c) => {
823 if let Some(model) = schema.models.iter_mut().find(|m| m.name == c.model) {
824 if let Some(field) = model.fields.iter_mut().find(|f| f.name == c.field) {
825 field.nullable = c.nullable;
826 }
827 }
828 }
829 Primitive::AddRelation(r) => {
830 use crate::schema::{Relation, RelationKind};
831 if let Some(model) = schema.models.iter_mut().find(|m| m.name == r.from) {
832 model.relations.push(SchemaRelation {
833 kind: format!("{:?}", r.kind).to_lowercase(),
834 to: r.to.clone(),
835 via: r.via.clone(),
836 });
837 if matches!(r.kind, RelationKind::BelongsTo)
841 && !model.fields.iter().any(|f| f.name == r.via)
842 {
843 model.fields.push(SchemaField {
844 name: r.via.clone(),
845 ty: "i64".to_string(),
846 nullable: !r.required,
847 editable: true,
848 relation: Some(Relation {
849 model: r.to.clone(),
850 field: "id".to_string(),
851 kind: RelationKind::BelongsTo,
852 display_field: None,
853 required: Some(r.required),
854 on_delete: Some(r.on_delete.as_str().to_string()),
855 }),
856 });
857 model.fields.sort_by(|a, b| a.name.cmp(&b.name));
858 }
859 }
860 }
861 Primitive::RemoveRelation(r) => {
862 if let Some(model) = schema.models.iter_mut().find(|m| m.name == r.from) {
863 model.relations.retain(|rel| rel.via != r.via);
864 }
865 }
866 Primitive::UpdateAdmin(_) | Primitive::CreateMigration(_) => {}
867 }
868}
869
870fn touches_core_model(p: &Primitive, schema: &Schema) -> bool {
874 let target = match p {
875 Primitive::AddField(a) => Some(a.model.as_str()),
876 Primitive::RemoveField(r) => Some(r.model.as_str()),
877 Primitive::RenameField(r) => Some(r.model.as_str()),
878 Primitive::ChangeFieldType(c) => Some(c.model.as_str()),
879 Primitive::ChangeFieldNullability(c) => Some(c.model.as_str()),
880 Primitive::UpdateAdmin(u) => Some(u.model.as_str()),
881 Primitive::RenameModel(r) => Some(r.from.as_str()),
882 Primitive::RemoveModel(m) => Some(m.name.as_str()),
883 Primitive::AddRelation(r) => Some(r.from.as_str()),
884 Primitive::RemoveRelation(r) => Some(r.from.as_str()),
885 Primitive::AddModel(_) | Primitive::CreateMigration(_) => None,
887 };
888 let Some(name) = target else { return false };
889 schema.models.iter().any(|m| m.name == name && m.core)
890}
891
892fn per_step_risk(p: &Primitive) -> RiskLevel {
893 use crate::ai::OnDelete;
894 match p {
895 Primitive::AddField(a) if a.field.nullable => RiskLevel::Low,
897 Primitive::AddField(_) => RiskLevel::Low,
898 Primitive::AddRelation(r) => match (r.required, r.on_delete) {
905 (true, OnDelete::Cascade) => RiskLevel::High,
906 (true, _) | (_, OnDelete::Cascade) => RiskLevel::Medium,
907 _ => RiskLevel::Low,
908 },
909 Primitive::AddModel(_) => RiskLevel::Low,
910 Primitive::UpdateAdmin(_) => RiskLevel::Low,
911 Primitive::ChangeFieldNullability(c) if c.nullable => RiskLevel::Low,
913 Primitive::ChangeFieldNullability(_) => RiskLevel::High,
918 Primitive::RenameField(_) | Primitive::RenameModel(_) | Primitive::ChangeFieldType(_) => {
920 RiskLevel::Medium
921 }
922 Primitive::RemoveField(_) | Primitive::RemoveModel(_) | Primitive::RemoveRelation(_) => {
924 RiskLevel::High
925 }
926 Primitive::CreateMigration(_) => RiskLevel::Critical,
928 }
929}
930
931fn summarise_primitive(p: &Primitive) -> String {
934 match p {
935 Primitive::AddField(a) => format!(
936 "Add field \"{}\" ({}{}) to model \"{}\"",
937 a.field.name,
938 a.field.ty,
939 if a.field.nullable { ", nullable" } else { "" },
940 a.model,
941 ),
942 Primitive::RemoveField(r) => {
943 format!("Remove field \"{}\" from model \"{}\"", r.field, r.model)
944 }
945 Primitive::RenameField(r) => {
946 format!("Rename field \"{}.{}\" to \"{}\"", r.model, r.from, r.to)
947 }
948 Primitive::RenameModel(r) => {
949 format!("Rename model \"{}\" to \"{}\"", r.from, r.to)
950 }
951 Primitive::ChangeFieldType(c) => format!(
952 "Change type of \"{}.{}\" to {}",
953 c.model, c.field, c.new_type
954 ),
955 Primitive::ChangeFieldNullability(c) => format!(
956 "Mark \"{}.{}\" as {}",
957 c.model,
958 c.field,
959 if c.nullable { "nullable" } else { "required" },
960 ),
961 Primitive::AddModel(m) => format!(
962 "Add model \"{}\" ({} field{})",
963 m.name,
964 m.fields.len(),
965 if m.fields.len() == 1 { "" } else { "s" }
966 ),
967 Primitive::RemoveModel(m) => format!("Remove model \"{}\"", m.name),
968 Primitive::AddRelation(r) => format!(
969 "Add relation {:?}: {}.{} -> {}",
970 r.kind, r.from, r.via, r.to
971 ),
972 Primitive::RemoveRelation(r) => {
973 format!("Remove relation \"{}.{}\"", r.from, r.via)
974 }
975 Primitive::UpdateAdmin(u) => format!(
976 "Update admin attribute \"{}.{}\".{}",
977 u.model, u.field, u.attr
978 ),
979 Primitive::CreateMigration(m) => format!("[dev-only] create_migration \"{}\"", m.name),
980 }
981}
982
983fn render_impact_lines(i: &PlanImpact) -> Vec<String> {
986 let mut lines: Vec<String> = Vec::new();
987 push_count_line(&mut lines, "Add", i.adds_fields, "field");
988 push_count_line(&mut lines, "Remove", i.removes_fields, "field");
989 push_count_line(&mut lines, "Rename", i.renames, "item");
990 push_count_line(&mut lines, "Type change", i.type_changes, "field");
991 push_count_line(
992 &mut lines,
993 "Nullability change",
994 i.nullability_changes,
995 "field",
996 );
997 if i.destructive {
998 lines.push("Includes at least one destructive step".into());
999 } else {
1000 lines.push("No destructive changes".into());
1001 }
1002 if i.touches_core_models {
1003 lines.push("Touches a core model — review carefully".into());
1004 } else {
1005 lines.push("Does not touch core models".into());
1006 }
1007 lines
1008}
1009
1010fn push_count_line(out: &mut Vec<String>, verb: &str, n: usize, unit: &str) {
1011 if n == 0 {
1012 return;
1013 }
1014 out.push(format!(
1015 "{verb} {n} {unit}{s}",
1016 s = if n == 1 { "" } else { "s" }
1017 ));
1018}