Skip to main content

compose_lens/model/
dependency.rs

1//! Typed health checks and service dependency conditions.
2
3use super::{BooleanValue, FieldReference, Located};
4use crate::source::SourceSpan;
5
6/// A service dependency collection with its authored form retained.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum DependsOn {
9    /// Sequence of service names.
10    Short {
11        /// The complete sequence span.
12        span: SourceSpan,
13        /// Dependency service names in authored order.
14        services: Vec<Located<String>>,
15    },
16    /// Mapping of service names to dependency options.
17    Long {
18        /// The complete mapping span.
19        span: SourceSpan,
20        /// Dependency entries in authored order.
21        services: Vec<ServiceDependency>,
22    },
23}
24
25impl DependsOn {
26    /// Returns the complete collection span.
27    #[must_use]
28    pub const fn span(&self) -> SourceSpan {
29        match self {
30            Self::Short { span, .. } | Self::Long { span, .. } => *span,
31        }
32    }
33}
34
35/// One long-syntax service dependency.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ServiceDependency {
38    service: Located<String>,
39    span: SourceSpan,
40    condition: Option<Located<DependencyCondition>>,
41    restart: Option<Located<BooleanValue>>,
42    required: Option<Located<BooleanValue>>,
43    extension_fields: Vec<FieldReference>,
44    unknown_fields: Vec<FieldReference>,
45}
46
47impl ServiceDependency {
48    pub(super) const fn new(service: Located<String>, span: SourceSpan) -> Self {
49        Self {
50            service,
51            span,
52            condition: None,
53            restart: None,
54            required: None,
55            extension_fields: Vec::new(),
56            unknown_fields: Vec::new(),
57        }
58    }
59
60    pub(super) fn set_condition(&mut self, value: Located<DependencyCondition>) {
61        self.condition = Some(value);
62    }
63
64    pub(super) fn set_restart(&mut self, value: Located<BooleanValue>) {
65        self.restart = Some(value);
66    }
67
68    pub(super) fn set_required(&mut self, value: Located<BooleanValue>) {
69        self.required = Some(value);
70    }
71
72    pub(super) fn push_extension(&mut self, field: FieldReference) {
73        self.extension_fields.push(field);
74    }
75
76    pub(super) fn push_unknown(&mut self, field: FieldReference) {
77        self.unknown_fields.push(field);
78    }
79
80    /// Returns the dependency service name.
81    #[must_use]
82    pub const fn service(&self) -> &Located<String> {
83        &self.service
84    }
85
86    /// Returns the complete dependency mapping span.
87    #[must_use]
88    pub const fn span(&self) -> SourceSpan {
89        self.span
90    }
91
92    /// Returns the explicitly authored dependency condition.
93    #[must_use]
94    pub const fn condition(&self) -> Option<&Located<DependencyCondition>> {
95        self.condition.as_ref()
96    }
97
98    /// Returns whether explicit Compose updates restart the dependent service.
99    #[must_use]
100    pub const fn restart(&self) -> Option<&Located<BooleanValue>> {
101        self.restart.as_ref()
102    }
103
104    /// Returns whether the dependency is required.
105    #[must_use]
106    pub const fn required(&self) -> Option<&Located<BooleanValue>> {
107        self.required.as_ref()
108    }
109
110    /// Returns retained `x-` fields.
111    #[must_use]
112    pub fn extension_fields(&self) -> &[FieldReference] {
113        &self.extension_fields
114    }
115
116    /// Returns unrecognized dependency fields.
117    #[must_use]
118    pub fn unknown_fields(&self) -> &[FieldReference] {
119        &self.unknown_fields
120    }
121}
122
123/// A long-syntax dependency condition.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum DependencyCondition {
126    /// Wait until the dependency has started.
127    ServiceStarted,
128    /// Wait until the dependency's health check succeeds.
129    ServiceHealthy,
130    /// Wait until the dependency exits successfully.
131    ServiceCompletedSuccessfully,
132    /// A deferred or provider-specific condition.
133    Other(String),
134}
135
136impl DependencyCondition {
137    pub(crate) fn parse(value: String) -> Self {
138        match value.as_str() {
139            "service_started" => Self::ServiceStarted,
140            "service_healthy" => Self::ServiceHealthy,
141            "service_completed_successfully" => Self::ServiceCompletedSuccessfully,
142            _ => Self::Other(value),
143        }
144    }
145
146    /// Reports whether the condition is defined by the Compose Specification.
147    #[must_use]
148    pub const fn is_known(&self) -> bool {
149        !matches!(self, Self::Other(_))
150    }
151}
152
153/// A health-check duration retained before interpolation.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub enum HealthcheckDuration {
156    /// A syntactically valid Compose duration.
157    Value(String),
158    /// A deferred interpolation expression.
159    Expression(String),
160    /// An invalid or provider-specific scalar retained for diagnostics.
161    Other(String),
162}
163
164impl HealthcheckDuration {
165    pub(crate) fn parse(value: String) -> Self {
166        if value.contains('$') {
167            Self::Expression(value)
168        } else if valid_duration(&value) {
169            Self::Value(value)
170        } else {
171            Self::Other(value)
172        }
173    }
174
175    /// Reports whether this is a valid or deferred value.
176    #[must_use]
177    pub const fn is_valid(&self) -> bool {
178        !matches!(self, Self::Other(_))
179    }
180
181    /// Returns the retained scalar spelling.
182    #[must_use]
183    pub fn raw(&self) -> &str {
184        match self {
185            Self::Value(value) | Self::Expression(value) | Self::Other(value) => value,
186        }
187    }
188}
189
190/// A health-check retry count retained before interpolation.
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub enum HealthcheckRetries {
193    /// A non-negative integer with its spelling retained.
194    Count(String),
195    /// A deferred interpolation expression.
196    Expression(String),
197    /// An invalid or provider-specific scalar retained for diagnostics.
198    Other(String),
199}
200
201impl HealthcheckRetries {
202    pub(crate) fn parse(value: String) -> Self {
203        if value.contains('$') {
204            Self::Expression(value)
205        } else if !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) {
206            Self::Count(value)
207        } else {
208            Self::Other(value)
209        }
210    }
211
212    /// Reports whether this is a valid or deferred value.
213    #[must_use]
214    pub const fn is_valid(&self) -> bool {
215        !matches!(self, Self::Other(_))
216    }
217
218    /// Returns the retained scalar spelling.
219    #[must_use]
220    pub fn raw(&self) -> &str {
221        match self {
222            Self::Count(value) | Self::Expression(value) | Self::Other(value) => value,
223        }
224    }
225}
226
227/// A service health-check definition.
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct Healthcheck {
230    span: SourceSpan,
231    test: Option<HealthcheckTest>,
232    interval: Option<Located<HealthcheckDuration>>,
233    timeout: Option<Located<HealthcheckDuration>>,
234    retries: Option<Located<HealthcheckRetries>>,
235    start_period: Option<Located<HealthcheckDuration>>,
236    start_interval: Option<Located<HealthcheckDuration>>,
237    disable: Option<Located<BooleanValue>>,
238    extension_fields: Vec<FieldReference>,
239    unknown_fields: Vec<FieldReference>,
240}
241
242impl Healthcheck {
243    pub(super) const fn new(span: SourceSpan) -> Self {
244        Self {
245            span,
246            test: None,
247            interval: None,
248            timeout: None,
249            retries: None,
250            start_period: None,
251            start_interval: None,
252            disable: None,
253            extension_fields: Vec::new(),
254            unknown_fields: Vec::new(),
255        }
256    }
257
258    pub(super) fn set_test(&mut self, value: HealthcheckTest) {
259        self.test = Some(value);
260    }
261
262    pub(super) fn set_interval(&mut self, value: Located<HealthcheckDuration>) {
263        self.interval = Some(value);
264    }
265
266    pub(super) fn set_timeout(&mut self, value: Located<HealthcheckDuration>) {
267        self.timeout = Some(value);
268    }
269
270    pub(super) fn set_retries(&mut self, value: Located<HealthcheckRetries>) {
271        self.retries = Some(value);
272    }
273
274    pub(super) fn set_start_period(&mut self, value: Located<HealthcheckDuration>) {
275        self.start_period = Some(value);
276    }
277
278    pub(super) fn set_start_interval(&mut self, value: Located<HealthcheckDuration>) {
279        self.start_interval = Some(value);
280    }
281
282    pub(super) fn set_disable(&mut self, value: Located<BooleanValue>) {
283        self.disable = Some(value);
284    }
285
286    pub(super) fn push_extension(&mut self, field: FieldReference) {
287        self.extension_fields.push(field);
288    }
289
290    pub(super) fn push_unknown(&mut self, field: FieldReference) {
291        self.unknown_fields.push(field);
292    }
293
294    /// Returns the complete health-check mapping span.
295    #[must_use]
296    pub const fn span(&self) -> SourceSpan {
297        self.span
298    }
299
300    /// Returns the health command with its scalar/list form retained.
301    #[must_use]
302    pub const fn test(&self) -> Option<&HealthcheckTest> {
303        self.test.as_ref()
304    }
305
306    /// Returns the explicitly authored interval.
307    #[must_use]
308    pub const fn interval(&self) -> Option<&Located<HealthcheckDuration>> {
309        self.interval.as_ref()
310    }
311
312    /// Returns the explicitly authored timeout.
313    #[must_use]
314    pub const fn timeout(&self) -> Option<&Located<HealthcheckDuration>> {
315        self.timeout.as_ref()
316    }
317
318    /// Returns the explicitly authored retry count.
319    #[must_use]
320    pub const fn retries(&self) -> Option<&Located<HealthcheckRetries>> {
321        self.retries.as_ref()
322    }
323
324    /// Returns the explicitly authored start period.
325    #[must_use]
326    pub const fn start_period(&self) -> Option<&Located<HealthcheckDuration>> {
327        self.start_period.as_ref()
328    }
329
330    /// Returns the explicitly authored start interval.
331    #[must_use]
332    pub const fn start_interval(&self) -> Option<&Located<HealthcheckDuration>> {
333        self.start_interval.as_ref()
334    }
335
336    /// Returns whether the image health check is explicitly disabled.
337    #[must_use]
338    pub const fn disable(&self) -> Option<&Located<BooleanValue>> {
339        self.disable.as_ref()
340    }
341
342    /// Reports whether the authored health check is explicitly disabled.
343    #[must_use]
344    pub fn is_disabled(&self) -> bool {
345        matches!(
346            self.disable.as_ref().map(Located::value),
347            Some(BooleanValue::Literal(true))
348        ) || matches!(
349            self.test.as_ref().and_then(HealthcheckTest::kind),
350            Some(HealthcheckTestKind::None)
351        )
352    }
353
354    /// Returns retained `x-` fields.
355    #[must_use]
356    pub fn extension_fields(&self) -> &[FieldReference] {
357        &self.extension_fields
358    }
359
360    /// Returns unrecognized health-check fields.
361    #[must_use]
362    pub fn unknown_fields(&self) -> &[FieldReference] {
363        &self.unknown_fields
364    }
365}
366
367/// A health-check command with scalar and list forms kept distinct.
368#[derive(Debug, Clone, PartialEq, Eq)]
369pub enum HealthcheckTest {
370    /// Scalar form, equivalent to `CMD-SHELL` after processing.
371    String(Located<String>),
372    /// List form, retaining every authored item.
373    List {
374        /// The complete sequence span.
375        span: SourceSpan,
376        /// The command mode derived from the first item, when present.
377        kind: Option<HealthcheckTestKind>,
378        /// Every list item, including the command-mode token.
379        values: Vec<Located<String>>,
380    },
381}
382
383impl HealthcheckTest {
384    /// Returns the effective command mode without rewriting the authored form.
385    #[must_use]
386    pub fn kind(&self) -> Option<HealthcheckTestKind> {
387        match self {
388            Self::String(_) => Some(HealthcheckTestKind::CmdShell),
389            Self::List { kind, .. } => *kind,
390        }
391    }
392
393    /// Returns the complete value span.
394    #[must_use]
395    pub const fn span(&self) -> SourceSpan {
396        match self {
397            Self::String(value) => value.span(),
398            Self::List { span, .. } => *span,
399        }
400    }
401}
402
403/// The command-mode token at the beginning of a health-check list.
404#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
405pub enum HealthcheckTestKind {
406    /// Disable the image health check.
407    None,
408    /// Execute the remaining list directly.
409    Cmd,
410    /// Execute the remaining string through the container shell.
411    CmdShell,
412    /// An unrecognized command-mode token.
413    Other,
414}
415
416impl HealthcheckTestKind {
417    pub(crate) fn parse(value: &str) -> Self {
418        match value {
419            "NONE" => Self::None,
420            "CMD" => Self::Cmd,
421            "CMD-SHELL" => Self::CmdShell,
422            _ => Self::Other,
423        }
424    }
425}
426
427fn valid_duration(mut value: &str) -> bool {
428    if value == "0" {
429        return true;
430    }
431    let mut found = false;
432    while !value.is_empty() {
433        let number_end = value
434            .char_indices()
435            .take_while(|(_, character)| character.is_ascii_digit() || *character == '.')
436            .map(|(index, character)| index + character.len_utf8())
437            .last()
438            .unwrap_or(0);
439        if number_end == 0 {
440            return false;
441        }
442        let number = &value[..number_end];
443        if number.matches('.').count() > 1 || number == "." {
444            return false;
445        }
446        value = &value[number_end..];
447        let Some(unit) = ["ns", "us", "µs", "μs", "ms", "s", "m", "h"]
448            .into_iter()
449            .find(|unit| value.starts_with(unit))
450        else {
451            return false;
452        };
453        value = &value[unit.len()..];
454        found = true;
455    }
456    found
457}
458
459#[cfg(test)]
460mod tests {
461    use super::valid_duration;
462
463    #[test]
464    fn accepts_compose_duration_segments_without_runtime_parsing() {
465        for value in ["0", "30s", "1m30s", "1.5s", "250ms", "10us"] {
466            assert!(valid_duration(value), "expected valid duration {value}");
467        }
468        for value in ["", "forever", "-1s", "1", "1..5s"] {
469            assert!(!valid_duration(value), "expected invalid duration {value}");
470        }
471    }
472}