Skip to main content

boxferry_engine/
outcome.rs

1//! Fidelity outcomes, loss authorization, and conversion plans.
2
3use std::{error::Error, fmt};
4
5use boxferry_model::Provenance;
6
7use crate::{Diagnostic, DiagnosticCode, Severity};
8
9/// Fidelity of one source-to-target decision.
10#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11#[non_exhaustive]
12pub enum ConversionKind {
13    /// Target behavior represents the source intent exactly within known evidence.
14    Exact,
15    /// Target behavior requires a documented adjustment.
16    Approximate,
17    /// Target cannot represent the source intent.
18    Unsupported,
19    /// Source intent or target configuration is invalid.
20    Invalid,
21}
22
23/// One subject-level conversion decision.
24#[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    /// Creates an exact outcome with no loss diagnostic.
34    #[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    /// Creates a non-exact outcome linked to a structured diagnostic.
45    ///
46    /// # Errors
47    ///
48    /// Returns [`PlanError::ExactOutcomeHasDiagnostic`] if `kind` is exact.
49    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    /// Adds a source origin that contributed to this decision.
66    #[must_use]
67    pub fn with_origin(mut self, origin: Provenance) -> Self {
68        self.origins.push(origin);
69        self
70    }
71
72    /// Returns the stable subject path.
73    #[must_use]
74    pub fn subject(&self) -> &str {
75        &self.subject
76    }
77
78    /// Returns the decision fidelity.
79    #[must_use]
80    pub const fn kind(&self) -> ConversionKind {
81        self.kind
82    }
83
84    /// Returns the required diagnostic code for a non-exact decision.
85    #[must_use]
86    pub const fn diagnostic(&self) -> Option<&DiagnosticCode> {
87        self.diagnostic.as_ref()
88    }
89
90    /// Returns contributing source origins in discovery order.
91    #[must_use]
92    pub fn origins(&self) -> &[Provenance] {
93        &self.origins
94    }
95}
96
97/// Caller-selected authorization for non-exact candidate output.
98#[derive(Clone, Copy, Debug, Eq, PartialEq)]
99#[non_exhaustive]
100pub enum LossPolicy {
101    /// Emit output only when every decision is exact.
102    ExactOnly,
103    /// Permit documented approximate mappings but not unsupported intent.
104    AllowApproximate,
105    /// Permit partial output with diagnostics for unsupported intent.
106    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/// Invalid conversion plan invariant.
121#[derive(Clone, Debug, Eq, PartialEq)]
122#[non_exhaustive]
123pub enum PlanError {
124    /// An exact outcome was incorrectly linked to a loss diagnostic.
125    ExactOutcomeHasDiagnostic,
126    /// A non-exact outcome referenced a diagnostic missing from the plan.
127    MissingDiagnostic {
128        /// Outcome subject.
129        subject: String,
130        /// Referenced code.
131        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/// Validated target candidate, decisions, and structured diagnostics.
153#[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    /// Creates a plan and verifies that every loss has its referenced diagnostic.
162    ///
163    /// # Errors
164    ///
165    /// Returns [`PlanError::MissingDiagnostic`] when a non-exact outcome has no
166    /// matching structured diagnostic.
167    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    /// Returns the unapproved target candidate.
190    #[must_use]
191    pub const fn candidate(&self) -> Option<&T> {
192        self.candidate.as_ref()
193    }
194
195    /// Returns all subject outcomes.
196    #[must_use]
197    pub fn outcomes(&self) -> &[ConversionOutcome] {
198        &self.outcomes
199    }
200
201    /// Returns all diagnostics.
202    #[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    /// Applies a caller-selected loss policy without changing the candidate bytes.
228    #[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/// Policy-authorized conversion output and its complete report.
246#[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    /// Returns output only when authorized by the selected loss policy.
256    #[must_use]
257    pub const fn output(&self) -> Option<&T> {
258        self.output.as_ref()
259    }
260
261    /// Returns whether output was unavailable because the candidate was missing,
262    /// forbidden by policy, or accompanied by an error diagnostic.
263    #[must_use]
264    pub const fn is_blocked(&self) -> bool {
265        self.candidate_blocked
266    }
267
268    /// Returns all subject outcomes.
269    #[must_use]
270    pub fn outcomes(&self) -> &[ConversionOutcome] {
271        &self.outcomes
272    }
273
274    /// Returns all diagnostics, including import diagnostics.
275    #[must_use]
276    pub fn diagnostics(&self) -> &[Diagnostic] {
277        &self.diagnostics
278    }
279
280    /// Decomposes the authorized result.
281    #[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}