Skip to main content

compose_lens/profiles/
mod.rs

1//! Explicit, non-destructive Compose service-profile selection.
2
3use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
4use crate::merge::{MergeOperation, MergedProject, MergedValue};
5use crate::source::{SourceId, SourceSpan};
6use std::collections::BTreeSet;
7
8/// A requested or declared profile name does not follow the Compose grammar.
9pub const INVALID_PROFILE_NAME: DiagnosticCode = DiagnosticCode::new("compose.profiles.invalid-name");
10
11/// A service `profiles` field is not a sequence.
12pub const PROFILES_EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.profiles.expected-sequence");
13
14/// A service profile entry is not a scalar string.
15pub const PROFILE_EXPECTED_SCALAR: DiagnosticCode = DiagnosticCode::new("compose.profiles.expected-scalar");
16
17/// An explicitly authored profile list is empty.
18pub const EMPTY_PROFILE_LIST: DiagnosticCode = DiagnosticCode::new("compose.profiles.empty-list");
19
20/// The explicit active-profile input for one selection operation.
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct ProfileRequest {
23    active: BTreeSet<String>,
24    all: bool,
25}
26
27impl ProfileRequest {
28    /// Creates a request with no active profiles.
29    #[must_use]
30    pub const fn new() -> Self {
31        Self {
32            active: BTreeSet::new(),
33            all: false,
34        }
35    }
36
37    /// Creates a request that activates every valid declared profile.
38    #[must_use]
39    pub const fn all() -> Self {
40        Self {
41            active: BTreeSet::new(),
42            all: true,
43        }
44    }
45
46    /// Adds one active profile and reports whether it was newly inserted.
47    pub fn activate(&mut self, profile: impl Into<String>) -> bool {
48        self.active.insert(profile.into())
49    }
50
51    /// Adds one active profile using builder syntax.
52    #[must_use]
53    pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
54        let _ = self.activate(profile);
55        self
56    }
57
58    /// Returns explicitly active profiles in deterministic order.
59    pub fn active(&self) -> impl Iterator<Item = &str> {
60        self.active.iter().map(String::as_str)
61    }
62
63    /// Reports whether all declared profiles are active.
64    #[must_use]
65    pub const fn activates_all(&self) -> bool {
66        self.all
67    }
68}
69
70/// Why one service is active or inactive.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum ActivationReason {
73    /// The service has no effective `profiles` restriction.
74    Unprofiled,
75    /// At least one declared profile matched an explicitly active profile.
76    MatchingProfile(String),
77    /// The caller explicitly requested all profiles.
78    AllProfiles,
79    /// None of the declared profiles is active.
80    NoMatchingProfile,
81}
82
83/// The selection state of one authored service.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
85pub enum ServiceStatus {
86    /// The service participates in subsequent project processing.
87    Active,
88    /// The service remains in the merged project but is outside the selected view.
89    Inactive,
90}
91
92/// One declared service's profile decision.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct ServiceSelection {
95    name: String,
96    source: SourceSpan,
97    profiles: Vec<(String, SourceSpan)>,
98    status: ServiceStatus,
99    reason: ActivationReason,
100}
101
102impl ServiceSelection {
103    /// Returns the service name.
104    #[must_use]
105    pub fn name(&self) -> &str {
106        &self.name
107    }
108
109    /// Returns the effective service-key source span.
110    #[must_use]
111    pub const fn source(&self) -> SourceSpan {
112        self.source
113    }
114
115    /// Returns declared profile names and source spans in authored merge order.
116    #[must_use]
117    pub fn profiles(&self) -> &[(String, SourceSpan)] {
118        &self.profiles
119    }
120
121    /// Returns whether the service is active.
122    #[must_use]
123    pub const fn status(&self) -> ServiceStatus {
124        self.status
125    }
126
127    /// Returns the reason for the decision.
128    #[must_use]
129    pub const fn reason(&self) -> &ActivationReason {
130        &self.reason
131    }
132
133    /// Reports whether the service is active.
134    #[must_use]
135    pub const fn is_active(&self) -> bool {
136        matches!(self.status, ServiceStatus::Active)
137    }
138}
139
140/// A non-destructive service selection over one merged project.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct ProfileSelection {
143    project: MergedProject,
144    active_profiles: BTreeSet<String>,
145    all_profiles: bool,
146    services: Vec<ServiceSelection>,
147    diagnostics: Vec<Diagnostic>,
148}
149
150impl ProfileSelection {
151    /// Returns source documents identifying the merged project used for selection.
152    #[must_use]
153    pub fn source_ids(&self) -> &[SourceId] {
154        self.project.source_ids()
155    }
156
157    /// Returns explicitly active profiles in deterministic order.
158    pub fn active_profiles(&self) -> impl Iterator<Item = &str> {
159        self.active_profiles.iter().map(String::as_str)
160    }
161
162    /// Reports whether the request activated all declared profiles.
163    #[must_use]
164    pub const fn activates_all_profiles(&self) -> bool {
165        self.all_profiles
166    }
167
168    /// Returns service decisions in merged service order.
169    #[must_use]
170    pub fn services(&self) -> &[ServiceSelection] {
171        &self.services
172    }
173
174    /// Finds one service decision.
175    #[must_use]
176    pub fn service(&self, name: &str) -> Option<&ServiceSelection> {
177        self.services.iter().find(|service| service.name == name)
178    }
179
180    /// Reports whether a service exists and is active.
181    #[must_use]
182    pub fn is_active(&self, name: &str) -> bool {
183        self.service(name).is_some_and(ServiceSelection::is_active)
184    }
185
186    /// Returns selection diagnostics.
187    #[must_use]
188    pub fn diagnostics(&self) -> &[Diagnostic] {
189        &self.diagnostics
190    }
191
192    /// Reports whether selection emitted no error diagnostics.
193    #[must_use]
194    pub fn is_valid(&self) -> bool {
195        self.diagnostics
196            .iter()
197            .all(|diagnostic| diagnostic.severity() != Severity::Error)
198    }
199
200    pub(crate) fn belongs_to(&self, project: &MergedProject) -> bool {
201        &self.project == project
202    }
203}
204
205/// Selects active services from a merged project using only caller-supplied profiles.
206///
207/// The merged project is not modified. Explicit runtime service targeting and dependency startup
208/// are command concerns and are intentionally not inferred by this operation.
209#[must_use]
210pub fn select_profiles(project: &MergedProject, request: &ProfileRequest) -> ProfileSelection {
211    let mut diagnostics = Vec::new();
212    for profile in &request.active {
213        if !valid_profile_name(profile) {
214            diagnostics.push(Diagnostic::new(
215                INVALID_PROFILE_NAME,
216                Severity::Error,
217                "active profile name does not follow the Compose profile grammar",
218            ));
219        }
220    }
221
222    let services = project
223        .root()
224        .get("services")
225        .and_then(MergedValue::as_mapping)
226        .into_iter()
227        .flatten()
228        .filter_map(|entry| {
229            let source = entry.key_sources().last().copied()?;
230            let profiles_value = entry.value().get("profiles");
231            let (profiles, unrestricted) = read_profiles(profiles_value, &mut diagnostics);
232            let (status, reason) = if unrestricted {
233                (ServiceStatus::Active, ActivationReason::Unprofiled)
234            } else if request.all {
235                (ServiceStatus::Active, ActivationReason::AllProfiles)
236            } else if let Some(profile) = profiles
237                .iter()
238                .find(|(profile, _)| request.active.contains(profile) && valid_profile_name(profile))
239            {
240                (
241                    ServiceStatus::Active,
242                    ActivationReason::MatchingProfile(profile.0.clone()),
243                )
244            } else {
245                (ServiceStatus::Inactive, ActivationReason::NoMatchingProfile)
246            };
247            Some(ServiceSelection {
248                name: entry.key().to_owned(),
249                source,
250                profiles,
251                status,
252                reason,
253            })
254        })
255        .collect();
256
257    ProfileSelection {
258        project: project.clone(),
259        active_profiles: request.active.clone(),
260        all_profiles: request.all,
261        services,
262        diagnostics,
263    }
264}
265
266fn read_profiles(value: Option<&MergedValue>, diagnostics: &mut Vec<Diagnostic>) -> (Vec<(String, SourceSpan)>, bool) {
267    let Some(value) = value else {
268        return (Vec::new(), true);
269    };
270    let Some(values) = value.as_sequence() else {
271        diagnostics.push(
272            Diagnostic::new(
273                PROFILES_EXPECTED_SEQUENCE,
274                Severity::Error,
275                "service profiles must be a sequence",
276            )
277            .with_label(DiagnosticLabel::primary(
278                value
279                    .provenance()
280                    .effective_source()
281                    .unwrap_or_else(|| fallback_span(value)),
282                "not a profile sequence",
283            )),
284        );
285        return (Vec::new(), false);
286    };
287    if values.is_empty() {
288        if value.provenance().operation() == MergeOperation::Reset {
289            return (Vec::new(), true);
290        }
291        diagnostics.push(
292            Diagnostic::new(
293                EMPTY_PROFILE_LIST,
294                Severity::Error,
295                "an explicitly authored profiles list must not be empty",
296            )
297            .with_label(DiagnosticLabel::primary(
298                value
299                    .provenance()
300                    .effective_source()
301                    .unwrap_or_else(|| fallback_span(value)),
302                "empty profile list",
303            )),
304        );
305        return (Vec::new(), false);
306    }
307
308    let mut profiles = Vec::new();
309    for profile in values {
310        let Some(scalar) = profile.as_scalar() else {
311            diagnostics.push(
312                Diagnostic::new(
313                    PROFILE_EXPECTED_SCALAR,
314                    Severity::Error,
315                    "profile names must be scalar strings",
316                )
317                .with_label(DiagnosticLabel::primary(
318                    profile
319                        .provenance()
320                        .effective_source()
321                        .unwrap_or_else(|| fallback_span(profile)),
322                    "not a profile name",
323                )),
324            );
325            continue;
326        };
327        let span = profile
328            .provenance()
329            .effective_source()
330            .unwrap_or_else(|| fallback_span(profile));
331        if scalar.kind() != crate::merge::MergedScalarKind::String || !valid_profile_name(scalar.value()) {
332            diagnostics.push(
333                Diagnostic::new(
334                    INVALID_PROFILE_NAME,
335                    Severity::Error,
336                    "declared profile name does not follow the Compose profile grammar",
337                )
338                .with_label(DiagnosticLabel::primary(span, "invalid profile name")),
339            );
340        }
341        profiles.push((scalar.value().to_owned(), span));
342    }
343    (profiles, false)
344}
345
346fn valid_profile_name(value: &str) -> bool {
347    let mut bytes = value.bytes();
348    bytes.next().is_some_and(|byte| byte.is_ascii_alphanumeric())
349        && bytes.next().is_some_and(valid_profile_tail)
350        && bytes.all(valid_profile_tail)
351}
352
353fn valid_profile_tail(byte: u8) -> bool {
354    byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-')
355}
356
357fn fallback_span(value: &MergedValue) -> SourceSpan {
358    value
359        .provenance()
360        .sources()
361        .first()
362        .copied()
363        .unwrap_or_else(|| SourceSpan::from_valid_offsets(SourceId::new(0), 0, 0))
364}