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}
93
94impl ComposeSource {
95    /// Creates a source without guessing profiles or source filenames from ambient state.
96    ///
97    /// Every Compose source initially receives the stable neutral identity
98    /// `compose-source-<numeric-id>`. Call [`Self::with_source_id`] to replace it with a caller-owned
99    /// path, URI, or other display identity. The project's explicit top-level `name` wins when
100    /// present; the fallback is used when Compose project naming was supplied externally or
101    /// omitted.
102    ///
103    /// # Errors
104    ///
105    /// Returns [`ModelError`] if a generated fallback source identity violates the neutral-model
106    /// invariant. This cannot occur for the current `compose-source-<u32>` spelling, but keeping
107    /// construction fallible avoids a hidden panic if that policy changes.
108    pub fn new(project: MergedProject, fallback_application_name: Identifier) -> Result<Self, ModelError> {
109        let source_ids = project
110            .source_ids()
111            .iter()
112            .copied()
113            .map(|source_id| {
114                SourceId::new(format!("compose-source-{}", source_id.get())).map(|neutral| (source_id, neutral))
115            })
116            .collect::<Result<_, _>>()?;
117        Ok(Self {
118            project,
119            fallback_application_name,
120            source_ids,
121            profile_selection: None,
122            native_findings: Vec::new(),
123        })
124    }
125
126    /// Assigns a caller-owned neutral identity to one Compose source document.
127    ///
128    /// Unknown Compose source IDs are retained in the map but cannot contribute provenance unless
129    /// they also occur in the merged project.
130    #[must_use]
131    pub fn with_source_id(mut self, compose: ComposeSourceId, neutral: SourceId) -> Self {
132        self.source_ids.insert(compose, neutral);
133        self
134    }
135
136    /// Attaches the explicit selection produced by `ComposeLens` project processing.
137    #[must_use]
138    pub fn with_profile_selection(mut self, selection: ProfileSelection) -> Self {
139        self.profile_selection = Some(selection);
140        self
141    }
142
143    /// Retains native Compose diagnostics without terminal rendering or source contents.
144    ///
145    /// Diagnostics should be attached at the stage where the caller obtained them. The adapter
146    /// keeps native codes as provenance and maps them to BoxFerry-owned rules during import.
147    #[must_use]
148    pub fn with_native_diagnostics<'a>(
149        mut self,
150        stage: ComposeFindingStage,
151        diagnostics: impl IntoIterator<Item = &'a ComposeDiagnostic>,
152    ) -> Self {
153        self.native_findings.extend(
154            diagnostics
155                .into_iter()
156                .map(|diagnostic| stage.native_finding(diagnostic)),
157        );
158        self
159    }
160
161    /// Returns the merged native project.
162    #[must_use]
163    pub const fn project(&self) -> &MergedProject {
164        &self.project
165    }
166
167    /// Returns the caller-selected fallback application name.
168    #[must_use]
169    pub const fn fallback_application_name(&self) -> &Identifier {
170        &self.fallback_application_name
171    }
172
173    /// Resolves a Compose source identity into its neutral-model identity.
174    #[must_use]
175    pub fn source_id(&self, compose: ComposeSourceId) -> Option<&SourceId> {
176        self.source_ids.get(&compose)
177    }
178
179    /// Returns the explicit profile selection, when one was supplied.
180    #[must_use]
181    pub const fn profile_selection(&self) -> Option<&ProfileSelection> {
182        self.profile_selection.as_ref()
183    }
184
185    /// Returns retained native Compose findings in processing order.
186    #[must_use]
187    pub fn native_findings(&self) -> &[NativeFinding] {
188        &self.native_findings
189    }
190}
191
192pub(crate) fn native_project_finding(diagnostic: &ComposeDiagnostic) -> NativeFinding {
193    ComposeFindingStage::ProjectModel.native_finding(diagnostic)
194}
195
196fn interpolation_variable<'a>(code: &str, message: &'a str) -> Option<&'a str> {
197    if !matches!(
198        code,
199        "compose.interpolation.unset-variable" | "compose.interpolation.required-variable"
200    ) {
201        return None;
202    }
203    let (_, remainder) = message.split_once('`')?;
204    let (variable, _) = remainder.split_once('`')?;
205    let mut bytes = variable.bytes();
206    let first = bytes.next()?;
207    ((first == b'_' || first.is_ascii_alphabetic()) && bytes.all(|byte| byte == b'_' || byte.is_ascii_alphanumeric()))
208        .then_some(variable)
209}