Skip to main content

rustio_core/ai_gen/
diff.rs

1//! Phase 8.1 — minimal schema-diff for the AI update flow.
2//!
3//! Used ONLY by the CLI to render a human-readable change-list before
4//! the operator confirms a write. Intentionally small: model + field
5//! adds / removes, plus a flag when a field gains or loses a `Relation`.
6//! No deep structural diff (type changes, nullability flips, rename
7//! detection) — keep the surface narrow so the operator can read the
8//! whole thing in one screen.
9
10use crate::schema::{Schema, SchemaField, SchemaModel};
11
12/// One human-readable change line between two schemas. Variant order
13/// is the print order: model adds first, then per-model field churn,
14/// then model removes (least surprising scan).
15#[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
65/// Compute the change-list between two schemas. Order:
66///   1. ModelAdded (new models surface their full field-add list too)
67///   2. Per-model field add/remove + relation add/remove for fields
68///      that exist in both versions
69///   3. ModelRemoved (last so the human eye sees additions before
70///      destructive lines)
71pub fn diff(old: &Schema, new: &Schema) -> Vec<Change> {
72    let mut out: Vec<Change> = Vec::new();
73
74    // Models added (in new, not in old by name).
75    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            // Also surface every field of the new model as added so
79            // the operator sees the full shape of what just landed.
80            for f in &m.fields {
81                out.push(field_added_change(&m.name, f));
82            }
83        }
84    }
85
86    // Per-model field churn for models in both versions.
87    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    // Models removed (last per print-order rationale above).
95    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    // Field added in new (and not present in old by name).
106    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    // Field removed (in old, not in new).
112    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    // Relation churn on fields that exist in both versions: relation
121    // gained, relation dropped. (Relation target rename is not
122    // reported as a separate event — it surfaces as drop+add in the
123    // unlikely case the model rewrites the relation block.)
124    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
151/// Pretty-print a change list to a single string. Empty list yields
152/// the literal string `"(no changes)"` so the CLI can show *something*
153/// rather than printing a blank section.
154pub 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    /// Hand-build a minimal schema fixture. Models / fields are slice
174    /// of (name, ty) pairs; the helper fills the boilerplate
175    /// (table = name lowercased, admin/display/singular = name).
176    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    /// Phase 8.1 — adding a brand-new model surfaces both the model
206    /// add AND each of its fields as adds, so the operator sees the
207    /// full landed shape on screen.
208    #[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    /// Phase 8.1 — fields that survive a diff must not surface as
225    /// added or removed. Locks the preserve-by-default contract on
226    /// the diff side (the prompt enforces it on the model side).
227    #[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        // No FieldRemoved for any of {id, title, body}.
234        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        // status is the only addition.
243        assert_eq!(
244            changes.iter().filter(|c| matches!(c, Change::FieldAdded { .. })).count(),
245            1
246        );
247    }
248
249    /// Phase 8.1 — render() output is deterministic and matches the
250    /// CLI display. Locks the spec example shape:
251    ///   + Model added: <Name>
252    ///   + Field added: <Model>.<field> (<type>)
253    ///   - Field removed: <Model>.<field>
254    ///   + Relation added: <Model>.<field> → <target>
255    #[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        // Add a relation block on Post.author_id in `new` so the
266        // FieldAdded line includes the "→ User" target arrow.
267        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        // Add a field that exists in both versions but gains a
280        // relation in `new`. Pre-existing field: Post.title was
281        // present in both — but a String field can't gain a relation,
282        // so use a synthetic id field (Post.editor_id) added to old
283        // first, then carry it into new with a relation block.
284        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        // Spec example shape — at least these load-bearing lines must
310        // be present (order matches the diff() print rationale).
311        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    /// Empty diff renders a placeholder string instead of nothing —
334    /// the CLI prints under a "Changes:" header and a blank section
335    /// would look like a UI bug.
336    #[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}