Skip to main content

backhopper_core/model/
apply.rs

1// Copyright (C) 2026 Michael S. Klishin and Contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// See LICENSE-APACHE and LICENSE-MIT for details.
4
5//! The apply axis: per-path predictions of whether a patch applies to
6//! the target tree as written. Independent of every pin verdict, so an
7//! `Inapplicable` row still carries its predicted conflicts.
8
9use std::collections::BTreeMap;
10use std::collections::btree_map::Entry;
11
12use serde::{Deserialize, Serialize};
13
14use crate::model::names::RelativePath;
15use crate::model::verdict::ApplyConflictKind;
16
17/// How one touched path is predicted to apply to the target tree.
18/// Ranked by [`PathApplyOutcome::severity`]; payload-carrying variants
19/// keep the enum out of `Ord`.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
22#[serde(tag = "outcome", rename_all = "snake_case")]
23pub enum PathApplyOutcome {
24    /// Every hunk's preimage matches at its expected line.
25    CleanExact,
26    /// Applies, but at least one hunk's preimage was found at a shifted
27    /// line. `line_delta` is the largest-magnitude shift.
28    CleanDrifted { line_delta: i64 },
29    /// The path could not be assessed; stated, never silent.
30    Unassessed { reason: UnassessedReason },
31    /// At least one hunk predicts a conflict. `kind` is the worst
32    /// conflict kind across the path's hunks.
33    Conflict { kind: ApplyConflictKind },
34}
35
36/// Why a path's apply outcome could not be assessed. The `Ord` only
37/// breaks merge ties deterministically; it is not a severity.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
39#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
40#[serde(rename_all = "snake_case")]
41pub enum UnassessedReason {
42    /// The diff marks the file binary; there is no hunk text to match.
43    BinaryFile,
44    /// The target blob exists but is not valid UTF-8.
45    TargetNotText,
46}
47
48impl PathApplyOutcome {
49    /// Severity rank. `Unassessed` ranks below `Conflict`: a coverage
50    /// statement must never trip a round to findings on its own.
51    #[must_use]
52    pub fn severity(&self) -> u8 {
53        match self {
54            Self::CleanExact => 0,
55            Self::CleanDrifted { .. } => 1,
56            Self::Unassessed { .. } => 2,
57            Self::Conflict { .. } => 3,
58        }
59    }
60
61    #[must_use]
62    pub fn is_conflict(&self) -> bool {
63        matches!(self, Self::Conflict { .. })
64    }
65
66    /// Fold another observation of the same path into this outcome,
67    /// keeping the worst. Two drifts keep the largest-magnitude delta;
68    /// two conflicts keep the worse kind. Ties break on the payload so
69    /// the fold is order-independent.
70    #[must_use]
71    pub fn merge(self, other: Self) -> Self {
72        match (self, other) {
73            (Self::CleanDrifted { line_delta: a }, Self::CleanDrifted { line_delta: b }) => {
74                Self::CleanDrifted {
75                    line_delta: if (b.abs(), b) > (a.abs(), a) { b } else { a },
76                }
77            }
78            (Self::Unassessed { reason: a }, Self::Unassessed { reason: b }) => {
79                Self::Unassessed { reason: a.max(b) }
80            }
81            (Self::Conflict { kind: a }, Self::Conflict { kind: b }) => {
82                Self::Conflict { kind: a.max(b) }
83            }
84            (a, b) => {
85                if b.severity() > a.severity() {
86                    b
87                } else {
88                    a
89                }
90            }
91        }
92    }
93}
94
95/// Per-path apply prediction for one `(commit, series)` row, computed
96/// from the target tree. Present exactly when a target context was
97/// supplied.
98#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
99#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
100pub struct ApplyForecast {
101    /// Every assessed touched path, including ones absent on the
102    /// target (`Conflict { FileAbsent }`).
103    pub paths: BTreeMap<RelativePath, PathApplyOutcome>,
104}
105
106/// Derived-on-read counts over a forecast, never serialized.
107#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
108pub struct ApplySummary {
109    pub clean: usize,
110    pub drifted: usize,
111    pub unassessed: usize,
112    pub conflicted: usize,
113}
114
115impl ApplyForecast {
116    /// Record one observation of `path`, folding into any prior
117    /// observation at max severity.
118    pub fn record(&mut self, path: RelativePath, outcome: PathApplyOutcome) {
119        match self.paths.entry(path) {
120            Entry::Vacant(slot) => {
121                slot.insert(outcome);
122            }
123            Entry::Occupied(mut slot) => {
124                let merged = slot.get().clone().merge(outcome);
125                slot.insert(merged);
126            }
127        }
128    }
129
130    #[must_use]
131    pub fn has_conflict(&self) -> bool {
132        self.paths.values().any(PathApplyOutcome::is_conflict)
133    }
134
135    /// Every conflicting path with its conflict kind.
136    pub fn conflicts(&self) -> impl Iterator<Item = (&RelativePath, ApplyConflictKind)> {
137        self.paths.iter().filter_map(|(p, o)| match o {
138            PathApplyOutcome::Conflict { kind } => Some((p, *kind)),
139            _ => None,
140        })
141    }
142
143    #[must_use]
144    pub fn summary(&self) -> ApplySummary {
145        let mut s = ApplySummary::default();
146        for outcome in self.paths.values() {
147            match outcome {
148                PathApplyOutcome::CleanExact => s.clean += 1,
149                PathApplyOutcome::CleanDrifted { .. } => s.drifted += 1,
150                PathApplyOutcome::Unassessed { .. } => s.unassessed += 1,
151                PathApplyOutcome::Conflict { .. } => s.conflicted += 1,
152            }
153        }
154        s
155    }
156
157    #[must_use]
158    pub fn is_empty(&self) -> bool {
159        self.paths.is_empty()
160    }
161}