1use std::{error::Error, fmt};
4
5use boxferry_model::Provenance;
6
7use crate::{Diagnostic, DiagnosticCode, Severity};
8
9#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11#[non_exhaustive]
12pub enum ConversionKind {
13 Exact,
15 Approximate,
17 Unsupported,
19 Invalid,
21}
22
23#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct ConversionOutcome {
26 subject: String,
27 kind: ConversionKind,
28 diagnostic: Option<DiagnosticCode>,
29 origins: Vec<Provenance>,
30}
31
32impl ConversionOutcome {
33 #[must_use]
35 pub fn exact(subject: impl Into<String>) -> Self {
36 Self {
37 subject: subject.into(),
38 kind: ConversionKind::Exact,
39 diagnostic: None,
40 origins: Vec::new(),
41 }
42 }
43
44 pub fn loss(
50 subject: impl Into<String>,
51 kind: ConversionKind,
52 diagnostic: DiagnosticCode,
53 ) -> Result<Self, PlanError> {
54 if kind == ConversionKind::Exact {
55 return Err(PlanError::ExactOutcomeHasDiagnostic);
56 }
57 Ok(Self {
58 subject: subject.into(),
59 kind,
60 diagnostic: Some(diagnostic),
61 origins: Vec::new(),
62 })
63 }
64
65 #[must_use]
67 pub fn with_origin(mut self, origin: Provenance) -> Self {
68 self.origins.push(origin);
69 self
70 }
71
72 #[must_use]
74 pub fn subject(&self) -> &str {
75 &self.subject
76 }
77
78 #[must_use]
80 pub const fn kind(&self) -> ConversionKind {
81 self.kind
82 }
83
84 #[must_use]
86 pub const fn diagnostic(&self) -> Option<&DiagnosticCode> {
87 self.diagnostic.as_ref()
88 }
89
90 #[must_use]
92 pub fn origins(&self) -> &[Provenance] {
93 &self.origins
94 }
95}
96
97#[derive(Clone, Copy, Debug, Eq, PartialEq)]
99#[non_exhaustive]
100pub enum LossPolicy {
101 ExactOnly,
103 AllowApproximate,
105 AllowPartial,
107}
108
109impl LossPolicy {
110 const fn permits(self, kind: ConversionKind) -> bool {
111 match kind {
112 ConversionKind::Exact => true,
113 ConversionKind::Approximate => !matches!(self, Self::ExactOnly),
114 ConversionKind::Unsupported => matches!(self, Self::AllowPartial),
115 ConversionKind::Invalid => false,
116 }
117 }
118}
119
120#[derive(Clone, Debug, Eq, PartialEq)]
122#[non_exhaustive]
123pub enum PlanError {
124 ExactOutcomeHasDiagnostic,
126 MissingDiagnostic {
128 subject: String,
130 code: DiagnosticCode,
132 },
133}
134
135impl fmt::Display for PlanError {
136 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
137 match self {
138 Self::ExactOutcomeHasDiagnostic => {
139 formatter.write_str("exact conversion outcomes must not carry a loss diagnostic")
140 }
141 Self::MissingDiagnostic { subject, code } => write!(
142 formatter,
143 "conversion outcome `{subject}` references missing diagnostic {}",
144 code.as_str()
145 ),
146 }
147 }
148}
149
150impl Error for PlanError {}
151
152#[derive(Clone, Debug, Eq, PartialEq)]
154pub struct ConversionPlan<T> {
155 candidate: Option<T>,
156 outcomes: Vec<ConversionOutcome>,
157 diagnostics: Vec<Diagnostic>,
158}
159
160impl<T> ConversionPlan<T> {
161 pub fn new(
168 candidate: Option<T>,
169 outcomes: Vec<ConversionOutcome>,
170 diagnostics: Vec<Diagnostic>,
171 ) -> Result<Self, PlanError> {
172 for outcome in &outcomes {
173 if let Some(code) = outcome.diagnostic() {
174 if !diagnostics.iter().any(|diagnostic| diagnostic.code() == code) {
175 return Err(PlanError::MissingDiagnostic {
176 subject: outcome.subject().to_owned(),
177 code: code.clone(),
178 });
179 }
180 }
181 }
182 Ok(Self {
183 candidate,
184 outcomes,
185 diagnostics,
186 })
187 }
188
189 #[must_use]
191 pub const fn candidate(&self) -> Option<&T> {
192 self.candidate.as_ref()
193 }
194
195 #[must_use]
197 pub fn outcomes(&self) -> &[ConversionOutcome] {
198 &self.outcomes
199 }
200
201 #[must_use]
203 pub fn diagnostics(&self) -> &[Diagnostic] {
204 &self.diagnostics
205 }
206
207 pub(crate) fn extend_import(
208 &mut self,
209 outcomes: Vec<ConversionOutcome>,
210 diagnostics: Vec<Diagnostic>,
211 ) -> Result<(), PlanError> {
212 self.diagnostics.extend(diagnostics);
213 for outcome in &outcomes {
214 if let Some(code) = outcome.diagnostic() {
215 if !self.diagnostics.iter().any(|diagnostic| diagnostic.code() == code) {
216 return Err(PlanError::MissingDiagnostic {
217 subject: outcome.subject().to_owned(),
218 code: code.clone(),
219 });
220 }
221 }
222 }
223 self.outcomes.splice(0..0, outcomes);
224 Ok(())
225 }
226
227 #[must_use]
229 pub fn authorize(self, policy: LossPolicy) -> ConversionResult<T> {
230 let blocked = self.candidate.is_none()
231 || self.outcomes.iter().any(|outcome| !policy.permits(outcome.kind()))
232 || self
233 .diagnostics
234 .iter()
235 .any(|diagnostic| diagnostic.severity() == Severity::Error);
236 ConversionResult {
237 output: if blocked { None } else { self.candidate },
238 candidate_blocked: blocked,
239 outcomes: self.outcomes,
240 diagnostics: self.diagnostics,
241 }
242 }
243}
244
245#[derive(Clone, Debug, Eq, PartialEq)]
247pub struct ConversionResult<T> {
248 output: Option<T>,
249 candidate_blocked: bool,
250 outcomes: Vec<ConversionOutcome>,
251 diagnostics: Vec<Diagnostic>,
252}
253
254impl<T> ConversionResult<T> {
255 #[must_use]
257 pub const fn output(&self) -> Option<&T> {
258 self.output.as_ref()
259 }
260
261 #[must_use]
264 pub const fn is_blocked(&self) -> bool {
265 self.candidate_blocked
266 }
267
268 #[must_use]
270 pub fn outcomes(&self) -> &[ConversionOutcome] {
271 &self.outcomes
272 }
273
274 #[must_use]
276 pub fn diagnostics(&self) -> &[Diagnostic] {
277 &self.diagnostics
278 }
279
280 #[must_use]
282 pub fn into_parts(self) -> (Option<T>, Vec<ConversionOutcome>, Vec<Diagnostic>) {
283 (self.output, self.outcomes, self.diagnostics)
284 }
285}
286
287#[cfg(test)]
288mod tests {
289 use boxferry_model::{Provenance, SourceId};
290
291 use super::{ConversionKind, ConversionOutcome, ConversionPlan, LossPolicy, PlanError};
292 use crate::{Diagnostic, DiagnosticCode, Severity};
293
294 #[test]
295 fn strict_and_partial_policies_treat_unsupported_output_differently() -> Result<(), String> {
296 let diagnostic = Diagnostic::new(code("BFE0002")?, Severity::Warning, "feature omitted");
297 let outcome = ConversionOutcome::loss(
298 "services.web.unsupported",
299 ConversionKind::Unsupported,
300 diagnostic.code().clone(),
301 )
302 .map_err(|error| error.to_string())?;
303
304 let strict = ConversionPlan::new(Some("candidate"), vec![outcome.clone()], vec![diagnostic.clone()])
305 .map_err(|error| error.to_string())?
306 .authorize(LossPolicy::ExactOnly);
307 assert!(strict.is_blocked());
308 assert_eq!(strict.output(), None);
309
310 let partial = ConversionPlan::new(Some("candidate"), vec![outcome], vec![diagnostic])
311 .map_err(|error| error.to_string())?
312 .authorize(LossPolicy::AllowPartial);
313 assert!(!partial.is_blocked());
314 assert_eq!(partial.output(), Some(&"candidate"));
315 Ok(())
316 }
317
318 #[test]
319 fn every_loss_must_reference_a_present_diagnostic() -> Result<(), String> {
320 let outcome = ConversionOutcome::loss("services.web.command", ConversionKind::Approximate, code("BFE0003")?)
321 .map_err(|error| error.to_string())?;
322 assert!(matches!(
323 ConversionPlan::<()>::new(None, vec![outcome], Vec::new()),
324 Err(PlanError::MissingDiagnostic { .. })
325 ));
326 Ok(())
327 }
328
329 #[test]
330 fn missing_and_invalid_candidates_are_always_blocked() -> Result<(), String> {
331 let missing = ConversionPlan::<String>::new(None, Vec::new(), Vec::new())
332 .map_err(|error| error.to_string())?
333 .authorize(LossPolicy::AllowPartial);
334 assert!(missing.is_blocked());
335
336 let diagnostic = Diagnostic::new(code("BFE0004")?, Severity::Error, "source value is invalid");
337 let invalid = ConversionOutcome::loss("services.web.port", ConversionKind::Invalid, diagnostic.code().clone())
338 .map_err(|error| error.to_string())?;
339 let result = ConversionPlan::new(Some("candidate"), vec![invalid], vec![diagnostic])
340 .map_err(|error| error.to_string())?
341 .authorize(LossPolicy::AllowPartial);
342 assert!(result.is_blocked());
343 assert_eq!(result.output(), None);
344 Ok(())
345 }
346
347 #[test]
348 fn decisions_retain_source_provenance() -> Result<(), String> {
349 let origin = Provenance::source(SourceId::new("compose.yaml").map_err(|error| error.to_string())?);
350 let outcome = ConversionOutcome::exact("services.web.image").with_origin(origin.clone());
351 assert_eq!(outcome.origins(), [origin]);
352 Ok(())
353 }
354
355 fn code(value: &str) -> Result<DiagnosticCode, String> {
356 DiagnosticCode::new(value).map_err(|error| error.to_string())
357 }
358}