Skip to main content

boxferry_compose/
source.rs

1//! Explicit input bundle for one processed Compose project.
2
3use std::{collections::BTreeMap, fmt};
4
5use boxferry_engine::{
6    Diagnostic, DiagnosticField, DiagnosticValue, InvalidDiagnosticCode, NativeFinding, NativeFindingLabel,
7    NativeFindingLabelKind, RuleId, Severity,
8};
9use boxferry_model::{Identifier, ModelError, SourceId};
10use compose_lens::{
11    diagnostic::{Diagnostic as ComposeDiagnostic, LabelKind, Severity as ComposeSeverity},
12    merge::MergedProject,
13    profiles::ProfileSelection,
14    render::render_canonical,
15    source::SourceId as ComposeSourceId,
16};
17
18/// One canonical Compose document produced directly from a processed native Compose source.
19///
20/// Native canonicalization retains valid Compose-only values, including unresolved interpolation
21/// expressions, without reading ambient environment values. The document deliberately redacts its
22/// complete text from `Debug` output because source literals and expression defaults can contain
23/// secrets even when no interpolation input marked them sensitive.
24#[derive(Clone, Eq, PartialEq)]
25pub struct CanonicalComposeDocument {
26    text: String,
27    sensitive: bool,
28}
29
30impl CanonicalComposeDocument {
31    /// Returns deployable canonical Compose YAML.
32    #[must_use]
33    pub fn text(&self) -> &str {
34        &self.text
35    }
36
37    /// Reports whether `ComposeLens` found protected rendered content.
38    #[must_use]
39    pub const fn is_sensitive(&self) -> bool {
40        self.sensitive
41    }
42}
43
44impl fmt::Debug for CanonicalComposeDocument {
45    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
46        formatter
47            .debug_struct("CanonicalComposeDocument")
48            .field("text", &"<redacted>")
49            .field("sensitive", &self.sensitive)
50            .finish()
51    }
52}
53
54/// Result of native Compose-to-Compose canonicalization.
55///
56/// An error-level retained or rendering finding suppresses the document. Warnings and notes remain
57/// attached while allowing canonical output.
58#[derive(Clone, Debug, Eq, PartialEq)]
59pub struct ComposeCanonicalization {
60    document: Option<CanonicalComposeDocument>,
61    diagnostics: Vec<Diagnostic>,
62}
63
64impl ComposeCanonicalization {
65    /// Returns the canonical document when native validation succeeded.
66    #[must_use]
67    pub const fn document(&self) -> Option<&CanonicalComposeDocument> {
68        self.document.as_ref()
69    }
70
71    /// Returns retained processing and rendering diagnostics.
72    #[must_use]
73    pub fn diagnostics(&self) -> &[Diagnostic] {
74        &self.diagnostics
75    }
76
77    /// Consumes the result into its canonical document and diagnostics.
78    #[must_use]
79    pub fn into_parts(self) -> (Option<CanonicalComposeDocument>, Vec<Diagnostic>) {
80        (self.document, self.diagnostics)
81    }
82}
83
84/// Compose processing layer that emitted a retained native finding.
85#[derive(Clone, Copy, Debug, Eq, PartialEq)]
86#[non_exhaustive]
87pub enum ComposeFindingStage {
88    /// YAML syntax or single-document loading.
89    Load,
90    /// Explicit variable interpolation.
91    Interpolation,
92    /// Ordered multi-document merge.
93    Merge,
94    /// Explicit service profile selection.
95    ProfileSelection,
96    /// Native merged-project typing.
97    ProjectModel,
98    /// Provider/runtime compatibility validation.
99    Validation,
100    /// Native Compose generation or parse-back validation.
101    Rendering,
102}
103
104impl ComposeFindingStage {
105    pub(crate) const fn as_str(self) -> &'static str {
106        match self {
107            Self::Load => "load",
108            Self::Interpolation => "interpolation",
109            Self::Merge => "merge",
110            Self::ProfileSelection => "profile-selection",
111            Self::ProjectModel => "project-model",
112            Self::Validation => "validation",
113            Self::Rendering => "rendering",
114        }
115    }
116
117    /// Converts one native diagnostic into the shared protected finding envelope.
118    #[must_use]
119    pub fn native_finding(self, diagnostic: &ComposeDiagnostic) -> NativeFinding {
120        let severity = match diagnostic.severity() {
121            ComposeSeverity::Error => Severity::Error,
122            ComposeSeverity::Warning => Severity::Warning,
123            ComposeSeverity::Note => Severity::Note,
124        };
125        let mut finding = NativeFinding::new(
126            "compose",
127            "compose-lens",
128            diagnostic.code().as_str(),
129            self.as_str(),
130            severity,
131            diagnostic.message(),
132        );
133        if let Some(variable) = interpolation_variable(diagnostic.code().as_str(), diagnostic.message()) {
134            finding = finding.with_field(DiagnosticField::new("variable", DiagnosticValue::plain(variable)));
135        }
136        for label in diagnostic.labels() {
137            finding = finding.with_label(NativeFindingLabel::new(
138                match label.kind() {
139                    LabelKind::Primary => NativeFindingLabelKind::Primary,
140                    LabelKind::Secondary => NativeFindingLabelKind::Secondary,
141                },
142                label.span().source_id().get(),
143                label.span().start(),
144                label.span().end(),
145                label.message(),
146            ));
147        }
148        for note in diagnostic.notes() {
149            finding = finding.with_note(note);
150        }
151        finding
152    }
153}
154
155/// A merged Compose project and the caller-owned context needed to import it safely.
156#[derive(Clone, Debug, Eq, PartialEq)]
157pub struct ComposeSource {
158    project: MergedProject,
159    fallback_application_name: Identifier,
160    source_ids: BTreeMap<ComposeSourceId, SourceId>,
161    profile_selection: Option<ProfileSelection>,
162    native_findings: Vec<NativeFinding>,
163}
164
165impl ComposeSource {
166    /// Creates a source without guessing profiles or source filenames from ambient state.
167    ///
168    /// Every Compose source initially receives the stable neutral identity
169    /// `compose-source-<numeric-id>`. Call [`Self::with_source_id`] to replace it with a caller-owned
170    /// path, URI, or other display identity. The project's explicit top-level `name` wins when
171    /// present; the fallback is used when Compose project naming was supplied externally or
172    /// omitted.
173    ///
174    /// # Errors
175    ///
176    /// Returns [`ModelError`] if a generated fallback source identity violates the neutral-model
177    /// invariant. This cannot occur for the current `compose-source-<u32>` spelling, but keeping
178    /// construction fallible avoids a hidden panic if that policy changes.
179    pub fn new(project: MergedProject, fallback_application_name: Identifier) -> Result<Self, ModelError> {
180        let source_ids = project
181            .source_ids()
182            .iter()
183            .copied()
184            .map(|source_id| {
185                SourceId::new(format!("compose-source-{}", source_id.get())).map(|neutral| (source_id, neutral))
186            })
187            .collect::<Result<_, _>>()?;
188        Ok(Self {
189            project,
190            fallback_application_name,
191            source_ids,
192            profile_selection: None,
193            native_findings: Vec::new(),
194        })
195    }
196
197    /// Assigns a caller-owned neutral identity to one Compose source document.
198    ///
199    /// Unknown Compose source IDs are retained in the map but cannot contribute provenance unless
200    /// they also occur in the merged project.
201    #[must_use]
202    pub fn with_source_id(mut self, compose: ComposeSourceId, neutral: SourceId) -> Self {
203        self.source_ids.insert(compose, neutral);
204        self
205    }
206
207    /// Attaches the explicit selection produced by `ComposeLens` project processing.
208    #[must_use]
209    pub fn with_profile_selection(mut self, selection: ProfileSelection) -> Self {
210        self.profile_selection = Some(selection);
211        self
212    }
213
214    /// Retains native Compose diagnostics without terminal rendering or source contents.
215    ///
216    /// Diagnostics should be attached at the stage where the caller obtained them. The adapter
217    /// keeps native codes as provenance and maps them to BoxFerry-owned rules during import.
218    #[must_use]
219    pub fn with_native_diagnostics<'a>(
220        mut self,
221        stage: ComposeFindingStage,
222        diagnostics: impl IntoIterator<Item = &'a ComposeDiagnostic>,
223    ) -> Self {
224        self.native_findings.extend(
225            diagnostics
226                .into_iter()
227                .map(|diagnostic| stage.native_finding(diagnostic)),
228        );
229        self
230    }
231
232    /// Returns the merged native project.
233    #[must_use]
234    pub const fn project(&self) -> &MergedProject {
235        &self.project
236    }
237
238    /// Returns the caller-selected fallback application name.
239    #[must_use]
240    pub const fn fallback_application_name(&self) -> &Identifier {
241        &self.fallback_application_name
242    }
243
244    /// Resolves a Compose source identity into its neutral-model identity.
245    #[must_use]
246    pub fn source_id(&self, compose: ComposeSourceId) -> Option<&SourceId> {
247        self.source_ids.get(&compose)
248    }
249
250    /// Returns the explicit profile selection, when one was supplied.
251    #[must_use]
252    pub const fn profile_selection(&self) -> Option<&ProfileSelection> {
253        self.profile_selection.as_ref()
254    }
255
256    /// Returns retained native Compose findings in processing order.
257    #[must_use]
258    pub fn native_findings(&self) -> &[NativeFinding] {
259        &self.native_findings
260    }
261
262    /// Canonically renders this processed Compose project without evaluating unresolved variables.
263    ///
264    /// This same-format boundary delegates syntax generation to `ComposeLens`. Unlike export from
265    /// the neutral application model, it can retain valid source-native values that have no
266    /// format-independent representation, including interpolation expressions with defaults.
267    /// Loader, interpolation, merge, profile, and rendering findings remain structured.
268    ///
269    /// # Errors
270    ///
271    /// Returns [`InvalidDiagnosticCode`] if `BoxFerry`'s static diagnostic catalogue contains an
272    /// invalid code.
273    pub fn canonicalize(&self) -> Result<ComposeCanonicalization, InvalidDiagnosticCode> {
274        let rendered = render_canonical(self.project(), self.profile_selection());
275        let mut findings = self.native_findings.clone();
276        findings.extend(
277            rendered
278                .diagnostics()
279                .iter()
280                .map(|diagnostic| ComposeFindingStage::Rendering.native_finding(diagnostic)),
281        );
282        let diagnostics = findings
283            .into_iter()
284            .map(compose_native_diagnostic)
285            .collect::<Result<Vec<_>, _>>()?;
286        let valid = rendered.is_valid()
287            && diagnostics
288                .iter()
289                .all(|diagnostic| diagnostic.severity() != Severity::Error);
290        let sensitive = rendered.is_sensitive();
291        let document = valid.then(|| CanonicalComposeDocument {
292            text: rendered.into_output(),
293            sensitive,
294        });
295        Ok(ComposeCanonicalization { document, diagnostics })
296    }
297}
298
299pub(crate) fn native_project_finding(diagnostic: &ComposeDiagnostic) -> NativeFinding {
300    ComposeFindingStage::ProjectModel.native_finding(diagnostic)
301}
302
303fn compose_native_diagnostic(finding: NativeFinding) -> Result<Diagnostic, InvalidDiagnosticCode> {
304    let rule = match finding.code() {
305        "compose.interpolation.unset-variable" => RuleId::ComposeUnsetVariable,
306        "compose.interpolation.required-variable" => RuleId::ComposeRequiredVariable,
307        "compose.interpolation.invalid-expression" => RuleId::ComposeInterpolationInvalid,
308        "compose.interpolation.nesting-limit" => RuleId::ComposeInterpolationNestingLimit,
309        _ => match finding.severity() {
310            Severity::Error => RuleId::ComposeNativeError,
311            Severity::Note => RuleId::ComposeNativeNote,
312            _ => RuleId::ComposeNativeWarning,
313        },
314    };
315    Ok(Diagnostic::new(
316        rule.definition().diagnostic_code()?,
317        finding.severity(),
318        "ComposeLens reported a native Compose finding",
319    )
320    .with_native_finding(finding))
321}
322
323fn interpolation_variable<'a>(code: &str, message: &'a str) -> Option<&'a str> {
324    if !matches!(
325        code,
326        "compose.interpolation.unset-variable" | "compose.interpolation.required-variable"
327    ) {
328        return None;
329    }
330    let (_, remainder) = message.split_once('`')?;
331    let (variable, _) = remainder.split_once('`')?;
332    let mut bytes = variable.bytes();
333    let first = bytes.next()?;
334    ((first == b'_' || first.is_ascii_alphabetic()) && bytes.all(|byte| byte == b'_' || byte.is_ascii_alphanumeric()))
335        .then_some(variable)
336}