Skip to main content

boxferry_compose/
source.rs

1//! Explicit input bundle for one processed Compose project.
2
3use std::collections::BTreeMap;
4
5use boxferry_engine::{
6    DiagnosticField, DiagnosticValue, NativeFinding, NativeFindingLabel, NativeFindingLabelKind, Severity,
7};
8use boxferry_model::{Identifier, ModelError, SourceId};
9use compose_lens::{
10    diagnostic::{Diagnostic as ComposeDiagnostic, LabelKind, Severity as ComposeSeverity},
11    merge::MergedProject,
12    profiles::ProfileSelection,
13    source::SourceId as ComposeSourceId,
14};
15
16/// Compose processing layer that emitted a retained native finding.
17#[derive(Clone, Copy, Debug, Eq, PartialEq)]
18#[non_exhaustive]
19pub enum ComposeFindingStage {
20    /// YAML syntax or single-document loading.
21    Load,
22    /// Explicit variable interpolation.
23    Interpolation,
24    /// Ordered multi-document merge.
25    Merge,
26    /// Explicit service profile selection.
27    ProfileSelection,
28    /// Native merged-project typing.
29    ProjectModel,
30    /// Provider/runtime compatibility validation.
31    Validation,
32}
33
34impl ComposeFindingStage {
35    pub(crate) const fn as_str(self) -> &'static str {
36        match self {
37            Self::Load => "load",
38            Self::Interpolation => "interpolation",
39            Self::Merge => "merge",
40            Self::ProfileSelection => "profile-selection",
41            Self::ProjectModel => "project-model",
42            Self::Validation => "validation",
43        }
44    }
45
46    /// Converts one native diagnostic into the shared protected finding envelope.
47    #[must_use]
48    pub fn native_finding(self, diagnostic: &ComposeDiagnostic) -> NativeFinding {
49        let severity = match diagnostic.severity() {
50            ComposeSeverity::Error => Severity::Error,
51            ComposeSeverity::Warning => Severity::Warning,
52            ComposeSeverity::Note => Severity::Note,
53        };
54        let mut finding = NativeFinding::new(
55            "compose",
56            "compose-lens",
57            diagnostic.code().as_str(),
58            self.as_str(),
59            severity,
60            diagnostic.message(),
61        );
62        if let Some(variable) = interpolation_variable(diagnostic.code().as_str(), diagnostic.message()) {
63            finding = finding.with_field(DiagnosticField::new("variable", DiagnosticValue::plain(variable)));
64        }
65        for label in diagnostic.labels() {
66            finding = finding.with_label(NativeFindingLabel::new(
67                match label.kind() {
68                    LabelKind::Primary => NativeFindingLabelKind::Primary,
69                    LabelKind::Secondary => NativeFindingLabelKind::Secondary,
70                },
71                label.span().source_id().get(),
72                label.span().start(),
73                label.span().end(),
74                label.message(),
75            ));
76        }
77        for note in diagnostic.notes() {
78            finding = finding.with_note(note);
79        }
80        finding
81    }
82}
83
84/// A merged Compose project and the caller-owned context needed to import it safely.
85#[derive(Clone, Debug, Eq, PartialEq)]
86pub struct ComposeSource {
87    project: MergedProject,
88    fallback_application_name: Identifier,
89    source_ids: BTreeMap<ComposeSourceId, SourceId>,
90    profile_selection: Option<ProfileSelection>,
91    native_findings: Vec<NativeFinding>,
92    deferred_options: crate::QuadletDeferredOptions,
93    interpolation_applied: bool,
94}
95
96impl ComposeSource {
97    /// Records that the native merge consumed an interpolation overlay, including an empty one.
98    /// Callers must set this whenever merging with an overlay: escaped dollars may have been
99    /// decoded even when no substituted provider value carries sensitivity taint.
100    #[must_use]
101    pub const fn with_interpolation_applied(mut self) -> Self {
102        self.interpolation_applied = true;
103        self
104    }
105    /// Returns the explicit native processing-stage evidence supplied by the caller.
106    #[must_use]
107    pub const fn interpolation_applied(&self) -> bool {
108        self.interpolation_applied
109    }
110
111    /// Attaches explicit per-service deferred execution plans to uninterpolated input.
112    ///
113    /// Callers must merge without an interpolation overlay. Explicitly recorded overlays are
114    /// rejected; this option never authorizes a second evaluation stage.
115    #[must_use]
116    pub fn with_deferred_options(mut self, options: crate::QuadletDeferredOptions) -> Self {
117        self.deferred_options = options;
118        self
119    }
120    /// Returns the explicit deferral policy.
121    #[must_use]
122    pub const fn deferred_options(&self) -> &crate::QuadletDeferredOptions {
123        &self.deferred_options
124    }
125
126    /// Creates a source without guessing profiles or source filenames from ambient state.
127    ///
128    /// Every Compose source initially receives the stable neutral identity
129    /// `compose-source-<numeric-id>`. Call [`Self::with_source_id`] to replace it with a caller-owned
130    /// path, URI, or other display identity. The project's explicit top-level `name` wins when
131    /// present; the fallback is used when Compose project naming was supplied externally or
132    /// omitted. If the merge consumed any interpolation overlay, callers must additionally use
133    /// [`Self::with_interpolation_applied`], even when the overlay was empty or only decoded escapes.
134    /// Processing stage is explicit caller context and is never inferred from provenance or taint.
135    ///
136    /// # Errors
137    ///
138    /// Returns [`ModelError`] if a generated fallback source identity violates the neutral-model
139    /// invariant. This cannot occur for the current `compose-source-<u32>` spelling, but keeping
140    /// construction fallible avoids a hidden panic if that policy changes.
141    pub fn new(project: MergedProject, fallback_application_name: Identifier) -> Result<Self, ModelError> {
142        let source_ids = project
143            .source_ids()
144            .iter()
145            .copied()
146            .map(|source_id| {
147                SourceId::new(format!("compose-source-{}", source_id.get())).map(|neutral| (source_id, neutral))
148            })
149            .collect::<Result<_, _>>()?;
150        Ok(Self {
151            project,
152            fallback_application_name,
153            source_ids,
154            profile_selection: None,
155            native_findings: Vec::new(),
156            deferred_options: crate::QuadletDeferredOptions::new(),
157            interpolation_applied: false,
158        })
159    }
160
161    /// Assigns a caller-owned neutral identity to one Compose source document.
162    ///
163    /// Unknown Compose source IDs are retained in the map but cannot contribute provenance unless
164    /// they also occur in the merged project.
165    #[must_use]
166    pub fn with_source_id(mut self, compose: ComposeSourceId, neutral: SourceId) -> Self {
167        self.source_ids.insert(compose, neutral);
168        self
169    }
170
171    /// Attaches the explicit selection produced by `ComposeLens` project processing.
172    #[must_use]
173    pub fn with_profile_selection(mut self, selection: ProfileSelection) -> Self {
174        self.profile_selection = Some(selection);
175        self
176    }
177
178    /// Retains native Compose diagnostics without terminal rendering or source contents.
179    ///
180    /// Diagnostics should be attached at the stage where the caller obtained them. The adapter
181    /// keeps native codes as provenance and maps them to BoxFerry-owned rules during import.
182    #[must_use]
183    pub fn with_native_diagnostics<'a>(
184        mut self,
185        stage: ComposeFindingStage,
186        diagnostics: impl IntoIterator<Item = &'a ComposeDiagnostic>,
187    ) -> Self {
188        self.native_findings.extend(
189            diagnostics
190                .into_iter()
191                .map(|diagnostic| stage.native_finding(diagnostic)),
192        );
193        self
194    }
195
196    /// Returns the merged native project.
197    #[must_use]
198    pub const fn project(&self) -> &MergedProject {
199        &self.project
200    }
201
202    /// Returns the caller-selected fallback application name.
203    #[must_use]
204    pub const fn fallback_application_name(&self) -> &Identifier {
205        &self.fallback_application_name
206    }
207
208    /// Resolves a Compose source identity into its neutral-model identity.
209    #[must_use]
210    pub fn source_id(&self, compose: ComposeSourceId) -> Option<&SourceId> {
211        self.source_ids.get(&compose)
212    }
213
214    /// Returns the explicit profile selection, when one was supplied.
215    #[must_use]
216    pub const fn profile_selection(&self) -> Option<&ProfileSelection> {
217        self.profile_selection.as_ref()
218    }
219
220    /// Returns retained native Compose findings in processing order.
221    #[must_use]
222    pub fn native_findings(&self) -> &[NativeFinding] {
223        &self.native_findings
224    }
225}
226
227pub(crate) fn native_project_finding(diagnostic: &ComposeDiagnostic) -> NativeFinding {
228    ComposeFindingStage::ProjectModel.native_finding(diagnostic)
229}
230
231fn interpolation_variable<'a>(code: &str, message: &'a str) -> Option<&'a str> {
232    if !matches!(
233        code,
234        "compose.interpolation.unset-variable" | "compose.interpolation.required-variable"
235    ) {
236        return None;
237    }
238    let (_, remainder) = message.split_once('`')?;
239    let (variable, _) = remainder.split_once('`')?;
240    let mut bytes = variable.bytes();
241    let first = bytes.next()?;
242    ((first == b'_' || first.is_ascii_alphabetic()) && bytes.all(|byte| byte == b'_' || byte.is_ascii_alphanumeric()))
243        .then_some(variable)
244}