1use crate::StackLabel;
7
8pub const EXACT_IMAGE_RULES: &[(&str, StackLabel)] = &[
9 ("mongo", StackLabel::database("MongoDB")),
10 ("httpd", StackLabel::service("Apache")),
11 ("node", StackLabel::runtime("Node.js")),
12 ("python", StackLabel::runtime("Python")),
13 ("python3", StackLabel::runtime("Python")),
14 ("ruby", StackLabel::runtime("Ruby")),
15 ("golang", StackLabel::runtime("Go")),
16 ("go", StackLabel::runtime("Go")),
17 ("rust", StackLabel::runtime("Rust")),
18 ("bun", StackLabel::runtime("Bun")),
19 ("deno", StackLabel::runtime("Deno")),
20 ("php", StackLabel::runtime("PHP")),
21];
22
23pub const PREFIX_IMAGE_RULES: &[(&str, StackLabel)] = &[
27 ("postgres", StackLabel::database("PostgreSQL")),
28 ("postgresql", StackLabel::database("PostgreSQL")),
29 ("postgis", StackLabel::database("PostgreSQL")),
30 ("timescaledb", StackLabel::database("PostgreSQL")),
31 ("mysql", StackLabel::database("MySQL")),
32 ("mariadb", StackLabel::database("MariaDB")),
33 ("mongodb", StackLabel::database("MongoDB")),
34 ("redis", StackLabel::database("Redis")),
35 ("valkey", StackLabel::database("Valkey")),
36 ("memcached", StackLabel::database("Memcached")),
37 ("nginx", StackLabel::service("Nginx")),
38 ("apache", StackLabel::service("Apache")),
39 ("rabbitmq", StackLabel::service("RabbitMQ")),
40 ("kafka", StackLabel::service("Kafka")),
41 ("localstack", StackLabel::service("LocalStack")),
42 ("elasticsearch", StackLabel::database("Elasticsearch")),
43 ("opensearch", StackLabel::database("OpenSearch")),
44 ("clickhouse", StackLabel::database("ClickHouse")),
45 ("caddy", StackLabel::service("Caddy")),
46 ("traefik", StackLabel::service("Traefik")),
47 ("openjdk", StackLabel::runtime("Java")),
48 ("eclipse-temurin", StackLabel::runtime("Java")),
49 ("dotnet", StackLabel::runtime(".NET")),
50];
51
52const COMPANION_SEGMENTS: &[&str] = &[
60 "exporter",
61 "dashboard",
62 "dashboards",
63 "operator",
64 "admin",
65 "ui",
66 "gui",
67 "backup",
68 "client",
69 "cli",
70 "commander",
71 "workbench",
72 "shell",
73 "curator",
74 "auth",
75];
76
77pub const DOTNET_NAMESPACE_LABEL: StackLabel = StackLabel::runtime(".NET");
80
81#[must_use]
117pub fn detect_from_image(image: &str) -> Option<StackLabel> {
118 let last_segment = image.rsplit('/').next().unwrap_or(image);
119 let base = last_segment
120 .split([':', '@'])
121 .next()
122 .unwrap_or(last_segment);
123
124 detect_exact_base(base)
125 .or_else(|| detect_prefixed_base(base))
126 .or_else(|| image_has_dotnet_namespace(image).then_some(DOTNET_NAMESPACE_LABEL))
127}
128
129fn detect_exact_base(base: &str) -> Option<StackLabel> {
130 EXACT_IMAGE_RULES
131 .iter()
132 .find(|(name, _)| base.eq_ignore_ascii_case(name))
133 .map(|(_, label)| label.clone())
134}
135
136fn detect_prefixed_base(base: &str) -> Option<StackLabel> {
137 PREFIX_IMAGE_RULES
138 .iter()
139 .find(|(prefix, _)| matches_service_prefix(base, prefix))
140 .map(|(_, label)| label.clone())
141}
142
143fn image_has_dotnet_namespace(image: &str) -> bool {
144 image
145 .split('/')
146 .any(|segment| segment.eq_ignore_ascii_case("dotnet"))
147}
148
149const NAME_SEPARATORS: [char; 3] = ['-', '_', '.'];
150
151fn matches_service_prefix(base: &str, prefix: &str) -> bool {
152 let Some(rest) = strip_prefix_ascii_case(base, prefix) else {
153 return false;
154 };
155
156 if rest.is_empty() {
157 return true;
158 }
159
160 rest.strip_prefix(NAME_SEPARATORS).is_some_and(|rest| {
161 !rest.split(NAME_SEPARATORS).any(|segment| {
162 COMPANION_SEGMENTS
163 .iter()
164 .any(|companion| segment.eq_ignore_ascii_case(companion))
165 })
166 })
167}
168
169fn strip_prefix_ascii_case<'a>(value: &'a str, prefix: &str) -> Option<&'a str> {
170 value
171 .get(..prefix.len())
172 .filter(|head| head.eq_ignore_ascii_case(prefix))
173 .and_then(|_| value.get(prefix.len()..))
174}