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