1use crate::interner::StringInterner;
4use crate::types::{
5 ChangeType, GroupDeployAction, GroupDeployDecision, GroupDeployReason, GroupResult,
6 InternedString, ProcessedResult, RebuildReasonKind, VanishedFile,
7};
8use std::collections::{HashMap, HashSet};
9
10pub struct ComputedOutputs {
12 pub filtered_added: Vec<u32>,
14 pub filtered_copied: Vec<u32>,
16 pub filtered_deleted: Vec<u32>,
18 pub filtered_modified: Vec<u32>,
20 pub filtered_renamed: Vec<u32>,
22 pub filtered_type_changed: Vec<u32>,
24 pub filtered_unmerged: Vec<u32>,
26 pub filtered_unknown: Vec<u32>,
28 pub other_changed: Vec<u32>,
30 pub other_modified: Vec<u32>,
32 pub other_deleted: Vec<u32>,
34 pub all_changed_and_modified: Vec<u32>,
36 pub renamed_mapping: Vec<(u32, InternedString)>,
38 pub rename_split_deletions: Vec<(u32, InternedString)>,
41 pub modified_keys: Vec<InternedString>,
43 pub changed_keys: Vec<InternedString>,
45 pub group_deploy_decisions: Vec<GroupDeployDecision>,
47}
48
49impl ComputedOutputs {
50 pub fn compute(result: &ProcessedResult, output_renamed_as_deleted_added: bool) -> Self {
58 Self::compute_with_concurrency(result, output_renamed_as_deleted_added, None, None)
59 }
60
61 pub fn compute_with_concurrency(
66 result: &ProcessedResult,
67 output_renamed_as_deleted_added: bool,
68 blocked_groups: Option<&HashMap<InternedString, Vec<u64>>>,
69 interner: Option<&StringInterner>,
70 ) -> Self {
71 Self::compute_full(
72 result,
73 output_renamed_as_deleted_added,
74 blocked_groups,
75 interner,
76 false,
77 )
78 }
79
80 pub fn compute_full(
86 result: &ProcessedResult,
87 output_renamed_as_deleted_added: bool,
88 blocked_groups: Option<&HashMap<InternedString, Vec<u64>>>,
89 _interner: Option<&StringInterner>,
90 deleted_to_destroy: bool,
91 ) -> Self {
92 let filtered_set: HashSet<u32> = result.filtered_indices.iter().copied().collect();
93 let unmatched_set: HashSet<u32> = result.unmatched_indices.iter().copied().collect();
94
95 let mut out = Self {
96 filtered_added: Vec::new(),
97 filtered_copied: Vec::new(),
98 filtered_deleted: Vec::new(),
99 filtered_modified: Vec::new(),
100 filtered_renamed: Vec::new(),
101 filtered_type_changed: Vec::new(),
102 filtered_unmerged: Vec::new(),
103 filtered_unknown: Vec::new(),
104 other_changed: Vec::new(),
105 other_modified: Vec::new(),
106 other_deleted: Vec::new(),
107 all_changed_and_modified: Vec::new(),
108 renamed_mapping: Vec::new(),
109 rename_split_deletions: Vec::new(),
110 modified_keys: Vec::new(),
111 changed_keys: Vec::new(),
112 group_deploy_decisions: Vec::new(),
113 };
114
115 for (i, file) in result.all_files.iter().enumerate() {
116 let idx = i as u32;
117 let in_filter = filtered_set.contains(&idx);
118 let in_unmatched = unmatched_set.contains(&idx);
119
120 out.all_changed_and_modified.push(idx);
122
123 if in_filter {
124 match file.change_type {
125 ChangeType::Added => out.filtered_added.push(idx),
126 ChangeType::Copied => out.filtered_copied.push(idx),
127 ChangeType::Deleted => out.filtered_deleted.push(idx),
128 ChangeType::Modified => out.filtered_modified.push(idx),
129 ChangeType::Renamed => {
130 if output_renamed_as_deleted_added {
131 out.filtered_added.push(idx);
133 if let Some(prev) = file.previous_path {
134 out.rename_split_deletions.push((idx, prev));
135 }
136 } else {
137 out.filtered_renamed.push(idx);
138 if let Some(prev) = file.previous_path {
139 out.renamed_mapping.push((idx, prev));
140 }
141 }
142 }
143 ChangeType::TypeChanged => out.filtered_type_changed.push(idx),
144 ChangeType::Unmerged => out.filtered_unmerged.push(idx),
145 ChangeType::Unknown => out.filtered_unknown.push(idx),
146 }
147 }
148
149 if in_unmatched {
150 match file.change_type {
152 ChangeType::Added
153 | ChangeType::Copied
154 | ChangeType::Modified
155 | ChangeType::Renamed => {
156 out.other_changed.push(idx);
157 }
158 _ => {}
159 }
160
161 match file.change_type {
163 ChangeType::Added
164 | ChangeType::Copied
165 | ChangeType::Modified
166 | ChangeType::Renamed
167 | ChangeType::Deleted => {
168 out.other_modified.push(idx);
169 }
170 _ => {}
171 }
172
173 if file.change_type == ChangeType::Deleted {
175 out.other_deleted.push(idx);
176 }
177 }
178 }
179
180 for group in &result.group_results {
182 if !group.matched_indices.is_empty() {
183 out.changed_keys.push(group.key);
184
185 let has_modified = group.matched_indices.iter().any(|&idx| {
186 result
187 .all_files
188 .get(idx as usize)
189 .map(|f| f.change_type == ChangeType::Modified)
190 .unwrap_or(false)
191 });
192
193 if has_modified {
194 out.modified_keys.push(group.key);
195 }
196 }
197 }
198
199 let concurrency_for = |key: InternedString| -> (bool, u32) {
201 blocked_groups
202 .and_then(|bg| bg.get(&key))
203 .map(|ids| (true, ids.len() as u32))
204 .unwrap_or((false, 0))
205 };
206
207 let resolve_group_paths = |group: &GroupResult| -> Vec<InternedString> {
209 group
210 .matched_indices
211 .iter()
212 .filter_map(|&idx| result.all_files.get(idx as usize).map(|f| f.path))
213 .collect()
214 };
215
216 let vanished_of = |group: &GroupResult| -> Vec<VanishedFile> {
219 group
220 .vanished_indices
221 .iter()
222 .map(|&i| result.vanished_files[i as usize])
223 .collect()
224 };
225
226 let destroy_decision =
232 |group: &GroupResult, cb: bool, cb_by: u32| -> Option<GroupDeployDecision> {
233 let group_vanished = vanished_of(group);
234 let live_changes = group
235 .matched_indices
236 .iter()
237 .any(|&i| result.all_files[i as usize].change_type != ChangeType::Deleted);
238 let all_deleted = !group.matched_indices.is_empty() && !live_changes;
239 let deleted_paths: Vec<InternedString> = group
240 .matched_indices
241 .iter()
242 .filter_map(|&i| {
243 let f = &result.all_files[i as usize];
244 (f.change_type == ChangeType::Deleted).then_some(f.path)
245 })
246 .collect();
247
248 if live_changes {
249 return None; }
251 if !group_vanished.is_empty() && (!all_deleted || deleted_to_destroy) {
252 let reconstruct = group_vanished.first().map(|v| v.last_seen_sha);
254 return Some(GroupDeployDecision {
255 key: group.key,
256 action: GroupDeployAction::Destroy,
257 reason: Some(GroupDeployReason::Vanished),
258 files_to_rebuild: deleted_paths,
259 files_to_skip: Vec::new(),
260 total_files: (group.matched_indices.len() + group_vanished.len()) as u32,
261 concurrency_blocked: cb,
262 concurrency_blocked_by: cb_by,
263 vanished_files: group_vanished,
264 reconstruct_sha: reconstruct,
265 });
266 }
267 if deleted_to_destroy && all_deleted && group_vanished.is_empty() {
268 return Some(GroupDeployDecision {
269 key: group.key,
270 action: GroupDeployAction::Destroy,
271 reason: Some(GroupDeployReason::EndpointDeleted),
272 files_to_rebuild: deleted_paths,
273 files_to_skip: Vec::new(),
274 total_files: group.matched_indices.len() as u32,
275 concurrency_blocked: cb,
276 concurrency_blocked_by: cb_by,
277 vanished_files: Vec::new(),
278 reconstruct_sha: result.base_sha,
279 });
280 }
281 None
282 };
283
284 if !result.group_results.is_empty() {
285 if let Some(ref ci) = result.ci_decision {
286 let rebuild_set: HashSet<InternedString> =
288 ci.files_to_rebuild.iter().copied().collect();
289 let skip_set: HashSet<InternedString> = ci.files_to_skip.iter().copied().collect();
290 let reasons_map: HashMap<InternedString, RebuildReasonKind> = ci
291 .rebuild_reasons
292 .iter()
293 .map(|r| (r.file, r.kind))
294 .collect();
295
296 for group in &result.group_results {
297 let group_paths = resolve_group_paths(group);
298 let (cb0, cb_by0) = concurrency_for(group.key);
299 if let Some(decision) = destroy_decision(group, cb0, cb_by0) {
300 out.group_deploy_decisions.push(decision);
301 continue;
302 }
303
304 if group_paths.is_empty() {
305 continue;
306 }
307
308 let mut group_rebuild = Vec::new();
310 let mut group_skip = Vec::new();
311 for &path in &group_paths {
312 if rebuild_set.contains(&path) {
313 group_rebuild.push(path);
314 } else if skip_set.contains(&path) {
315 group_skip.push(path);
316 } else {
317 group_rebuild.push(path);
319 }
320 }
321
322 let total_files = group_paths.len() as u32;
323
324 let (cb, cb_by) = concurrency_for(group.key);
325
326 if group_rebuild.is_empty() {
327 out.group_deploy_decisions.push(GroupDeployDecision {
328 key: group.key,
329 action: GroupDeployAction::Skip,
330 reason: None,
331 files_to_rebuild: Vec::new(),
332 files_to_skip: group_skip,
333 total_files,
334 concurrency_blocked: cb,
335 concurrency_blocked_by: cb_by,
336 vanished_files: vanished_of(group),
337 reconstruct_sha: None,
338 });
339 } else {
340 let has_new = group_rebuild.iter().any(|p| {
341 matches!(
342 reasons_map.get(p),
343 Some(RebuildReasonKind::NewChange)
344 | Some(RebuildReasonKind::BothNewAndFailed)
345 )
346 });
347 let has_failure = group_rebuild.iter().any(|p| {
348 matches!(
349 reasons_map.get(p),
350 Some(RebuildReasonKind::PreviousFailure)
351 | Some(RebuildReasonKind::BothNewAndFailed)
352 )
353 });
354
355 let reason = match (has_new, has_failure) {
356 (true, true) => GroupDeployReason::BothNewAndFailed,
357 (false, true) => GroupDeployReason::PreviousFailure,
358 _ => GroupDeployReason::NewChange,
359 };
360
361 out.group_deploy_decisions.push(GroupDeployDecision {
362 key: group.key,
363 action: GroupDeployAction::Deploy,
364 reason: Some(reason),
365 files_to_rebuild: group_rebuild,
366 files_to_skip: group_skip,
367 total_files,
368 concurrency_blocked: cb,
369 concurrency_blocked_by: cb_by,
370 vanished_files: vanished_of(group),
371 reconstruct_sha: None,
372 });
373 }
374 }
375 } else {
376 for group in &result.group_results {
378 let group_paths = resolve_group_paths(group);
379 let (cb0, cb_by0) = concurrency_for(group.key);
380 if let Some(decision) = destroy_decision(group, cb0, cb_by0) {
381 out.group_deploy_decisions.push(decision);
382 continue;
383 }
384
385 if group_paths.is_empty() {
386 continue;
387 }
388
389 let total_files = group_paths.len() as u32;
390 let (cb, cb_by) = concurrency_for(group.key);
391 out.group_deploy_decisions.push(GroupDeployDecision {
392 key: group.key,
393 action: GroupDeployAction::Deploy,
394 reason: Some(GroupDeployReason::NewChange),
395 files_to_rebuild: group_paths,
396 files_to_skip: Vec::new(),
397 total_files,
398 concurrency_blocked: cb,
399 concurrency_blocked_by: cb_by,
400 vanished_files: vanished_of(group),
401 reconstruct_sha: None,
402 });
403 }
404 }
405 }
406
407 out
408 }
409
410 pub fn any_changed(&self) -> bool {
412 !self.filtered_added.is_empty()
413 || !self.filtered_copied.is_empty()
414 || !self.filtered_modified.is_empty()
415 || !self.filtered_renamed.is_empty()
416 }
417
418 pub fn only_changed(&self) -> bool {
420 let count = self.filtered_added.len()
421 + self.filtered_copied.len()
422 + self.filtered_modified.len()
423 + self.filtered_renamed.len();
424 count == 1
425 }
426
427 pub fn any_modified(&self) -> bool {
429 !self.filtered_modified.is_empty()
430 }
431
432 pub fn only_modified(&self) -> bool {
434 self.filtered_modified.len() == 1
435 && self.filtered_added.is_empty()
436 && self.filtered_copied.is_empty()
437 && self.filtered_renamed.is_empty()
438 && self.filtered_deleted.is_empty()
439 }
440
441 pub fn has_deployable_groups(&self) -> bool {
443 self.group_deploy_decisions
444 .iter()
445 .any(|d| d.action == GroupDeployAction::Deploy)
446 }
447
448 pub fn has_destroyable_groups(&self) -> bool {
450 self.group_deploy_decisions
451 .iter()
452 .any(|d| d.action == GroupDeployAction::Destroy)
453 }
454
455 pub fn any_deleted(&self) -> bool {
457 !self.filtered_deleted.is_empty()
458 }
459
460 pub fn only_deleted(&self) -> bool {
462 self.filtered_deleted.len() == 1
463 && self.filtered_added.is_empty()
464 && self.filtered_copied.is_empty()
465 && self.filtered_modified.is_empty()
466 && self.filtered_renamed.is_empty()
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473 use crate::interner::StringInterner;
474 use crate::types::{ChangedFile, CiDecision, FileOrigin, GroupResult, RebuildReason};
475
476 fn make_file(change_type: ChangeType, idx: u32) -> ChangedFile {
477 ChangedFile {
478 path: InternedString(idx),
479 change_type,
480 previous_path: None,
481 is_symlink: false,
482 submodule_depth: 0,
483 origin: FileOrigin::default(),
484 }
485 }
486
487 #[test]
488 fn test_compute_basic() {
489 let result = ProcessedResult {
490 all_files: vec![
491 make_file(ChangeType::Added, 0),
492 make_file(ChangeType::Modified, 1),
493 make_file(ChangeType::Deleted, 2),
494 ],
495 filtered_indices: vec![0, 1],
496 unmatched_indices: vec![2],
497 vanished_files: Vec::new(),
498 base_sha: None,
499 head_sha: None,
500 pattern_applied: true,
501 group_results: Vec::new(),
502 additions: 0,
503 deletions: 0,
504 diagnostics: Vec::new(),
505 workflow_result: None,
506 ci_decision: None,
507 };
508
509 let outputs = ComputedOutputs::compute(&result, false);
510 assert_eq!(outputs.filtered_added, vec![0]);
511 assert_eq!(outputs.filtered_modified, vec![1]);
512 assert!(outputs.filtered_deleted.is_empty());
513 assert_eq!(outputs.other_deleted, vec![2]);
514 assert!(outputs.any_changed());
515 assert!(!outputs.only_changed());
516 }
517
518 #[test]
519 fn test_compute_unfiltered() {
520 let result = ProcessedResult {
521 all_files: vec![make_file(ChangeType::Modified, 0)],
522 filtered_indices: vec![0],
523 unmatched_indices: Vec::new(),
524 vanished_files: Vec::new(),
525 base_sha: None,
526 head_sha: None,
527 pattern_applied: false,
528 group_results: Vec::new(),
529 additions: 0,
530 deletions: 0,
531 diagnostics: Vec::new(),
532 workflow_result: None,
533 ci_decision: None,
534 };
535
536 let outputs = ComputedOutputs::compute(&result, false);
537 assert!(outputs.any_modified());
538 assert!(outputs.only_modified());
539 }
540
541 #[test]
542 fn test_compute_rename_splitting() {
543 let result = ProcessedResult {
544 all_files: vec![
545 ChangedFile {
546 path: InternedString(0), change_type: ChangeType::Renamed,
548 previous_path: Some(InternedString(10)), is_symlink: false,
550 submodule_depth: 0,
551 origin: FileOrigin::default(),
552 },
553 make_file(ChangeType::Modified, 1),
554 ],
555 filtered_indices: vec![0, 1],
556 unmatched_indices: Vec::new(),
557 vanished_files: Vec::new(),
558 base_sha: None,
559 head_sha: None,
560 pattern_applied: true,
561 group_results: Vec::new(),
562 additions: 0,
563 deletions: 0,
564 diagnostics: Vec::new(),
565 workflow_result: None,
566 ci_decision: None,
567 };
568
569 let outputs = ComputedOutputs::compute(&result, false);
571 assert_eq!(outputs.filtered_renamed, vec![0]);
572 assert!(outputs.filtered_added.is_empty());
573 assert!(outputs.rename_split_deletions.is_empty());
574 assert_eq!(outputs.renamed_mapping.len(), 1);
575
576 let outputs = ComputedOutputs::compute(&result, true);
578 assert!(outputs.filtered_renamed.is_empty());
579 assert_eq!(outputs.filtered_added, vec![0]); assert_eq!(outputs.rename_split_deletions.len(), 1); assert_eq!(outputs.rename_split_deletions[0], (0, InternedString(10)));
582 assert!(outputs.renamed_mapping.is_empty());
583 }
584
585 #[test]
586 fn test_group_keys() {
587 let interner = StringInterner::new();
588 let frontend_key = interner.intern("frontend");
589 let backend_key = interner.intern("backend");
590
591 let result = ProcessedResult {
592 all_files: vec![
593 make_file(ChangeType::Modified, 0),
594 make_file(ChangeType::Added, 1),
595 ],
596 filtered_indices: vec![0, 1],
597 unmatched_indices: Vec::new(),
598 vanished_files: Vec::new(),
599 base_sha: None,
600 head_sha: None,
601 pattern_applied: true,
602 group_results: vec![
603 GroupResult {
604 key: frontend_key,
605 matched_indices: vec![0],
606 vanished_indices: Vec::new(),
607 },
608 GroupResult {
609 key: backend_key,
610 matched_indices: vec![1],
611 vanished_indices: Vec::new(),
612 },
613 ],
614 additions: 0,
615 deletions: 0,
616 diagnostics: Vec::new(),
617 workflow_result: None,
618 ci_decision: None,
619 };
620
621 let outputs = ComputedOutputs::compute(&result, false);
622 assert_eq!(outputs.changed_keys, vec![frontend_key, backend_key]);
623 assert_eq!(outputs.modified_keys, vec![frontend_key]); }
625
626 #[test]
627 fn test_deploy_decisions_with_ci_decision_mixed() {
628 let interner = StringInterner::new();
629 let dev_key = interner.intern("dev");
630 let staging_key = interner.intern("staging");
631 let prod_key = interner.intern("prod");
632
633 let result = ProcessedResult {
634 all_files: vec![
635 make_file(ChangeType::Modified, 0),
636 make_file(ChangeType::Modified, 1),
637 make_file(ChangeType::Modified, 2),
638 ],
639 filtered_indices: vec![0, 1, 2],
640 unmatched_indices: Vec::new(),
641 vanished_files: Vec::new(),
642 base_sha: None,
643 head_sha: None,
644 pattern_applied: true,
645 group_results: vec![
646 GroupResult {
647 key: dev_key,
648 matched_indices: vec![0],
649 vanished_indices: Vec::new(),
650 },
651 GroupResult {
652 key: staging_key,
653 matched_indices: vec![1],
654 vanished_indices: Vec::new(),
655 },
656 GroupResult {
657 key: prod_key,
658 matched_indices: vec![2],
659 vanished_indices: Vec::new(),
660 },
661 ],
662 additions: 0,
663 deletions: 0,
664 diagnostics: Vec::new(),
665 workflow_result: None,
666 ci_decision: Some(CiDecision {
667 files_to_rebuild: vec![InternedString(0), InternedString(2)],
668 files_to_skip: vec![InternedString(1)],
669 failed_jobs: Vec::new(),
670 successful_jobs: Vec::new(),
671 rebuild_reasons: vec![
672 RebuildReason {
673 file: InternedString(0),
674 kind: RebuildReasonKind::NewChange,
675 failed_run_id: None,
676 failed_job_name: None,
677 },
678 RebuildReason {
679 file: InternedString(2),
680 kind: RebuildReasonKind::PreviousFailure,
681 failed_run_id: Some(100),
682 failed_job_name: None,
683 },
684 ],
685 }),
686 };
687
688 let outputs = ComputedOutputs::compute(&result, false);
689 assert_eq!(outputs.group_deploy_decisions.len(), 3);
690
691 assert_eq!(outputs.group_deploy_decisions[0].key, dev_key);
692 assert_eq!(
693 outputs.group_deploy_decisions[0].action,
694 GroupDeployAction::Deploy
695 );
696 assert_eq!(
697 outputs.group_deploy_decisions[0].reason,
698 Some(GroupDeployReason::NewChange)
699 );
700
701 assert_eq!(outputs.group_deploy_decisions[1].key, staging_key);
702 assert_eq!(
703 outputs.group_deploy_decisions[1].action,
704 GroupDeployAction::Skip
705 );
706 assert!(outputs.group_deploy_decisions[1].reason.is_none());
707
708 assert_eq!(outputs.group_deploy_decisions[2].key, prod_key);
709 assert_eq!(
710 outputs.group_deploy_decisions[2].action,
711 GroupDeployAction::Deploy
712 );
713 assert_eq!(
714 outputs.group_deploy_decisions[2].reason,
715 Some(GroupDeployReason::PreviousFailure)
716 );
717
718 assert!(outputs.has_deployable_groups());
719 }
720
721 #[test]
722 fn test_deploy_decisions_without_ci_decision() {
723 let interner = StringInterner::new();
724 let dev_key = interner.intern("dev");
725 let prod_key = interner.intern("prod");
726
727 let result = ProcessedResult {
728 all_files: vec![
729 make_file(ChangeType::Added, 0),
730 make_file(ChangeType::Modified, 1),
731 ],
732 filtered_indices: vec![0, 1],
733 unmatched_indices: Vec::new(),
734 vanished_files: Vec::new(),
735 base_sha: None,
736 head_sha: None,
737 pattern_applied: true,
738 group_results: vec![
739 GroupResult {
740 key: dev_key,
741 matched_indices: vec![0],
742 vanished_indices: Vec::new(),
743 },
744 GroupResult {
745 key: prod_key,
746 matched_indices: vec![1],
747 vanished_indices: Vec::new(),
748 },
749 ],
750 additions: 0,
751 deletions: 0,
752 diagnostics: Vec::new(),
753 workflow_result: None,
754 ci_decision: None,
755 };
756
757 let outputs = ComputedOutputs::compute(&result, false);
758 assert_eq!(outputs.group_deploy_decisions.len(), 2);
759
760 for d in &outputs.group_deploy_decisions {
761 assert_eq!(d.action, GroupDeployAction::Deploy);
762 assert_eq!(d.reason, Some(GroupDeployReason::NewChange));
763 }
764 assert!(outputs.has_deployable_groups());
765 }
766
767 #[test]
768 fn test_deploy_decisions_empty_groups() {
769 let result = ProcessedResult {
770 all_files: vec![make_file(ChangeType::Modified, 0)],
771 filtered_indices: vec![0],
772 unmatched_indices: Vec::new(),
773 vanished_files: Vec::new(),
774 base_sha: None,
775 head_sha: None,
776 pattern_applied: false,
777 group_results: Vec::new(),
778 additions: 0,
779 deletions: 0,
780 diagnostics: Vec::new(),
781 workflow_result: None,
782 ci_decision: None,
783 };
784
785 let outputs = ComputedOutputs::compute(&result, false);
786 assert!(outputs.group_deploy_decisions.is_empty());
787 assert!(!outputs.has_deployable_groups());
788 }
789
790 #[test]
791 fn test_deploy_decisions_both_new_and_failed() {
792 let interner = StringInterner::new();
793 let mixed_key = interner.intern("mixed");
794
795 let result = ProcessedResult {
796 all_files: vec![
797 make_file(ChangeType::Modified, 0),
798 make_file(ChangeType::Modified, 1),
799 ],
800 filtered_indices: vec![0, 1],
801 unmatched_indices: Vec::new(),
802 vanished_files: Vec::new(),
803 base_sha: None,
804 head_sha: None,
805 pattern_applied: true,
806 group_results: vec![GroupResult {
807 key: mixed_key,
808 matched_indices: vec![0, 1],
809 vanished_indices: Vec::new(),
810 }],
811 additions: 0,
812 deletions: 0,
813 diagnostics: Vec::new(),
814 workflow_result: None,
815 ci_decision: Some(CiDecision {
816 files_to_rebuild: vec![InternedString(0), InternedString(1)],
817 files_to_skip: Vec::new(),
818 failed_jobs: Vec::new(),
819 successful_jobs: Vec::new(),
820 rebuild_reasons: vec![
821 RebuildReason {
822 file: InternedString(0),
823 kind: RebuildReasonKind::NewChange,
824 failed_run_id: None,
825 failed_job_name: None,
826 },
827 RebuildReason {
828 file: InternedString(1),
829 kind: RebuildReasonKind::PreviousFailure,
830 failed_run_id: Some(200),
831 failed_job_name: None,
832 },
833 ],
834 }),
835 };
836
837 let outputs = ComputedOutputs::compute(&result, false);
838 assert_eq!(outputs.group_deploy_decisions.len(), 1);
839 assert_eq!(
840 outputs.group_deploy_decisions[0].action,
841 GroupDeployAction::Deploy
842 );
843 assert_eq!(
844 outputs.group_deploy_decisions[0].reason,
845 Some(GroupDeployReason::BothNewAndFailed)
846 );
847 assert_eq!(outputs.group_deploy_decisions[0].files_to_rebuild.len(), 2);
848 }
849
850 #[test]
851 fn test_deploy_decisions_all_skip() {
852 let interner = StringInterner::new();
853 let staging_key = interner.intern("staging");
854
855 let result = ProcessedResult {
856 all_files: vec![make_file(ChangeType::Modified, 0)],
857 filtered_indices: vec![0],
858 unmatched_indices: Vec::new(),
859 vanished_files: Vec::new(),
860 base_sha: None,
861 head_sha: None,
862 pattern_applied: true,
863 group_results: vec![GroupResult {
864 key: staging_key,
865 matched_indices: vec![0],
866 vanished_indices: Vec::new(),
867 }],
868 additions: 0,
869 deletions: 0,
870 diagnostics: Vec::new(),
871 workflow_result: None,
872 ci_decision: Some(CiDecision {
873 files_to_rebuild: Vec::new(),
874 files_to_skip: vec![InternedString(0)],
875 failed_jobs: Vec::new(),
876 successful_jobs: Vec::new(),
877 rebuild_reasons: Vec::new(),
878 }),
879 };
880
881 let outputs = ComputedOutputs::compute(&result, false);
882 assert_eq!(outputs.group_deploy_decisions.len(), 1);
883 assert_eq!(
884 outputs.group_deploy_decisions[0].action,
885 GroupDeployAction::Skip
886 );
887 assert!(outputs.group_deploy_decisions[0].reason.is_none());
888 assert!(!outputs.has_deployable_groups());
889 }
890}
891
892#[cfg(test)]
893mod vanished_decision_tests {
894 use super::*;
895 use crate::interner::StringInterner;
896 use crate::types::{ChangedFile, FileOrigin, GroupResult, VanishedFile};
897
898 fn deleted_file(path: InternedString) -> ChangedFile {
899 ChangedFile {
900 path,
901 change_type: ChangeType::Deleted,
902 previous_path: None,
903 is_symlink: false,
904 submodule_depth: 0,
905 origin: FileOrigin {
906 in_current_changes: true,
907 in_previous_failure: false,
908 in_previous_success: false,
909 },
910 }
911 }
912
913 fn modified_file(path: InternedString) -> ChangedFile {
914 ChangedFile {
915 change_type: ChangeType::Modified,
916 ..deleted_file(path)
917 }
918 }
919
920 fn base_result(interner: &StringInterner) -> ProcessedResult {
921 ProcessedResult {
922 base_sha: Some(interner.intern("basesha")),
923 head_sha: Some(interner.intern("headsha")),
924 ..Default::default()
925 }
926 }
927
928 #[test]
929 fn test_vanished_only_group_destroys_with_last_seen() {
930 let interner = StringInterner::new();
931 let mut result = base_result(&interner);
932 result.vanished_files = vec![VanishedFile {
933 path: interner.intern("stacks/gone/Pulumi.yaml"),
934 last_seen_sha: interner.intern("cafe1234"),
935 }];
936 result.group_results = vec![GroupResult {
937 key: interner.intern("gone"),
938 matched_indices: Vec::new(),
939 vanished_indices: vec![0],
940 }];
941
942 let out = ComputedOutputs::compute_full(&result, false, None, None, false);
943 assert_eq!(out.group_deploy_decisions.len(), 1);
944 let d = &out.group_deploy_decisions[0];
945 assert_eq!(d.action, GroupDeployAction::Destroy);
946 assert_eq!(d.reason, Some(GroupDeployReason::Vanished));
947 assert_eq!(
948 interner.resolve(d.reconstruct_sha.unwrap()),
949 Some("cafe1234")
950 );
951 assert!(out.has_destroyable_groups());
952 assert!(!out.has_deployable_groups());
953 }
954
955 #[test]
956 fn test_endpoint_deleted_group_destroys_only_with_flag() {
957 let interner = StringInterner::new();
958 let mut result = base_result(&interner);
959 result.all_files = vec![deleted_file(interner.intern("stacks/old/Pulumi.yaml"))];
960 result.filtered_indices = vec![0];
961 result.group_results = vec![GroupResult {
962 key: interner.intern("old"),
963 matched_indices: vec![0],
964 vanished_indices: Vec::new(),
965 }];
966
967 let out = ComputedOutputs::compute_full(&result, false, None, None, false);
969 assert_eq!(
970 out.group_deploy_decisions[0].action,
971 GroupDeployAction::Deploy
972 );
973
974 let out = ComputedOutputs::compute_full(&result, false, None, None, true);
976 let d = &out.group_deploy_decisions[0];
977 assert_eq!(d.action, GroupDeployAction::Destroy);
978 assert_eq!(d.reason, Some(GroupDeployReason::EndpointDeleted));
979 assert_eq!(
980 interner.resolve(d.reconstruct_sha.unwrap()),
981 Some("basesha")
982 );
983 assert_eq!(d.files_to_rebuild.len(), 1);
984 }
985
986 #[test]
987 fn test_mixed_group_with_live_changes_stays_deploy() {
988 let interner = StringInterner::new();
989 let mut result = base_result(&interner);
990 result.all_files = vec![modified_file(interner.intern("stacks/live/Pulumi.yaml"))];
991 result.filtered_indices = vec![0];
992 result.vanished_files = vec![VanishedFile {
993 path: interner.intern("stacks/live/old.yaml"),
994 last_seen_sha: interner.intern("cafe1234"),
995 }];
996 result.group_results = vec![GroupResult {
997 key: interner.intern("live"),
998 matched_indices: vec![0],
999 vanished_indices: vec![0],
1000 }];
1001
1002 let out = ComputedOutputs::compute_full(&result, false, None, None, true);
1003 let d = &out.group_deploy_decisions[0];
1004 assert_eq!(d.action, GroupDeployAction::Deploy);
1005 assert!(d.reconstruct_sha.is_none());
1006 }
1007
1008 #[test]
1009 fn test_vanished_plus_deleted_destroy_gated_by_flag() {
1010 let interner = StringInterner::new();
1011 let mut result = base_result(&interner);
1012 result.all_files = vec![deleted_file(interner.intern("stacks/g/Pulumi.yaml"))];
1013 result.filtered_indices = vec![0];
1014 result.vanished_files = vec![VanishedFile {
1015 path: interner.intern("stacks/g/schema.json"),
1016 last_seen_sha: interner.intern("beef5678"),
1017 }];
1018 result.group_results = vec![GroupResult {
1019 key: interner.intern("g"),
1020 matched_indices: vec![0],
1021 vanished_indices: vec![0],
1022 }];
1023
1024 let out = ComputedOutputs::compute_full(&result, false, None, None, false);
1026 assert_eq!(
1027 out.group_deploy_decisions[0].action,
1028 GroupDeployAction::Deploy
1029 );
1030
1031 let out = ComputedOutputs::compute_full(&result, false, None, None, true);
1033 let d = &out.group_deploy_decisions[0];
1034 assert_eq!(d.action, GroupDeployAction::Destroy);
1035 assert_eq!(d.reason, Some(GroupDeployReason::Vanished));
1036 assert_eq!(
1037 interner.resolve(d.reconstruct_sha.unwrap()),
1038 Some("beef5678")
1039 );
1040 assert_eq!(d.total_files, 2);
1041 }
1042}