1use crate::schema::{Schema, SchemaField, SchemaModel};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum Change {
17 ModelAdded(String),
18 ModelRemoved(String),
19 FieldAdded {
20 model: String,
21 field: String,
22 ty: String,
23 relation: Option<String>,
24 },
25 FieldRemoved {
26 model: String,
27 field: String,
28 },
29 RelationAdded {
30 model: String,
31 field: String,
32 target: String,
33 },
34 RelationRemoved {
35 model: String,
36 field: String,
37 },
38}
39
40impl std::fmt::Display for Change {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 match self {
43 Self::ModelAdded(name) => write!(f, "+ Model added: {name}"),
44 Self::ModelRemoved(name) => write!(f, "- Model removed: {name}"),
45 Self::FieldAdded { model, field, ty, relation } => {
46 if let Some(target) = relation {
47 write!(f, "+ Field added: {model}.{field} ({ty}) → {target}")
48 } else {
49 write!(f, "+ Field added: {model}.{field} ({ty})")
50 }
51 }
52 Self::FieldRemoved { model, field } => {
53 write!(f, "- Field removed: {model}.{field}")
54 }
55 Self::RelationAdded { model, field, target } => {
56 write!(f, "+ Relation added: {model}.{field} → {target}")
57 }
58 Self::RelationRemoved { model, field } => {
59 write!(f, "- Relation removed: {model}.{field}")
60 }
61 }
62 }
63}
64
65pub fn diff(old: &Schema, new: &Schema) -> Vec<Change> {
72 let mut out: Vec<Change> = Vec::new();
73
74 for m in &new.models {
76 if !old.models.iter().any(|o| o.name == m.name) {
77 out.push(Change::ModelAdded(m.name.clone()));
78 for f in &m.fields {
81 out.push(field_added_change(&m.name, f));
82 }
83 }
84 }
85
86 for new_model in &new.models {
88 let Some(old_model) = old.models.iter().find(|o| o.name == new_model.name) else {
89 continue;
90 };
91 diff_fields(old_model, new_model, &mut out);
92 }
93
94 for o in &old.models {
96 if !new.models.iter().any(|n| n.name == o.name) {
97 out.push(Change::ModelRemoved(o.name.clone()));
98 }
99 }
100
101 out
102}
103
104fn diff_fields(old: &SchemaModel, new: &SchemaModel, out: &mut Vec<Change>) {
105 for f in &new.fields {
107 if !old.fields.iter().any(|of| of.name == f.name) {
108 out.push(field_added_change(&new.name, f));
109 }
110 }
111 for of in &old.fields {
113 if !new.fields.iter().any(|f| f.name == of.name) {
114 out.push(Change::FieldRemoved {
115 model: new.name.clone(),
116 field: of.name.clone(),
117 });
118 }
119 }
120 for f in &new.fields {
125 if let Some(of) = old.fields.iter().find(|of| of.name == f.name) {
126 match (&of.relation, &f.relation) {
127 (None, Some(rel)) => out.push(Change::RelationAdded {
128 model: new.name.clone(),
129 field: f.name.clone(),
130 target: rel.model.clone(),
131 }),
132 (Some(_), None) => out.push(Change::RelationRemoved {
133 model: new.name.clone(),
134 field: f.name.clone(),
135 }),
136 _ => {}
137 }
138 }
139 }
140}
141
142fn field_added_change(model: &str, f: &SchemaField) -> Change {
143 Change::FieldAdded {
144 model: model.to_string(),
145 field: f.name.clone(),
146 ty: f.ty.clone(),
147 relation: f.relation.as_ref().map(|r| r.model.clone()),
148 }
149}
150
151pub fn render(changes: &[Change]) -> String {
155 if changes.is_empty() {
156 return "(no changes)".to_string();
157 }
158 let mut out = String::new();
159 for (i, c) in changes.iter().enumerate() {
160 if i > 0 {
161 out.push('\n');
162 }
163 out.push_str(&c.to_string());
164 }
165 out
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171 use crate::schema::{Relation, RelationKind, Schema, SchemaField, SchemaModel, SCHEMA_VERSION};
172
173 fn schema_of(models: &[(&str, &[(&str, &str)])]) -> Schema {
177 Schema {
178 version: SCHEMA_VERSION,
179 rustio_version: "1.0.0".into(),
180 models: models
181 .iter()
182 .map(|(name, fields)| SchemaModel {
183 name: (*name).to_string(),
184 table: name.to_lowercase(),
185 admin_name: name.to_lowercase(),
186 display_name: (*name).to_string(),
187 singular_name: (*name).to_string(),
188 fields: fields
189 .iter()
190 .map(|(fname, ty)| SchemaField {
191 name: (*fname).to_string(),
192 ty: (*ty).to_string(),
193 nullable: false,
194 editable: true,
195 relation: None,
196 })
197 .collect(),
198 relations: vec![],
199 core: false,
200 })
201 .collect(),
202 }
203 }
204
205 #[test]
209 fn diff_reports_added_model_with_fields() {
210 let old = schema_of(&[("Post", &[("id", "i64"), ("title", "String")])]);
211 let new = schema_of(&[
212 ("Post", &[("id", "i64"), ("title", "String")]),
213 ("Tag", &[("id", "i64"), ("label", "String")]),
214 ]);
215
216 let changes = diff(&old, &new);
217 assert!(changes.contains(&Change::ModelAdded("Tag".into())));
218 assert!(changes.iter().any(|c| matches!(c,
219 Change::FieldAdded { model, field, ty, .. }
220 if model == "Tag" && field == "label" && ty == "String"
221 )));
222 }
223
224 #[test]
228 fn diff_preserves_existing_fields() {
229 let old = schema_of(&[("Post", &[("id", "i64"), ("title", "String"), ("body", "String")])]);
230 let new = schema_of(&[("Post", &[("id", "i64"), ("title", "String"), ("body", "String"), ("status", "String")])]);
231
232 let changes = diff(&old, &new);
233 for surviving in ["id", "title", "body"] {
235 assert!(
236 !changes.iter().any(|c| matches!(c,
237 Change::FieldRemoved { field, .. } if field == surviving
238 )),
239 "preserved field {surviving} surfaced as removed: {changes:?}"
240 );
241 }
242 assert_eq!(
244 changes.iter().filter(|c| matches!(c, Change::FieldAdded { .. })).count(),
245 1
246 );
247 }
248
249 #[test]
256 fn diff_output_correct() {
257 let mut old = schema_of(&[
258 ("Post", &[("id", "i64"), ("title", "String"), ("summary", "String")]),
259 ]);
260 let mut new = schema_of(&[
261 ("Post", &[("id", "i64"), ("title", "String"), ("status", "String"), ("author_id", "i64")]),
262 ("Tag", &[("id", "i64")]),
263 ]);
264
265 if let Some(p) = new.models.iter_mut().find(|m| m.name == "Post") {
268 if let Some(f) = p.fields.iter_mut().find(|f| f.name == "author_id") {
269 f.relation = Some(Relation {
270 model: "User".into(),
271 field: "id".into(),
272 kind: RelationKind::BelongsTo,
273 display_field: None,
274 required: None,
275 on_delete: None,
276 });
277 }
278 }
279 old.models[0].fields.push(SchemaField {
285 name: "editor_id".into(),
286 ty: "i64".into(),
287 nullable: false,
288 editable: true,
289 relation: None,
290 });
291 new.models[0].fields.push(SchemaField {
292 name: "editor_id".into(),
293 ty: "i64".into(),
294 nullable: false,
295 editable: true,
296 relation: Some(Relation {
297 model: "User".into(),
298 field: "id".into(),
299 kind: RelationKind::BelongsTo,
300 display_field: None,
301 required: None,
302 on_delete: None,
303 }),
304 });
305
306 let changes = diff(&old, &new);
307 let rendered = render(&changes);
308
309 assert!(
312 rendered.contains("+ Model added: Tag"),
313 "missing model-added line; got:\n{rendered}"
314 );
315 assert!(
316 rendered.contains("+ Field added: Post.status (String)"),
317 "missing simple field-added line; got:\n{rendered}"
318 );
319 assert!(
320 rendered.contains("+ Field added: Post.author_id (i64) → User"),
321 "missing field-added-with-relation arrow; got:\n{rendered}"
322 );
323 assert!(
324 rendered.contains("- Field removed: Post.summary"),
325 "missing field-removed line; got:\n{rendered}"
326 );
327 assert!(
328 rendered.contains("+ Relation added: Post.editor_id → User"),
329 "missing relation-added line; got:\n{rendered}"
330 );
331 }
332
333 #[test]
337 fn empty_diff_renders_placeholder() {
338 let s = schema_of(&[("Post", &[("id", "i64")])]);
339 assert_eq!(render(&diff(&s, &s)), "(no changes)");
340 }
341}