Skip to main content

pecto_core/
pr_diff.rs

1use crate::model::*;
2use std::collections::{BTreeMap, BTreeSet};
3use std::fmt::Write;
4
5/// Generate a GitHub-flavored Markdown diff between two ProjectSpecs.
6pub fn generate_pr_diff(base: &ProjectSpec, head: &ProjectSpec) -> String {
7    let mut md = String::new();
8
9    let ep_diff = diff_endpoints(base, head);
10    let ent_diff = diff_entities(base, head);
11    let dep_diff = diff_dependencies(base, head);
12    let flow_diff = diff_flows(base, head);
13    let cap_diff = diff_capabilities(base, head);
14
15    let has_changes = !ep_diff.added.is_empty()
16        || !ep_diff.removed.is_empty()
17        || !ep_diff.modified.is_empty()
18        || !ent_diff.added.is_empty()
19        || !ent_diff.removed.is_empty()
20        || !ent_diff.modified.is_empty()
21        || !dep_diff.added.is_empty()
22        || !dep_diff.removed.is_empty()
23        || !flow_diff.added.is_empty()
24        || !flow_diff.removed.is_empty()
25        || !flow_diff.modified.is_empty()
26        || !cap_diff.added.is_empty()
27        || !cap_diff.removed.is_empty();
28
29    // Header
30    let _ = writeln!(md, "## ๐Ÿ” pecto โ€” Behavior Diff\n");
31
32    if !has_changes {
33        let _ = writeln!(md, "No behavior changes detected.\n");
34        let _ = writeln!(md, "<!-- pecto -->");
35        return md;
36    }
37
38    // Summary line
39    let mut summary_parts = Vec::new();
40    let ep_total = ep_diff.added.len() + ep_diff.modified.len() + ep_diff.removed.len();
41    if ep_total > 0 {
42        summary_parts.push(format_summary_part(
43            "endpoint",
44            ep_diff.added.len(),
45            ep_diff.modified.len(),
46            ep_diff.removed.len(),
47        ));
48    }
49    let ent_total = ent_diff.added.len() + ent_diff.modified.len() + ent_diff.removed.len();
50    if ent_total > 0 {
51        summary_parts.push(format_summary_part(
52            "entity",
53            ent_diff.added.len(),
54            ent_diff.modified.len(),
55            ent_diff.removed.len(),
56        ));
57    }
58    let dep_total = dep_diff.added.len() + dep_diff.removed.len();
59    if dep_total > 0 {
60        let mut s = Vec::new();
61        if !dep_diff.added.is_empty() {
62            s.push(format!("**{}** added", dep_diff.added.len()));
63        }
64        if !dep_diff.removed.is_empty() {
65            s.push(format!("**{}** removed", dep_diff.removed.len()));
66        }
67        summary_parts.push(format!("{} (dependencies)", s.join(", ")));
68    }
69    if !cap_diff.added.is_empty() || !cap_diff.removed.is_empty() {
70        let mut s = Vec::new();
71        if !cap_diff.added.is_empty() {
72            s.push(format!("**{}** added", cap_diff.added.len()));
73        }
74        if !cap_diff.removed.is_empty() {
75            s.push(format!("**{}** removed", cap_diff.removed.len()));
76        }
77        summary_parts.push(format!("{} (capabilities)", s.join(", ")));
78    }
79
80    if !summary_parts.is_empty() {
81        let _ = writeln!(md, "{}\n", summary_parts.join(" ยท "));
82    }
83
84    // Capabilities section (new/removed)
85    if !cap_diff.added.is_empty() || !cap_diff.removed.is_empty() {
86        let _ = writeln!(md, "### Capabilities\n");
87        let _ = writeln!(md, "| Status | Name | Type |");
88        let _ = writeln!(md, "|--------|------|------|");
89        for (name, cap_type) in &cap_diff.added {
90            let _ = writeln!(md, "| ๐Ÿ†• | `{}` | {} |", name, cap_type);
91        }
92        for (name, cap_type) in &cap_diff.removed {
93            let _ = writeln!(md, "| ๐Ÿ—‘๏ธ | ~~`{}`~~ | {} |", name, cap_type);
94        }
95        let _ = writeln!(md);
96    }
97
98    // Endpoints section
99    if !ep_diff.added.is_empty() || !ep_diff.removed.is_empty() || !ep_diff.modified.is_empty() {
100        let _ = writeln!(md, "### Endpoints\n");
101        let _ = writeln!(md, "| Status | Method | Path | Capability |");
102        let _ = writeln!(md, "|--------|--------|------|------------|");
103        for ep in &ep_diff.added {
104            let _ = writeln!(
105                md,
106                "| ๐Ÿ†• | **{}** | `{}` | {} |",
107                ep.method, ep.path, ep.capability
108            );
109        }
110        for ep in &ep_diff.modified {
111            let _ = writeln!(
112                md,
113                "| โœ๏ธ | **{}** | `{}` | {} |",
114                ep.method, ep.path, ep.capability
115            );
116        }
117        for ep in &ep_diff.removed {
118            let _ = writeln!(
119                md,
120                "| ๐Ÿ—‘๏ธ | ~~**{}**~~ | ~~`{}`~~ | {} |",
121                ep.method, ep.path, ep.capability
122            );
123        }
124        let _ = writeln!(md);
125    }
126
127    // Entities section
128    if !ent_diff.added.is_empty() || !ent_diff.removed.is_empty() || !ent_diff.modified.is_empty() {
129        let _ = writeln!(md, "### Entities\n");
130        let _ = writeln!(md, "| Status | Entity | Details |");
131        let _ = writeln!(md, "|--------|--------|---------|");
132        for ent in &ent_diff.added {
133            let _ = writeln!(md, "| ๐Ÿ†• | `{}` | {} fields |", ent.name, ent.detail);
134        }
135        for ent in &ent_diff.modified {
136            let _ = writeln!(md, "| โœ๏ธ | `{}` | {} |", ent.name, ent.detail);
137        }
138        for ent in &ent_diff.removed {
139            let _ = writeln!(md, "| ๐Ÿ—‘๏ธ | ~~`{}`~~ | removed |", ent.name);
140        }
141        let _ = writeln!(md);
142    }
143
144    // Dependencies section
145    if !dep_diff.added.is_empty() || !dep_diff.removed.is_empty() {
146        let _ = writeln!(md, "### Dependencies\n");
147        let _ = writeln!(md, "| Status | From | | To | Kind |");
148        let _ = writeln!(md, "|--------|------|-|-----|------|");
149        for dep in &dep_diff.added {
150            let _ = writeln!(
151                md,
152                "| ๐Ÿ†• | `{}` | โ†’ | `{}` | {} |",
153                dep.from, dep.to, dep.kind
154            );
155        }
156        for dep in &dep_diff.removed {
157            let _ = writeln!(
158                md,
159                "| ๐Ÿ—‘๏ธ | ~~`{}`~~ | โ†’ | ~~`{}`~~ | {} |",
160                dep.from, dep.to, dep.kind
161            );
162        }
163        let _ = writeln!(md);
164    }
165
166    // Flow changes section
167    if !flow_diff.added.is_empty()
168        || !flow_diff.removed.is_empty()
169        || !flow_diff.modified.is_empty()
170    {
171        let _ = writeln!(md, "### Flow Changes\n");
172        for f in &flow_diff.added {
173            let _ = writeln!(md, "- ๐Ÿ†• **{}**: new flow ({} steps)", f.trigger, f.detail);
174        }
175        for f in &flow_diff.modified {
176            let _ = writeln!(md, "- โœ๏ธ **{}**: {}", f.trigger, f.detail);
177        }
178        for f in &flow_diff.removed {
179            let _ = writeln!(md, "- ๐Ÿ—‘๏ธ ~~**{}**~~: flow removed", f.trigger);
180        }
181        let _ = writeln!(md);
182    }
183
184    // Marker for GitHub Action update-mode
185    let _ = writeln!(md, "<!-- pecto -->");
186
187    md
188}
189
190// โ”€โ”€โ”€ Diff types โ”€โ”€โ”€
191
192struct EndpointDiff {
193    added: Vec<EndpointEntry>,
194    removed: Vec<EndpointEntry>,
195    modified: Vec<EndpointEntry>,
196}
197
198#[derive(Clone)]
199struct EndpointEntry {
200    method: String,
201    path: String,
202    capability: String,
203}
204
205struct EntityDiff {
206    added: Vec<EntityEntry>,
207    removed: Vec<EntityEntry>,
208    modified: Vec<EntityEntry>,
209}
210
211struct EntityEntry {
212    name: String,
213    detail: String,
214}
215
216struct DepDiff {
217    added: Vec<DepEntry>,
218    removed: Vec<DepEntry>,
219}
220
221struct DepEntry {
222    from: String,
223    to: String,
224    kind: String,
225}
226
227struct FlowDiff {
228    added: Vec<FlowEntry>,
229    removed: Vec<FlowEntry>,
230    modified: Vec<FlowEntry>,
231}
232
233struct FlowEntry {
234    trigger: String,
235    detail: String,
236}
237
238struct CapDiff {
239    added: Vec<(String, String)>, // (name, type)
240    removed: Vec<(String, String)>,
241}
242
243// โ”€โ”€โ”€ Diff logic โ”€โ”€โ”€
244
245fn diff_endpoints(base: &ProjectSpec, head: &ProjectSpec) -> EndpointDiff {
246    let base_eps = collect_endpoints(base);
247    let head_eps = collect_endpoints(head);
248
249    let base_keys: BTreeSet<_> = base_eps.keys().collect();
250    let head_keys: BTreeSet<_> = head_eps.keys().collect();
251
252    let added = head_keys
253        .difference(&base_keys)
254        .map(|k| head_eps[*k].clone())
255        .collect();
256    let removed = base_keys
257        .difference(&head_keys)
258        .map(|k| base_eps[*k].clone())
259        .collect();
260
261    // Modified: same key but different behaviors/security/validation
262    let modified = base_keys
263        .intersection(&head_keys)
264        .filter(|k| {
265            let b = &base_eps[**k];
266            let h = &head_eps[**k];
267            b.capability != h.capability // capability moved
268        })
269        .map(|k| head_eps[*k].clone())
270        .collect();
271
272    EndpointDiff {
273        added,
274        removed,
275        modified,
276    }
277}
278
279fn collect_endpoints(spec: &ProjectSpec) -> BTreeMap<String, EndpointEntry> {
280    let mut map = BTreeMap::new();
281    for cap in &spec.capabilities {
282        for ep in &cap.endpoints {
283            let method = format!("{:?}", ep.method).to_uppercase();
284            let key = format!("{} {}", method, ep.path);
285            map.insert(
286                key,
287                EndpointEntry {
288                    method,
289                    path: ep.path.clone(),
290                    capability: cap.name.clone(),
291                },
292            );
293        }
294    }
295    map
296}
297
298fn diff_entities(base: &ProjectSpec, head: &ProjectSpec) -> EntityDiff {
299    let base_ents = collect_entities(base);
300    let head_ents = collect_entities(head);
301
302    let base_keys: BTreeSet<_> = base_ents.keys().cloned().collect();
303    let head_keys: BTreeSet<_> = head_ents.keys().cloned().collect();
304
305    let added = head_keys
306        .difference(&base_keys)
307        .map(|name| {
308            let fields = &head_ents[name];
309            EntityEntry {
310                name: name.clone(),
311                detail: fields.len().to_string(),
312            }
313        })
314        .collect();
315
316    let removed = base_keys
317        .difference(&head_keys)
318        .map(|name| EntityEntry {
319            name: name.clone(),
320            detail: String::new(),
321        })
322        .collect();
323
324    let modified = base_keys
325        .intersection(&head_keys)
326        .filter_map(|name| {
327            let base_fields: BTreeSet<_> = base_ents[name].iter().cloned().collect();
328            let head_fields: BTreeSet<_> = head_ents[name].iter().cloned().collect();
329            if base_fields == head_fields {
330                return None;
331            }
332            let added: Vec<_> = head_fields.difference(&base_fields).collect();
333            let removed: Vec<_> = base_fields.difference(&head_fields).collect();
334            let mut parts = Vec::new();
335            for f in &added {
336                parts.push(format!("+{}", f));
337            }
338            for f in &removed {
339                parts.push(format!("-{}", f));
340            }
341            Some(EntityEntry {
342                name: name.clone(),
343                detail: parts.join(", "),
344            })
345        })
346        .collect();
347
348    EntityDiff {
349        added,
350        removed,
351        modified,
352    }
353}
354
355fn collect_entities(spec: &ProjectSpec) -> BTreeMap<String, Vec<String>> {
356    let mut map = BTreeMap::new();
357    for cap in &spec.capabilities {
358        for ent in &cap.entities {
359            let fields: Vec<String> = ent.fields.iter().map(|f| f.name.clone()).collect();
360            map.insert(ent.name.clone(), fields);
361        }
362    }
363    map
364}
365
366fn diff_dependencies(base: &ProjectSpec, head: &ProjectSpec) -> DepDiff {
367    let base_deps: BTreeSet<_> = base
368        .dependencies
369        .iter()
370        .map(|d| (d.from.clone(), d.to.clone(), format!("{:?}", d.kind)))
371        .collect();
372    let head_deps: BTreeSet<_> = head
373        .dependencies
374        .iter()
375        .map(|d| (d.from.clone(), d.to.clone(), format!("{:?}", d.kind)))
376        .collect();
377
378    let added = head_deps
379        .difference(&base_deps)
380        .map(|(f, t, k)| DepEntry {
381            from: f.clone(),
382            to: t.clone(),
383            kind: k.clone(),
384        })
385        .collect();
386    let removed = base_deps
387        .difference(&head_deps)
388        .map(|(f, t, k)| DepEntry {
389            from: f.clone(),
390            to: t.clone(),
391            kind: k.clone(),
392        })
393        .collect();
394
395    DepDiff { added, removed }
396}
397
398fn diff_flows(base: &ProjectSpec, head: &ProjectSpec) -> FlowDiff {
399    let base_flows: BTreeMap<_, _> = base.flows.iter().map(|f| (f.trigger.clone(), f)).collect();
400    let head_flows: BTreeMap<_, _> = head.flows.iter().map(|f| (f.trigger.clone(), f)).collect();
401
402    let base_keys: BTreeSet<_> = base_flows.keys().cloned().collect();
403    let head_keys: BTreeSet<_> = head_flows.keys().cloned().collect();
404
405    let added = head_keys
406        .difference(&base_keys)
407        .map(|t| {
408            let flow = head_flows[t];
409            FlowEntry {
410                trigger: t.clone(),
411                detail: count_steps(&flow.steps).to_string(),
412            }
413        })
414        .collect();
415
416    let removed = head_keys
417        .difference(&base_keys)
418        .map(|t| FlowEntry {
419            trigger: t.clone(),
420            detail: String::new(),
421        })
422        .collect();
423
424    let modified = base_keys
425        .intersection(&head_keys)
426        .filter_map(|t| {
427            let base_count = count_steps(&base_flows[t].steps);
428            let head_count = count_steps(&head_flows[t].steps);
429            if base_count == head_count {
430                return None;
431            }
432            let diff = head_count as i32 - base_count as i32;
433            let detail = if diff > 0 {
434                format!("+{} steps ({} โ†’ {})", diff, base_count, head_count)
435            } else {
436                format!("{} steps ({} โ†’ {})", diff, base_count, head_count)
437            };
438            Some(FlowEntry {
439                trigger: t.clone(),
440                detail,
441            })
442        })
443        .collect();
444
445    FlowDiff {
446        added,
447        removed,
448        modified,
449    }
450}
451
452fn count_steps(steps: &[FlowStep]) -> usize {
453    steps.iter().map(|s| 1 + count_steps(&s.children)).sum()
454}
455
456fn diff_capabilities(base: &ProjectSpec, head: &ProjectSpec) -> CapDiff {
457    let base_names: BTreeSet<_> = base.capabilities.iter().map(|c| c.name.clone()).collect();
458    let head_names: BTreeSet<_> = head.capabilities.iter().map(|c| c.name.clone()).collect();
459
460    let added = head_names
461        .difference(&base_names)
462        .map(|name| {
463            let cap = head.capabilities.iter().find(|c| &c.name == name).unwrap();
464            (name.clone(), cap_type_label(cap))
465        })
466        .collect();
467
468    let removed = base_names
469        .difference(&head_names)
470        .map(|name| {
471            let cap = base.capabilities.iter().find(|c| &c.name == name).unwrap();
472            (name.clone(), cap_type_label(cap))
473        })
474        .collect();
475
476    CapDiff { added, removed }
477}
478
479fn cap_type_label(cap: &Capability) -> String {
480    if !cap.endpoints.is_empty() {
481        "Controller".to_string()
482    } else if !cap.entities.is_empty() {
483        "Entity".to_string()
484    } else if !cap.operations.is_empty() {
485        "Service".to_string()
486    } else if !cap.scheduled_tasks.is_empty() {
487        "Scheduled".to_string()
488    } else {
489        "Other".to_string()
490    }
491}
492
493fn format_summary_part(noun: &str, added: usize, modified: usize, removed: usize) -> String {
494    let mut parts = Vec::new();
495    if added > 0 {
496        parts.push(format!("**{}** added", added));
497    }
498    if modified > 0 {
499        parts.push(format!("**{}** modified", modified));
500    }
501    if removed > 0 {
502        parts.push(format!("**{}** removed", removed));
503    }
504    let plural = if added + modified + removed != 1 {
505        "s"
506    } else {
507        ""
508    };
509    format!("{} ({}{})", parts.join(", "), noun, plural)
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    #[test]
517    fn test_no_changes() {
518        let spec = ProjectSpec::new("test".to_string());
519        let md = generate_pr_diff(&spec, &spec);
520        assert!(md.contains("No behavior changes detected"));
521        assert!(md.contains("<!-- pecto -->"));
522    }
523
524    #[test]
525    fn test_new_endpoint() {
526        let base = ProjectSpec::new("test".to_string());
527        let mut head = ProjectSpec::new("test".to_string());
528        let mut cap = Capability::new("users".to_string(), "users.py".to_string());
529        cap.endpoints.push(Endpoint {
530            method: HttpMethod::Post,
531            path: "/users".to_string(),
532            input: None,
533            validation: Vec::new(),
534            behaviors: Vec::new(),
535            security: None,
536        });
537        head.capabilities.push(cap);
538
539        let md = generate_pr_diff(&base, &head);
540        assert!(md.contains("POST"));
541        assert!(md.contains("/users"));
542        assert!(md.contains("๐Ÿ†•"));
543        assert!(md.contains("<!-- pecto -->"));
544    }
545
546    #[test]
547    fn test_new_entity() {
548        let base = ProjectSpec::new("test".to_string());
549        let mut head = ProjectSpec::new("test".to_string());
550        let mut cap = Capability::new("models".to_string(), "models.py".to_string());
551        cap.entities.push(Entity {
552            name: "User".to_string(),
553            table: "users".to_string(),
554            fields: vec![
555                EntityField {
556                    name: "id".to_string(),
557                    field_type: "int".to_string(),
558                    constraints: Vec::new(),
559                },
560                EntityField {
561                    name: "email".to_string(),
562                    field_type: "str".to_string(),
563                    constraints: Vec::new(),
564                },
565            ],
566            bases: Vec::new(),
567        });
568        head.capabilities.push(cap);
569
570        let md = generate_pr_diff(&base, &head);
571        assert!(md.contains("User"));
572        assert!(md.contains("2 fields"));
573    }
574
575    #[test]
576    fn test_removed_dependency() {
577        let mut base = ProjectSpec::new("test".to_string());
578        base.dependencies.push(DependencyEdge {
579            from: "controller".to_string(),
580            to: "service".to_string(),
581            kind: DependencyKind::Calls,
582            references: Vec::new(),
583        });
584        let head = ProjectSpec::new("test".to_string());
585
586        let md = generate_pr_diff(&base, &head);
587        assert!(md.contains("controller"));
588        assert!(md.contains("๐Ÿ—‘๏ธ"));
589    }
590}