1use crate::model::*;
2use std::collections::{BTreeMap, BTreeSet};
3use std::fmt::Write;
4
5pub 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 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 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 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 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 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 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 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 let _ = writeln!(md, "<!-- pecto -->");
186
187 md
188}
189
190struct 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)>, removed: Vec<(String, String)>,
241}
242
243fn 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 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 })
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}