1use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
4use crate::merge::{MergeOperation, MergedProject, MergedValue};
5use crate::source::{SourceId, SourceSpan};
6use std::collections::BTreeSet;
7
8pub const INVALID_PROFILE_NAME: DiagnosticCode = DiagnosticCode::new("compose.profiles.invalid-name");
10
11pub const PROFILES_EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.profiles.expected-sequence");
13
14pub const PROFILE_EXPECTED_SCALAR: DiagnosticCode = DiagnosticCode::new("compose.profiles.expected-scalar");
16
17pub const EMPTY_PROFILE_LIST: DiagnosticCode = DiagnosticCode::new("compose.profiles.empty-list");
19
20#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct ProfileRequest {
23 active: BTreeSet<String>,
24 all: bool,
25}
26
27impl ProfileRequest {
28 #[must_use]
30 pub const fn new() -> Self {
31 Self {
32 active: BTreeSet::new(),
33 all: false,
34 }
35 }
36
37 #[must_use]
39 pub const fn all() -> Self {
40 Self {
41 active: BTreeSet::new(),
42 all: true,
43 }
44 }
45
46 pub fn activate(&mut self, profile: impl Into<String>) -> bool {
48 self.active.insert(profile.into())
49 }
50
51 #[must_use]
53 pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
54 let _ = self.activate(profile);
55 self
56 }
57
58 pub fn active(&self) -> impl Iterator<Item = &str> {
60 self.active.iter().map(String::as_str)
61 }
62
63 #[must_use]
65 pub const fn activates_all(&self) -> bool {
66 self.all
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum ActivationReason {
73 Unprofiled,
75 MatchingProfile(String),
77 AllProfiles,
79 NoMatchingProfile,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
85pub enum ServiceStatus {
86 Active,
88 Inactive,
90}
91
92#[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 #[must_use]
105 pub fn name(&self) -> &str {
106 &self.name
107 }
108
109 #[must_use]
111 pub const fn source(&self) -> SourceSpan {
112 self.source
113 }
114
115 #[must_use]
117 pub fn profiles(&self) -> &[(String, SourceSpan)] {
118 &self.profiles
119 }
120
121 #[must_use]
123 pub const fn status(&self) -> ServiceStatus {
124 self.status
125 }
126
127 #[must_use]
129 pub const fn reason(&self) -> &ActivationReason {
130 &self.reason
131 }
132
133 #[must_use]
135 pub const fn is_active(&self) -> bool {
136 matches!(self.status, ServiceStatus::Active)
137 }
138}
139
140#[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 #[must_use]
153 pub fn source_ids(&self) -> &[SourceId] {
154 self.project.source_ids()
155 }
156
157 pub fn active_profiles(&self) -> impl Iterator<Item = &str> {
159 self.active_profiles.iter().map(String::as_str)
160 }
161
162 #[must_use]
164 pub const fn activates_all_profiles(&self) -> bool {
165 self.all_profiles
166 }
167
168 #[must_use]
170 pub fn services(&self) -> &[ServiceSelection] {
171 &self.services
172 }
173
174 #[must_use]
176 pub fn service(&self, name: &str) -> Option<&ServiceSelection> {
177 self.services.iter().find(|service| service.name == name)
178 }
179
180 #[must_use]
182 pub fn is_active(&self, name: &str) -> bool {
183 self.service(name).is_some_and(ServiceSelection::is_active)
184 }
185
186 #[must_use]
188 pub fn diagnostics(&self) -> &[Diagnostic] {
189 &self.diagnostics
190 }
191
192 #[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#[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}