protocol_droid/domain/ir/lower/
report.rs1use super::rule::{DegradeRule, Loss};
28use crate::domain::ProtocolVersion;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct TranslationStep {
33 pub rule: DegradeRule,
35 pub target: ProtocolVersion,
38}
39
40impl TranslationStep {
41 #[must_use]
43 pub const fn new(rule: DegradeRule, target: ProtocolVersion) -> Self {
44 Self { rule, target }
45 }
46
47 #[must_use]
49 pub const fn loss(&self) -> Loss {
50 self.rule.loss()
51 }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Default)]
56pub struct TranslationReport {
57 steps: Vec<TranslationStep>,
58 target: Option<ProtocolVersion>,
59}
60
61impl TranslationReport {
62 #[must_use]
64 pub const fn new(target: ProtocolVersion) -> Self {
65 Self {
66 steps: Vec::new(),
67 target: Some(target),
68 }
69 }
70
71 pub fn record(&mut self, rule: DegradeRule) {
73 let target = self.target.unwrap_or_else(ProtocolVersion::latest_known);
74 self.steps.push(TranslationStep::new(rule, target));
75 }
76
77 #[must_use]
79 pub const fn is_empty(&self) -> bool {
80 self.steps.is_empty()
81 }
82
83 #[must_use]
85 pub const fn len(&self) -> usize {
86 self.steps.len()
87 }
88
89 #[must_use]
91 pub fn steps(&self) -> &[TranslationStep] {
92 &self.steps
93 }
94
95 #[must_use]
100 pub fn worst_loss(&self) -> Loss {
101 self.steps
102 .iter()
103 .map(TranslationStep::loss)
104 .max()
105 .unwrap_or(Loss::None)
106 }
107
108 #[must_use]
111 pub fn is_lossless_or_flagged(&self) -> bool {
112 self.worst_loss().is_acceptable()
113 }
114
115 #[must_use]
117 pub fn is_lossless(&self) -> bool {
118 self.worst_loss().is_lossless()
119 }
120
121 #[must_use]
127 pub fn lost_features(&self) -> Vec<crate::domain::Feature> {
128 let mut out: Vec<crate::domain::Feature> = Vec::new();
129 for step in &self.steps {
130 let Some(feature) = step.rule.feature() else {
131 continue;
132 };
133 if !out.contains(&feature) {
134 out.push(feature);
135 }
136 }
137 out
138 }
139
140 pub fn absorb(&mut self, other: &Self) {
146 if self.target.is_none() {
147 self.target = other.target;
148 }
149 self.steps.extend_from_slice(&other.steps);
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 #![expect(
156 clippy::panic,
157 reason = "tests panic with the failing precondition, which is the useful part"
158 )]
159
160 use super::TranslationReport;
161 use crate::domain::ir::lower::rule::{DegradeRule, Loss};
162 use crate::domain::{Feature, ProtocolVersion};
163
164 fn report() -> TranslationReport {
165 TranslationReport::new(ProtocolVersion::V2025_03_26)
166 }
167
168 #[test]
169 fn a_fresh_report_is_lossless() {
170 let report = report();
171 assert!(report.is_empty());
172 assert_eq!(report.len(), 0);
173 assert_eq!(report.worst_loss(), Loss::None);
174 assert!(report.is_lossless());
175 assert!(report.is_lossless_or_flagged());
176 }
177
178 #[test]
179 fn a_lossless_step_stays_lossless() {
180 let mut report = report();
181 report.record(DegradeRule::StructuredContentToText);
182 assert_eq!(report.len(), 1);
183 assert!(report.is_lossless());
184 assert!(report.is_lossless_or_flagged());
185 assert!(!report.is_empty());
186 }
187
188 #[test]
190 fn flagged_is_not_lossless_but_is_acceptable() {
191 let mut report = report();
192 report.record(DegradeRule::DropAnnotations);
193 assert_eq!(report.worst_loss(), Loss::Flagged);
194 assert!(!report.is_lossless());
195 assert!(report.is_lossless_or_flagged());
196 }
197
198 #[test]
199 fn a_destructive_step_is_neither() {
200 let mut report = report();
201 report.record(DegradeRule::AudioToTextPlaceholder);
202 assert_eq!(report.worst_loss(), Loss::Destructive);
203 assert!(!report.is_lossless());
204 assert!(!report.is_lossless_or_flagged());
205 }
206
207 #[test]
211 fn the_worst_loss_dominates() {
212 let mut report = report();
213 report.record(DegradeRule::StructuredContentToText);
214 report.record(DegradeRule::AudioToTextPlaceholder);
215 report.record(DegradeRule::DropAnnotations);
216 assert_eq!(report.len(), 3);
217 assert_eq!(report.worst_loss(), Loss::Destructive);
218 assert!(!report.is_lossless_or_flagged());
219 }
220
221 #[test]
222 fn every_step_keeps_the_target_revision() {
223 let mut report = report();
224 report.record(DegradeRule::DropAudio);
225 let Some(step) = report.steps().first() else {
226 panic!("expected one step");
227 };
228 assert_eq!(step.target, ProtocolVersion::V2025_03_26);
229 }
230
231 #[test]
234 fn steps_accumulate_and_features_deduplicate() {
235 let mut report = report();
236 report.record(DegradeRule::DropAudio);
237 report.record(DegradeRule::AudioToTextPlaceholder);
238 report.record(DegradeRule::DropImage);
239 assert_eq!(report.len(), 3);
240 let lost = report.lost_features();
242 assert_eq!(lost, vec![Feature::ToolAudioContent]);
243 }
244
245 #[test]
246 fn absorbing_merges_steps() {
247 let mut first = report();
248 first.record(DegradeRule::DropAnnotations);
249
250 let mut second = TranslationReport::new(ProtocolVersion::V2025_03_26);
251 second.record(DegradeRule::StructuredContentToText);
252
253 first.absorb(&second);
254 assert_eq!(first.len(), 2);
255 assert!(first.is_lossless_or_flagged());
256 }
257}