Skip to main content

lightshuttle_export/
resolve.rs

1//! Pure resolution of per-target defaults and overrides.
2//!
3//! These helpers are the single place where the `export:` section is
4//! turned into concrete values, so the defaults (`namespace` from the
5//! project, `replicas` of one, `enabled` by default) are defined and
6//! tested once and shared by every emitter.
7
8use lightshuttle_manifest::{ExportConfig, ImagePullPolicy};
9
10use crate::model::Target;
11
12/// Default replica count when neither a per-resource nor a per-target
13/// override is set.
14const DEFAULT_REPLICAS: u32 = 1;
15
16/// Default Helm chart version when neither the chart override nor the
17/// project version is set.
18const DEFAULT_CHART_VERSION: &str = "0.1.0";
19
20/// Whether `resource` is emitted for `target`. A resource is included
21/// unless its per-target override sets `enabled: false`.
22#[must_use]
23pub fn enabled_for(target: Target, resource: &str, export: Option<&ExportConfig>) -> bool {
24    let Some(export) = export else { return true };
25    let enabled = match target {
26        Target::Compose => export
27            .compose
28            .as_ref()
29            .and_then(|t| t.resources.get(resource))
30            .and_then(|r| r.enabled),
31        Target::Kubernetes => export
32            .kubernetes
33            .as_ref()
34            .and_then(|t| t.resources.get(resource))
35            .and_then(|r| r.enabled),
36        Target::Helm => export
37            .helm
38            .as_ref()
39            .and_then(|t| t.resources.get(resource))
40            .and_then(|r| r.enabled),
41    };
42    enabled.unwrap_or(true)
43}
44
45/// Replica count for `resource` on `target`: a per-resource override
46/// wins over the per-target default, which falls back to one. Compose
47/// has no replica concept and always resolves to one.
48#[must_use]
49pub fn replicas_for(target: Target, resource: &str, export: Option<&ExportConfig>) -> u32 {
50    let Some(export) = export else {
51        return DEFAULT_REPLICAS;
52    };
53    match target {
54        Target::Compose => DEFAULT_REPLICAS,
55        Target::Kubernetes => export.kubernetes.as_ref().map_or(DEFAULT_REPLICAS, |t| {
56            t.resources
57                .get(resource)
58                .and_then(|r| r.replicas)
59                .or(t.replicas)
60                .unwrap_or(DEFAULT_REPLICAS)
61        }),
62        Target::Helm => export.helm.as_ref().map_or(DEFAULT_REPLICAS, |t| {
63            t.resources
64                .get(resource)
65                .and_then(|r| r.replicas)
66                .or(t.replicas)
67                .unwrap_or(DEFAULT_REPLICAS)
68        }),
69    }
70}
71
72/// Kubernetes namespace: the override if set, otherwise the project
73/// name.
74#[must_use]
75pub fn namespace_for(project: &str, export: Option<&ExportConfig>) -> String {
76    export
77        .and_then(|e| e.kubernetes.as_ref())
78        .and_then(|k| k.namespace.clone())
79        .unwrap_or_else(|| project.to_owned())
80}
81
82/// Image pull policy for `resource`: a per-resource override wins over
83/// the per-target default, which falls back to `IfNotPresent`.
84#[must_use]
85pub fn image_pull_policy_for(resource: &str, export: Option<&ExportConfig>) -> ImagePullPolicy {
86    export
87        .and_then(|e| e.kubernetes.as_ref())
88        .map(|k| {
89            k.resources
90                .get(resource)
91                .and_then(|r| r.image_pull_policy)
92                .or(k.image_pull_policy)
93                .unwrap_or_default()
94        })
95        .unwrap_or_default()
96}
97
98/// Helm chart name: the override if set, otherwise the project name.
99#[must_use]
100pub fn chart_name_for(project: &str, export: Option<&ExportConfig>) -> String {
101    export
102        .and_then(|e| e.helm.as_ref())
103        .and_then(|h| h.chart_name.clone())
104        .unwrap_or_else(|| project.to_owned())
105}
106
107/// Helm chart version: the override if set, otherwise the project
108/// version, otherwise `0.1.0`.
109#[must_use]
110pub fn chart_version_for(project_version: Option<&str>, export: Option<&ExportConfig>) -> String {
111    export
112        .and_then(|e| e.helm.as_ref())
113        .and_then(|h| h.chart_version.clone())
114        .or_else(|| project_version.map(ToOwned::to_owned))
115        .unwrap_or_else(|| DEFAULT_CHART_VERSION.to_owned())
116}
117
118/// Sanitise a manifest name into a DNS-1123 compliant label.
119///
120/// Lowercases the input, replaces every character outside `[a-z0-9-]`
121/// with a hyphen, prepends `x` when the result would start with a digit
122/// or a hyphen, and truncates to 63 characters (stripping any trailing
123/// hyphens produced by the truncation).
124#[must_use]
125pub(crate) fn dns_name(name: &str) -> String {
126    let normalized: String = name
127        .to_lowercase()
128        .chars()
129        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
130        .collect();
131    let prefixed = if normalized
132        .chars()
133        .next()
134        .is_none_or(|c| c == '-' || c.is_ascii_digit())
135    {
136        format!("x{normalized}")
137    } else {
138        normalized
139    };
140    let truncated: String = prefixed.chars().take(63).collect();
141    truncated.trim_end_matches('-').to_owned()
142}
143
144#[cfg(test)]
145mod tests {
146    use super::dns_name;
147
148    #[test]
149    fn dns_name_already_valid() {
150        assert_eq!(dns_name("my-service"), "my-service");
151    }
152
153    #[test]
154    fn dns_name_lowercase() {
155        assert_eq!(dns_name("MyService"), "myservice");
156    }
157
158    #[test]
159    fn dns_name_underscores_become_hyphens() {
160        assert_eq!(dns_name("my_service"), "my-service");
161    }
162
163    #[test]
164    fn dns_name_leading_digit_gets_prefix() {
165        assert_eq!(dns_name("1redis"), "x1redis");
166    }
167
168    #[test]
169    fn dns_name_leading_hyphen_gets_prefix() {
170        assert_eq!(dns_name("-leading"), "x-leading");
171    }
172
173    #[test]
174    fn dns_name_trailing_hyphen_stripped() {
175        assert_eq!(dns_name("trailing-"), "trailing");
176    }
177
178    #[test]
179    fn dns_name_truncated_to_63() {
180        let long = "a".repeat(70);
181        assert_eq!(dns_name(&long).len(), 63);
182    }
183
184    #[test]
185    fn dns_name_truncation_strips_trailing_hyphen() {
186        let name = format!("{}-b", "a".repeat(62));
187        let result = dns_name(&name);
188        assert!(
189            !result.ends_with('-'),
190            "must not end with hyphen after truncation"
191        );
192        assert!(result.len() <= 63);
193    }
194}