Skip to main content

lechange_core/output/
computed.rs

1//! Computed output categories from ProcessedResult
2
3use crate::interner::StringInterner;
4use crate::types::{
5    ChangeType, GroupDeployAction, GroupDeployDecision, GroupDeployReason, GroupResult,
6    InternedString, ProcessedResult, RebuildReasonKind, VanishedFile,
7};
8use std::collections::{HashMap, HashSet};
9
10/// All derived output categories computed in a single pass
11pub struct ComputedOutputs {
12    /// Filtered added file indices
13    pub filtered_added: Vec<u32>,
14    /// Filtered copied file indices
15    pub filtered_copied: Vec<u32>,
16    /// Filtered deleted file indices
17    pub filtered_deleted: Vec<u32>,
18    /// Filtered modified file indices
19    pub filtered_modified: Vec<u32>,
20    /// Filtered renamed file indices
21    pub filtered_renamed: Vec<u32>,
22    /// Filtered type-changed file indices
23    pub filtered_type_changed: Vec<u32>,
24    /// Filtered unmerged file indices
25    pub filtered_unmerged: Vec<u32>,
26    /// Filtered unknown file indices
27    pub filtered_unknown: Vec<u32>,
28    /// "Other changed" indices: ACMR not in filter
29    pub other_changed: Vec<u32>,
30    /// "Other modified" indices: ACMRD not in filter
31    pub other_modified: Vec<u32>,
32    /// "Other deleted" indices: D not in filter
33    pub other_deleted: Vec<u32>,
34    /// All changed and modified superset
35    pub all_changed_and_modified: Vec<u32>,
36    /// Rename mapping: (index, previous_path)
37    pub renamed_mapping: Vec<(u32, InternedString)>,
38    /// When output_renamed_as_deleted_added is true, contains (index, previous_path)
39    /// for renamed files split into deleted entries. The new name goes to filtered_added.
40    pub rename_split_deletions: Vec<(u32, InternedString)>,
41    /// YAML group keys that had modified matches
42    pub modified_keys: Vec<InternedString>,
43    /// YAML group keys that had any changed matches
44    pub changed_keys: Vec<InternedString>,
45    /// Per-group deploy decisions (populated when YAML groups are present)
46    pub group_deploy_decisions: Vec<GroupDeployDecision>,
47}
48
49impl ComputedOutputs {
50    /// Single-pass computation from a ProcessedResult
51    ///
52    /// When `output_renamed_as_deleted_added` is true, renamed files are split:
53    /// the new path goes to `filtered_added` and the old path is stored in
54    /// `rename_split_deletions` (the consumer should include these in deleted output).
55    ///
56    /// The `interner` parameter is used to look up group keys for concurrency tracking.
57    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    /// Compute with concurrency information from workflow check.
62    ///
63    /// `blocked_groups` maps group keys to blocking run IDs.
64    /// `interner` resolves InternedString keys for matching.
65    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    /// Full computation including Destroy deploy decisions.
81    ///
82    /// `deleted_to_destroy` maps groups whose only membership is
83    /// endpoint-Deleted files to a Destroy action with
84    /// `reconstruct_sha = result.base_sha`.
85    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            // All changed and modified includes everything
121            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                            // Split: new path → added, old path → deleted (stored separately)
132                            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                // "Other changed" = ACMR not in filter
151                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                // "Other modified" = ACMRD not in filter
162                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                // "Other deleted" = D not in filter
174                if file.change_type == ChangeType::Deleted {
175                    out.other_deleted.push(idx);
176                }
177            }
178        }
179
180        // Compute group keys (InternedString is Copy — no allocation)
181        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        // Helper: look up concurrency info for a group key
200        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        // Helper: resolve group matched indices to file paths
208        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        // A group's vanished members: rides along on every decision for
217        // visibility and drives Destroy classification.
218        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        // Compute group deploy decisions
227        // Destroy classification, shared by both decision branches. Returns
228        // Some(decision) when the group must be a Destroy: it has vanished
229        // members and no live (non-deleted) changes — or, with
230        // deleted_to_destroy, only endpoint-deleted members.
231        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; // group still deploys; vanished info rides along
250                }
251                if !group_vanished.is_empty() && (!all_deleted || deleted_to_destroy) {
252                    // vanished-only, or vanished + endpoint-deleted with the flag
253                    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                // Build lookup sets from CiDecision
287                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                    // Partition into rebuild/skip
309                    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                            // File not in CI decision — treat as needing rebuild
318                            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                // No CI decision — all groups with files get Deploy/NewChange
377                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    /// Any files in the changed category (filtered)
411    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    /// Only one file in the changed category
419    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    /// Any modified files (filtered)
428    pub fn any_modified(&self) -> bool {
429        !self.filtered_modified.is_empty()
430    }
431
432    /// Only one modified file
433    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    /// Any groups with Deploy action
442    pub fn has_deployable_groups(&self) -> bool {
443        self.group_deploy_decisions
444            .iter()
445            .any(|d| d.action == GroupDeployAction::Deploy)
446    }
447
448    /// Whether any group carries a Destroy deploy decision
449    pub fn has_destroyable_groups(&self) -> bool {
450        self.group_deploy_decisions
451            .iter()
452            .any(|d| d.action == GroupDeployAction::Destroy)
453    }
454
455    /// Any deleted files (filtered)
456    pub fn any_deleted(&self) -> bool {
457        !self.filtered_deleted.is_empty()
458    }
459
460    /// Only one deleted file
461    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), // new name
547                    change_type: ChangeType::Renamed,
548                    previous_path: Some(InternedString(10)), // old name
549                    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        // Without splitting
570        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        // With splitting
577        let outputs = ComputedOutputs::compute(&result, true);
578        assert!(outputs.filtered_renamed.is_empty());
579        assert_eq!(outputs.filtered_added, vec![0]); // new name → added
580        assert_eq!(outputs.rename_split_deletions.len(), 1); // old name → deleted
581        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]); // Only Modified type
624    }
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        // Flag off: legacy behavior (Deploy — the deletion rides the build path)
968        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        // Flag on: Destroy with reconstruct_sha = base
975        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        // Without the flag the endpoint-deleted member keeps legacy Deploy
1025        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        // With the flag: Destroy, newest vanished sha wins for reconstruction
1032        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}