Skip to main content

compose_lens/resolution/
defaults.rs

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/// A configurable omission for which a semantic default can be requested.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum DefaultKind {
12    /// The implicit project network definition.
13    ImplicitNetwork,
14    /// A service attachment to the implicit `default` network.
15    ServiceNetwork,
16    /// Port protocol, documented as `tcp`.
17    PortProtocol,
18    /// Port publication mode, documented as `ingress`.
19    PortMode,
20    /// Volume access mode, documented as read-write.
21    VolumeReadOnly,
22    /// Config target path.
23    ConfigTarget,
24    /// Config file mode, documented as `0444`.
25    ConfigMode,
26    /// Secret target name.
27    SecretTarget,
28    /// Secret file mode, documented as `0444`.
29    SecretMode,
30    /// Service restart policy, documented as `no`.
31    RestartPolicy,
32}
33
34/// The project location where a default would apply.
35#[derive(Debug, Clone, PartialEq, Eq, Hash)]
36pub enum DefaultLocation {
37    /// A project-level implicit object.
38    Project,
39    /// An omitted service field.
40    Service {
41        /// Service name.
42        service: String,
43    },
44    /// One item in a service sequence.
45    ServiceItem {
46        /// Service name.
47        service: String,
48        /// Field name.
49        field: String,
50        /// Zero-based merged item index.
51        index: usize,
52    },
53}
54
55/// A typed default value supplied by a policy.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum DefaultValue {
58    /// A string-valued default.
59    String(String),
60    /// A boolean-valued default.
61    Boolean(bool),
62}
63
64/// One explicit request made to a caller-owned default policy.
65#[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    /// Returns the omitted semantic field.
76    #[must_use]
77    pub const fn kind(&self) -> DefaultKind {
78        self.kind
79    }
80
81    /// Returns where the default would apply.
82    #[must_use]
83    pub const fn location(&self) -> &DefaultLocation {
84        &self.location
85    }
86
87    /// Returns the source resource name needed by target-path defaults.
88    #[must_use]
89    pub fn source_name(&self) -> Option<&str> {
90        self.source_name.as_deref()
91    }
92
93    /// Returns a nearby source span for diagnostics and provenance.
94    #[must_use]
95    pub const fn anchor(&self) -> SourceSpan {
96        self.anchor
97    }
98
99    /// Reports whether interpolation inserted sensitive content into the request.
100    #[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
126/// Supplies semantic defaults without granting access to ambient state.
127pub trait DefaultProvider {
128    /// Returns a value for one omission, or `None` to leave it unresolved.
129    fn resolve(&self, request: &DefaultRequest) -> Option<DefaultValue>;
130}
131
132/// A policy that deliberately leaves every omission unresolved.
133#[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/// Container path platform used by the specification-oriented defaults.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
144pub enum ContainerPlatform {
145    /// Linux container paths.
146    Linux,
147    /// Windows container paths.
148    Windows,
149}
150
151/// The documented Compose Specification defaults covered by this release.
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub struct ComposeDefaults {
154    platform: ContainerPlatform,
155}
156
157impl ComposeDefaults {
158    /// Creates a specification-oriented provider for the target container platform.
159    #[must_use]
160    pub const fn new(platform: ContainerPlatform) -> Self {
161        Self { platform }
162    }
163
164    /// Returns the target container platform.
165    #[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/// One policy-approved default decision; the merged source remains unchanged.
197#[derive(Clone, PartialEq, Eq)]
198pub struct AppliedDefault {
199    request: DefaultRequest,
200    value: DefaultValue,
201}
202
203impl AppliedDefault {
204    /// Returns the request that caused the decision.
205    #[must_use]
206    pub const fn request(&self) -> &DefaultRequest {
207        &self.request
208    }
209
210    /// Returns the supplied default value.
211    #[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/// Non-destructive default decisions for one selected project view.
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct DefaultResolution {
237    defaults: Vec<AppliedDefault>,
238    diagnostics: Vec<Diagnostic>,
239}
240
241impl DefaultResolution {
242    /// Returns policy-approved defaults in deterministic traversal order.
243    #[must_use]
244    pub fn defaults(&self) -> &[AppliedDefault] {
245        &self.defaults
246    }
247
248    /// Returns resolution diagnostics.
249    #[must_use]
250    pub fn diagnostics(&self) -> &[Diagnostic] {
251        &self.diagnostics
252    }
253
254    /// Reports whether default resolution emitted no error diagnostics.
255    #[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/// Requests defaults for omissions in active services without modifying the merged project.
264#[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}