Skip to main content

compose_lens/model/
pull.rs

1//! Raw-preserving service image pull policies.
2
3use super::Located;
4
5/// A service-level Compose `pull_policy` value with its authored scalar retained.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct PullPolicy {
8    raw: Located<String>,
9    kind: PullPolicyKind,
10}
11
12impl PullPolicy {
13    pub(crate) fn parse(raw: Located<String>) -> Self {
14        let kind = match raw.value().as_str() {
15            "always" => PullPolicyKind::Always,
16            "never" => PullPolicyKind::Never,
17            "missing" => PullPolicyKind::Missing,
18            "if_not_present" => PullPolicyKind::IfNotPresentAlias,
19            "build" => PullPolicyKind::Build,
20            "daily" => PullPolicyKind::Daily,
21            "weekly" => PullPolicyKind::Weekly,
22            "refresh" => PullPolicyKind::RefreshSchemaOnly,
23            value if value.contains('$') => PullPolicyKind::Expression,
24            value => parse_every_duration(value).map_or(PullPolicyKind::Other, |duration| PullPolicyKind::Every {
25                duration: duration.to_owned(),
26            }),
27        };
28        Self { raw, kind }
29    }
30
31    /// Returns the complete authored scalar and its source span.
32    #[must_use]
33    pub const fn raw(&self) -> &Located<String> {
34        &self.raw
35    }
36
37    /// Returns the non-destructive policy classification.
38    #[must_use]
39    pub const fn kind(&self) -> &PullPolicyKind {
40        &self.kind
41    }
42
43    /// Reports whether the value is documented, deferred, or recognized by the current schema.
44    #[must_use]
45    pub const fn is_recognized(&self) -> bool {
46        !matches!(self.kind, PullPolicyKind::Other)
47    }
48}
49
50/// The recognized family of a service-level Compose image pull policy.
51#[derive(Debug, Clone, PartialEq, Eq)]
52#[non_exhaustive]
53pub enum PullPolicyKind {
54    /// Pull the image before every service start.
55    Always,
56    /// Never pull and rely on a cached image.
57    Never,
58    /// Pull only when the image is missing.
59    Missing,
60    /// The retained `if_not_present` alias for [`Self::Missing`].
61    IfNotPresentAlias,
62    /// Build the image before starting the service.
63    Build,
64    /// Check for an updated image once per day.
65    Daily,
66    /// Check for an updated image once per week.
67    Weekly,
68    /// Check after the retained custom Compose duration.
69    Every {
70        /// Duration spelling after the `every_` prefix.
71        duration: String,
72    },
73    /// The schema-recognized `refresh` spelling, which lacks matching service-field documentation.
74    RefreshSchemaOnly,
75    /// A policy that still contains a Compose interpolation expression.
76    Expression,
77    /// An invalid or provider-specific value retained for diagnostics.
78    Other,
79}
80
81pub(crate) fn valid_pull_policy_duration(value: &str) -> bool {
82    parse_duration(value)
83}
84
85fn parse_every_duration(value: &str) -> Option<&str> {
86    let duration = value.strip_prefix("every_")?;
87    valid_pull_policy_duration(duration).then_some(duration)
88}
89
90fn parse_duration(mut value: &str) -> bool {
91    let mut found = false;
92    while !value.is_empty() {
93        let number_end = value.bytes().take_while(u8::is_ascii_digit).count();
94        if number_end == 0 {
95            return false;
96        }
97        value = &value[number_end..];
98        let Some(unit) = value.bytes().next() else {
99            return false;
100        };
101        if !matches!(unit, b'w' | b'd' | b'h' | b'm' | b's') {
102            return false;
103        }
104        value = &value[1..];
105        found = true;
106    }
107    found
108}
109
110#[cfg(test)]
111mod tests {
112    use super::{PullPolicy, PullPolicyKind, valid_pull_policy_duration};
113    use crate::model::Located;
114    use crate::source::{SourceId, SourceSpan};
115
116    #[test]
117    fn classifies_documented_schema_only_deferred_and_other_values() -> Result<(), &'static str> {
118        for (value, kind) in [
119            ("always", PullPolicyKind::Always),
120            ("missing", PullPolicyKind::Missing),
121            ("if_not_present", PullPolicyKind::IfNotPresentAlias),
122            ("refresh", PullPolicyKind::RefreshSchemaOnly),
123            ("${PULL_POLICY:-missing}", PullPolicyKind::Expression),
124            ("provider-newest", PullPolicyKind::Other),
125        ] {
126            let span = SourceSpan::new(SourceId::new(1), 0, value.len()).ok_or("valid test span expected")?;
127            assert_eq!(PullPolicy::parse(Located::new(value.to_owned(), span)).kind(), &kind);
128        }
129        Ok(())
130    }
131
132    #[test]
133    fn accepts_documented_compose_duration_units_without_normalizing_spelling() {
134        for value in ["1w", "2d", "3h", "4m", "5s", "1w2d3h4m5s", "0s", "01h30m"] {
135            assert!(valid_pull_policy_duration(value), "expected valid duration {value}");
136        }
137        for value in ["", "0", "1", "1us", "1ms", "1.5h", ".5h", "1h30", "1x", "h", "1hh"] {
138            assert!(!valid_pull_policy_duration(value), "expected invalid duration {value}");
139        }
140    }
141}