1use super::{effective_span, entry_span, selection_matches, service_entries, service_in_scope};
2use crate::diagnostic::{Diagnostic, Severity};
3use crate::merge::{MergedProject, MergedValue};
4use crate::model::{Located, ShortPort, ShortVolumeMount};
5use crate::profiles::ProfileSelection;
6use crate::source::SourceSpan;
7use std::fmt;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum DefaultKind {
12 ImplicitNetwork,
14 ServiceNetwork,
16 PortProtocol,
18 PortMode,
20 VolumeReadOnly,
22 ConfigTarget,
24 ConfigMode,
26 SecretTarget,
28 SecretMode,
30 RestartPolicy,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Hash)]
36pub enum DefaultLocation {
37 Project,
39 Service {
41 service: String,
43 },
44 ServiceItem {
46 service: String,
48 field: String,
50 index: usize,
52 },
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum DefaultValue {
58 String(String),
60 Boolean(bool),
62}
63
64#[derive(Clone, PartialEq, Eq)]
66pub struct DefaultRequest {
67 kind: DefaultKind,
68 location: DefaultLocation,
69 source_name: Option<String>,
70 anchor: SourceSpan,
71 sensitive: bool,
72}
73
74impl DefaultRequest {
75 #[must_use]
77 pub const fn kind(&self) -> DefaultKind {
78 self.kind
79 }
80
81 #[must_use]
83 pub const fn location(&self) -> &DefaultLocation {
84 &self.location
85 }
86
87 #[must_use]
89 pub fn source_name(&self) -> Option<&str> {
90 self.source_name.as_deref()
91 }
92
93 #[must_use]
95 pub const fn anchor(&self) -> SourceSpan {
96 self.anchor
97 }
98
99 #[must_use]
101 pub const fn is_sensitive(&self) -> bool {
102 self.sensitive
103 }
104}
105
106impl fmt::Debug for DefaultRequest {
107 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
108 formatter
109 .debug_struct("DefaultRequest")
110 .field("kind", &self.kind)
111 .field("location", &self.location)
112 .field(
113 "source_name",
114 &if self.sensitive {
115 Some("<redacted>")
116 } else {
117 self.source_name.as_deref()
118 },
119 )
120 .field("anchor", &self.anchor)
121 .field("sensitive", &self.sensitive)
122 .finish()
123 }
124}
125
126pub trait DefaultProvider {
128 fn resolve(&self, request: &DefaultRequest) -> Option<DefaultValue>;
130}
131
132#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
134pub struct NoDefaults;
135
136impl DefaultProvider for NoDefaults {
137 fn resolve(&self, _request: &DefaultRequest) -> Option<DefaultValue> {
138 None
139 }
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
144pub enum ContainerPlatform {
145 Linux,
147 Windows,
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub struct ComposeDefaults {
154 platform: ContainerPlatform,
155}
156
157impl ComposeDefaults {
158 #[must_use]
160 pub const fn new(platform: ContainerPlatform) -> Self {
161 Self { platform }
162 }
163
164 #[must_use]
166 pub const fn platform(self) -> ContainerPlatform {
167 self.platform
168 }
169}
170
171impl DefaultProvider for ComposeDefaults {
172 fn resolve(&self, request: &DefaultRequest) -> Option<DefaultValue> {
173 match request.kind {
174 DefaultKind::ImplicitNetwork | DefaultKind::ServiceNetwork => {
175 Some(DefaultValue::String("default".to_owned()))
176 }
177 DefaultKind::PortProtocol => Some(DefaultValue::String("tcp".to_owned())),
178 DefaultKind::PortMode => Some(DefaultValue::String("ingress".to_owned())),
179 DefaultKind::VolumeReadOnly => Some(DefaultValue::Boolean(false)),
180 DefaultKind::ConfigTarget => request.source_name.as_ref().map(|source| {
181 DefaultValue::String(match self.platform {
182 ContainerPlatform::Linux => format!("/{source}"),
183 ContainerPlatform::Windows => format!(r"C:\{source}"),
184 })
185 }),
186 DefaultKind::ConfigMode | DefaultKind::SecretMode => Some(DefaultValue::String("0444".to_owned())),
187 DefaultKind::SecretTarget => request
188 .source_name
189 .as_ref()
190 .map(|source| DefaultValue::String(source.clone())),
191 DefaultKind::RestartPolicy => Some(DefaultValue::String("no".to_owned())),
192 }
193 }
194}
195
196#[derive(Clone, PartialEq, Eq)]
198pub struct AppliedDefault {
199 request: DefaultRequest,
200 value: DefaultValue,
201}
202
203impl AppliedDefault {
204 #[must_use]
206 pub const fn request(&self) -> &DefaultRequest {
207 &self.request
208 }
209
210 #[must_use]
212 pub const fn value(&self) -> &DefaultValue {
213 &self.value
214 }
215}
216
217impl fmt::Debug for AppliedDefault {
218 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
219 formatter
220 .debug_struct("AppliedDefault")
221 .field("request", &self.request)
222 .field(
223 "value",
224 &if self.request.sensitive {
225 "<redacted>".to_owned()
226 } else {
227 format!("{:?}", self.value)
228 },
229 )
230 .finish()
231 }
232}
233
234#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct DefaultResolution {
237 defaults: Vec<AppliedDefault>,
238 diagnostics: Vec<Diagnostic>,
239}
240
241impl DefaultResolution {
242 #[must_use]
244 pub fn defaults(&self) -> &[AppliedDefault] {
245 &self.defaults
246 }
247
248 #[must_use]
250 pub fn diagnostics(&self) -> &[Diagnostic] {
251 &self.diagnostics
252 }
253
254 #[must_use]
256 pub fn is_valid(&self) -> bool {
257 self.diagnostics
258 .iter()
259 .all(|diagnostic| diagnostic.severity() != Severity::Error)
260 }
261}
262
263#[must_use]
265pub fn resolve_defaults(
266 project: &MergedProject,
267 selection: Option<&ProfileSelection>,
268 provider: &dyn DefaultProvider,
269) -> DefaultResolution {
270 let mut diagnostics = Vec::new();
271 if !selection_matches(project, selection, &mut diagnostics) {
272 return DefaultResolution {
273 defaults: Vec::new(),
274 diagnostics,
275 };
276 }
277
278 let mut defaults = Vec::new();
279 let mut needs_implicit_network = false;
280 for service in service_entries(project) {
281 if !service_in_scope(selection, service.key()) {
282 continue;
283 }
284 let anchor = entry_span(service);
285 if service.value().get("restart").is_none() {
286 request(
287 provider,
288 &mut defaults,
289 DefaultKind::RestartPolicy,
290 service_location(service.key()),
291 None,
292 anchor,
293 false,
294 );
295 }
296 if service.value().get("network_mode").is_none() && networks_empty(service.value().get("networks")) {
297 needs_implicit_network = true;
298 request(
299 provider,
300 &mut defaults,
301 DefaultKind::ServiceNetwork,
302 service_location(service.key()),
303 None,
304 anchor,
305 false,
306 );
307 }
308 collect_port_defaults(service.key(), service.value(), provider, &mut defaults);
309 collect_volume_defaults(service.key(), service.value(), provider, &mut defaults);
310 collect_grant_defaults(service.key(), service.value(), "configs", true, provider, &mut defaults);
311 collect_grant_defaults(
312 service.key(),
313 service.value(),
314 "secrets",
315 false,
316 provider,
317 &mut defaults,
318 );
319 }
320
321 let has_default_network = project
322 .root()
323 .get("networks")
324 .and_then(MergedValue::as_mapping)
325 .is_some_and(|entries| entries.iter().any(|entry| entry.key() == "default"));
326 if needs_implicit_network && !has_default_network {
327 request(
328 provider,
329 &mut defaults,
330 DefaultKind::ImplicitNetwork,
331 DefaultLocation::Project,
332 None,
333 effective_span(project.root()),
334 false,
335 );
336 }
337
338 DefaultResolution { defaults, diagnostics }
339}
340
341fn service_location(service: &str) -> DefaultLocation {
342 DefaultLocation::Service {
343 service: service.to_owned(),
344 }
345}
346
347fn item_location(service: &str, field: &str, index: usize) -> DefaultLocation {
348 DefaultLocation::ServiceItem {
349 service: service.to_owned(),
350 field: field.to_owned(),
351 index,
352 }
353}
354
355fn networks_empty(value: Option<&MergedValue>) -> bool {
356 value.is_none_or(|value| {
357 value.as_sequence().is_some_and(<[MergedValue]>::is_empty)
358 || value.as_mapping().is_some_and(<[crate::merge::MergedEntry]>::is_empty)
359 })
360}
361
362fn collect_port_defaults(
363 service: &str,
364 value: &MergedValue,
365 provider: &dyn DefaultProvider,
366 defaults: &mut Vec<AppliedDefault>,
367) {
368 let Some(ports) = value.get("ports").and_then(MergedValue::as_sequence) else {
369 return;
370 };
371 for (index, port) in ports.iter().enumerate() {
372 let anchor = effective_span(port);
373 let protocol_missing = port.as_scalar().is_some_and(|scalar| {
374 ShortPort::parse(Located::new(scalar.value().to_owned(), anchor))
375 .protocol()
376 .is_none()
377 }) || port.as_mapping().is_some_and(|_| port.get("protocol").is_none());
378 if protocol_missing {
379 request(
380 provider,
381 defaults,
382 DefaultKind::PortProtocol,
383 item_location(service, "ports", index),
384 None,
385 anchor,
386 false,
387 );
388 }
389 if port.as_scalar().is_some() || port.as_mapping().is_some_and(|_| port.get("mode").is_none()) {
390 request(
391 provider,
392 defaults,
393 DefaultKind::PortMode,
394 item_location(service, "ports", index),
395 None,
396 anchor,
397 false,
398 );
399 }
400 }
401}
402
403fn collect_volume_defaults(
404 service: &str,
405 value: &MergedValue,
406 provider: &dyn DefaultProvider,
407 defaults: &mut Vec<AppliedDefault>,
408) {
409 let Some(volumes) = value.get("volumes").and_then(MergedValue::as_sequence) else {
410 return;
411 };
412 for (index, volume) in volumes.iter().enumerate() {
413 let anchor = effective_span(volume);
414 let missing = volume.as_scalar().is_some_and(|scalar| {
415 let mount = ShortVolumeMount::new(Located::new(scalar.value().to_owned(), anchor));
416 !mount
417 .options()
418 .iter()
419 .any(|option| matches!(option.as_str(), "ro" | "rw"))
420 }) || volume.as_mapping().is_some_and(|_| volume.get("read_only").is_none());
421 if missing {
422 request(
423 provider,
424 defaults,
425 DefaultKind::VolumeReadOnly,
426 item_location(service, "volumes", index),
427 None,
428 anchor,
429 false,
430 );
431 }
432 }
433}
434
435fn collect_grant_defaults(
436 service: &str,
437 value: &MergedValue,
438 field: &str,
439 config: bool,
440 provider: &dyn DefaultProvider,
441 defaults: &mut Vec<AppliedDefault>,
442) {
443 let Some(grants) = value.get(field).and_then(MergedValue::as_sequence) else {
444 return;
445 };
446 for (index, grant) in grants.iter().enumerate() {
447 let source = grant.as_scalar().map(|scalar| (scalar, true)).or_else(|| {
448 grant
449 .get("source")
450 .and_then(MergedValue::as_scalar)
451 .map(|scalar| (scalar, grant.get("target").is_none()))
452 });
453 let Some((source, target_missing)) = source else {
454 continue;
455 };
456 let location = item_location(service, field, index);
457 let anchor = effective_span(grant);
458 if target_missing {
459 request(
460 provider,
461 defaults,
462 if config {
463 DefaultKind::ConfigTarget
464 } else {
465 DefaultKind::SecretTarget
466 },
467 location.clone(),
468 Some(source.value().to_owned()),
469 anchor,
470 source.is_sensitive(),
471 );
472 }
473 if grant.as_scalar().is_some() || grant.get("mode").is_none() {
474 request(
475 provider,
476 defaults,
477 if config {
478 DefaultKind::ConfigMode
479 } else {
480 DefaultKind::SecretMode
481 },
482 location,
483 None,
484 anchor,
485 false,
486 );
487 }
488 }
489}
490
491#[allow(clippy::too_many_arguments)]
492fn request(
493 provider: &dyn DefaultProvider,
494 defaults: &mut Vec<AppliedDefault>,
495 kind: DefaultKind,
496 location: DefaultLocation,
497 source_name: Option<String>,
498 anchor: SourceSpan,
499 sensitive: bool,
500) {
501 let request = DefaultRequest {
502 kind,
503 location,
504 source_name,
505 anchor,
506 sensitive,
507 };
508 if let Some(value) = provider.resolve(&request) {
509 defaults.push(AppliedDefault { request, value });
510 }
511}