1use crate::profile::HarnessKind;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum Os {
22 #[default]
25 Debian,
26 Arch,
28 Ubuntu,
30 Alpine,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Refusal {
37 TerminalLibrary,
44 GlibcProgram,
49 WindowOnDebianOnly,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum Gap {
58 Docx2txt,
60 Sox,
63 SoxOpus,
65}
66
67impl Gap {
68 #[must_use]
71 pub fn programs(self) -> &'static [&'static str] {
72 match self {
73 Self::Docx2txt => &["docx2txt"],
74 Self::Sox => &["play", "soxi"],
75 Self::SoxOpus => &[],
76 }
77 }
78}
79
80impl Os {
81 pub const ALL: [Self; 4] = [Self::Debian, Self::Arch, Self::Ubuntu, Self::Alpine];
84
85 #[must_use]
87 pub fn id(self) -> &'static str {
88 match self {
89 Self::Debian => "debian",
90 Self::Arch => "arch",
91 Self::Ubuntu => "ubuntu",
92 Self::Alpine => "alpine",
93 }
94 }
95
96 #[must_use]
98 pub fn parse(id: &str) -> Option<Self> {
99 Self::ALL.into_iter().find(|os| os.id() == id)
100 }
101
102 #[must_use]
104 pub fn display_name(self) -> &'static str {
105 match self {
106 Self::Debian => "Debian 13",
107 Self::Arch => "Arch Linux",
108 Self::Ubuntu => "Ubuntu 24.04 LTS",
109 Self::Alpine => "Alpine 3.24",
110 }
111 }
112
113 #[must_use]
116 pub fn recommended(self) -> bool {
117 self != Self::Alpine
118 }
119
120 #[must_use]
123 pub fn image(self) -> &'static str {
124 match self {
125 Self::Debian => crate::engine::names::BASE_IMAGE,
126 Self::Arch => "qcode/base-arch",
127 Self::Ubuntu => "qcode/base-ubuntu",
128 Self::Alpine => "qcode/base-alpine",
129 }
130 }
131
132 #[must_use]
134 pub fn containerfile(self) -> &'static str {
135 match self {
136 Self::Debian => super::CONTAINERFILE,
137 Self::Arch => include_str!("../../assets/containerfiles/base-arch.Containerfile"),
138 Self::Ubuntu => include_str!("../../assets/containerfiles/base-ubuntu.Containerfile"),
139 Self::Alpine => include_str!("../../assets/containerfiles/base-alpine.Containerfile"),
140 }
141 }
142
143 #[must_use]
150 pub fn image_mb(self) -> u64 {
151 match self {
152 Self::Debian => 517,
153 Self::Arch => 809,
154 Self::Ubuntu => 508,
155 Self::Alpine => 312,
156 }
157 }
158
159 #[must_use]
161 pub fn gaps(self) -> &'static [Gap] {
162 match self {
163 Self::Debian => &[],
164 Self::Arch => &[Gap::Sox],
165 Self::Ubuntu => &[Gap::SoxOpus],
166 Self::Alpine => &[Gap::Docx2txt],
167 }
168 }
169
170 #[must_use]
180 pub fn refuses(self, harness: HarnessKind) -> Option<Refusal> {
181 match (self, harness) {
182 (Self::Alpine, HarnessKind::GeminiCli | HarnessKind::QwenCode) => Some(Refusal::TerminalLibrary),
183 (Self::Alpine, HarnessKind::AntigravityIde) => Some(Refusal::GlibcProgram),
184 (Self::Arch | Self::Ubuntu, HarnessKind::AntigravityIde) => Some(Refusal::WindowOnDebianOnly),
185 _ => None,
186 }
187 }
188
189 #[must_use]
195 pub fn install(self, packages: &[&str]) -> String {
196 let packages = packages.join(" ");
197 match self {
198 Self::Debian | Self::Ubuntu => format!(
199 "apt-get update \\\n && apt-get install --yes --no-install-recommends {packages} \\\n \
200 && rm -rf /var/lib/apt/lists/*"
201 ),
202 Self::Arch => format!(
203 "pacman -Syu --noconfirm --needed {packages} \\\n \
204 && rm -rf /var/cache/pacman/pkg/* /var/lib/pacman/sync/*"
205 ),
206 Self::Alpine => format!("apk add --no-cache {packages}"),
207 }
208 }
209
210 #[must_use]
213 pub fn python(self) -> &'static [&'static str] {
214 match self {
215 Self::Debian | Self::Ubuntu | Self::Alpine => &["python3", "pipx"],
216 Self::Arch => &["python", "python-pipx"],
217 }
218 }
219
220 #[must_use]
227 pub fn python_venv(self) -> &'static [&'static str] {
228 match self {
229 Self::Debian | Self::Ubuntu => &["python3", "python3-venv"],
230 Self::Arch => &["python"],
231 Self::Alpine => &["python3"],
232 }
233 }
234
235 #[must_use]
240 pub fn build_tools(self) -> &'static [&'static str] {
241 match self {
242 Self::Debian | Self::Ubuntu => &[
243 "build-essential",
244 "pkg-config",
245 "libssl-dev",
246 "git",
247 "openssh-client",
248 "curl",
249 "ca-certificates",
250 "jq",
251 "procps",
252 "bash",
253 ],
254 Self::Arch => &["base-devel", "openssl", "git", "openssh", "curl", "jq", "procps-ng", "bash"],
255 Self::Alpine => {
256 &["build-base", "pkgconf", "openssl-dev", "git", "openssh-client", "curl", "jq", "procps-ng", "bash"]
257 }
258 }
259 }
260
261 #[must_use]
265 pub fn chromium(self) -> Option<&'static [&'static str]> {
266 match self {
267 Self::Debian | Self::Arch | Self::Alpine => Some(&["chromium"]),
268 Self::Ubuntu => None,
269 }
270 }
271
272 #[must_use]
279 pub fn pipx(self, home: &str, package: &str) -> String {
280 match self {
281 Self::Ubuntu => format!("PIPX_HOME={home} PIPX_BIN_DIR=/usr/local/bin pipx install {package}"),
282 Self::Debian | Self::Arch | Self::Alpine => {
283 format!("PIPX_GLOBAL_HOME={home} PIPX_GLOBAL_BIN_DIR=/usr/local/bin pipx install --global {package}")
284 }
285 }
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::{Gap, Os, Refusal};
292 use crate::base::apps::PROGRAMS;
293 use crate::engine::names::BASE_IMAGE;
294 use crate::profile::HarnessKind;
295
296 #[test]
297 fn every_system_names_the_build_tools_under_its_own_names_and_ubuntu_has_no_chromium() {
298 assert_eq!(
300 Os::Debian.build_tools(),
301 [
302 "build-essential",
303 "pkg-config",
304 "libssl-dev",
305 "git",
306 "openssh-client",
307 "curl",
308 "ca-certificates",
309 "jq",
310 "procps",
311 "bash"
312 ]
313 );
314 assert_eq!(Os::Ubuntu.build_tools(), Os::Debian.build_tools());
315 assert_eq!(
316 Os::Arch.build_tools(),
317 ["base-devel", "openssl", "git", "openssh", "curl", "jq", "procps-ng", "bash"]
318 );
319 assert_eq!(
320 Os::Alpine.build_tools(),
321 ["build-base", "pkgconf", "openssl-dev", "git", "openssh-client", "curl", "jq", "procps-ng", "bash"]
322 );
323 for os in [Os::Debian, Os::Arch, Os::Alpine] {
324 assert_eq!(os.chromium(), Some(["chromium"].as_slice()), "{os:?}");
325 }
326 assert_eq!(Os::Ubuntu.chromium(), None, "a snap does not run in a container");
327 }
328
329 #[test]
330 fn every_system_reads_back_as_itself_and_debian_is_the_default() {
331 for os in Os::ALL {
332 assert_eq!(Os::parse(os.id()), Some(os));
333 }
334 assert_eq!(Os::default(), Os::Debian);
335 assert_eq!(Os::ALL[0], Os::Debian, "the default is offered first");
336 assert_eq!(Os::parse("gentoo"), None);
337 }
338
339 #[test]
340 fn debian_keeps_the_image_name_every_image_had_before_the_choice() {
341 assert_eq!(Os::Debian.image(), BASE_IMAGE);
344 assert_eq!(Os::Debian.image(), "qcode/base");
345 assert_eq!(Os::Debian.containerfile(), crate::base::CONTAINERFILE);
346 let names: Vec<&str> = Os::ALL.map(Os::image).to_vec();
347 for (index, name) in names.iter().enumerate() {
348 assert!(name.starts_with("qcode/base") && name.chars().all(|c| c.is_ascii_lowercase() || "/-".contains(c)));
349 assert!(!names[..index].contains(name), "{name} is the image of two systems");
350 }
351 }
352
353 #[test]
354 fn every_system_starts_from_its_own_distribution_and_brings_node() {
355 let from = |os: Os| {
356 os.containerfile().lines().filter(|line| line.starts_with("FROM ")).map(str::to_owned).collect::<Vec<_>>()
357 };
358 assert_eq!(from(Os::Debian), ["FROM docker.io/library/node:24-trixie-slim"]);
359 assert_eq!(from(Os::Arch), ["FROM docker.io/library/archlinux:latest"]);
360 assert_eq!(from(Os::Ubuntu), ["FROM docker.io/library/ubuntu:24.04"]);
361 assert_eq!(from(Os::Alpine), ["FROM docker.io/library/node:24-alpine"]);
362 assert!(
365 Os::Ubuntu
366 .containerfile()
367 .contains("COPY --from=docker.io/library/node:24-trixie-slim /usr/local/bin/node")
368 );
369 assert!(!Os::Ubuntu.containerfile().contains(" nodejs"), "Ubuntu's own Node is never installed");
370 assert!(Os::Arch.containerfile().contains("--needed nodejs-lts-krypton ca-certificates"));
373 assert!(
374 Os::Arch
375 .containerfile()
376 .contains("COPY --from=docker.io/library/node:24-trixie-slim /usr/local/lib/node_modules/npm ")
377 );
378 }
379
380 #[test]
381 fn every_system_ends_with_the_same_user_variables_and_directories() {
382 let tail = |text: &'static str| -> Vec<&'static str> {
385 text.lines()
386 .filter(|line| !line.starts_with('#'))
387 .filter(|line| {
388 line.starts_with("ENV ")
389 || line.starts_with("WORKDIR ")
390 || line.starts_with("USER ")
391 || line.contains("useradd ")
392 || line.contains("qcode-open-home")
393 || line.contains("mkdir -p /usr/local/npm")
394 || line.contains("chmod 0777")
395 })
396 .map(str::trim)
397 .collect()
398 };
399 let debian = tail(Os::Debian.containerfile());
400 assert!(debian.len() >= 12, "{debian:?}");
401 for os in Os::ALL {
402 assert_eq!(tail(os.containerfile()), debian, "{os:?}");
403 }
404 }
405
406 #[test]
407 fn a_system_installs_with_its_own_package_manager() {
408 assert!(Os::Debian.install(&["python3"]).starts_with("apt-get update"));
409 assert!(Os::Ubuntu.install(&["python3"]).starts_with("apt-get update"));
410 assert!(Os::Arch.install(&["python"]).starts_with("pacman -Syu --noconfirm --needed python"));
411 assert_eq!(Os::Alpine.install(&["python3", "pipx"]), "apk add --no-cache python3 pipx");
412 for os in Os::ALL {
413 assert!(os.containerfile().contains(os.install(&[]).split(' ').next().unwrap_or_default()), "{os:?}");
414 }
415 }
416
417 #[test]
418 fn every_system_carries_an_environment_of_its_own_where_the_interpreter_alone_is_not_enough() {
419 assert_eq!(Os::Debian.python_venv(), ["python3", "python3-venv"]);
422 assert_eq!(Os::Ubuntu.python_venv(), Os::Debian.python_venv());
423 assert_eq!(Os::Arch.python_venv(), ["python"]);
424 assert_eq!(Os::Alpine.python_venv(), ["python3"]);
425 for os in Os::ALL {
426 assert!(os.python_venv().contains(&os.python()[0]), "{os:?}: it is the interpreter's own name");
427 assert!(!os.python_venv().contains(&"pipx"), "{os:?}: nothing here installs with pipx");
428 assert!(os.install(os.python_venv()).contains(&os.python_venv().join(" ")), "{os:?}");
429 }
430 }
431
432 #[test]
433 fn ubuntus_pipx_is_told_where_to_install_without_the_option_it_does_not_have() {
434 assert!(!Os::Ubuntu.pipx("/opt/pipx", "graphifyy").contains("--global"));
435 assert!(Os::Ubuntu.pipx("/opt/pipx", "graphifyy").contains("PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin"));
436 for os in [Os::Debian, Os::Arch, Os::Alpine] {
437 assert!(os.pipx("/opt/pipx", "graphifyy").contains("pipx install --global graphifyy"), "{os:?}");
438 }
439 }
440
441 #[test]
442 fn what_a_system_lacks_is_named_and_leaves_its_program_out_of_the_image() {
443 for os in Os::ALL {
444 for gap in os.gaps() {
445 for program in gap.programs() {
446 assert!(
447 PROGRAMS.iter().any(|command| command.iter().any(|word| word.contains(program))),
448 "{program} is a program the built-in apps know"
449 );
450 }
451 }
452 }
453 assert!(!Os::Alpine.containerfile().contains("docx2txt odt2txt"), "Alpine packages no docx2txt");
454 assert!(!Os::Arch.containerfile().lines().any(|line| line.starts_with(" ") && line.contains(" sox")));
455 assert_eq!(Os::Debian.gaps(), []);
456 assert_eq!(Os::Alpine.gaps(), [Gap::Docx2txt]);
457 }
458
459 #[test]
460 fn alpine_refuses_the_three_harnesses_that_do_not_run_on_musl_and_nothing_else() {
461 let refused: Vec<HarnessKind> =
462 HarnessKind::ALL.into_iter().filter(|harness| Os::Alpine.refuses(*harness).is_some()).collect();
463 assert_eq!(refused, [HarnessKind::GeminiCli, HarnessKind::QwenCode, HarnessKind::AntigravityIde]);
464 assert_eq!(Os::Alpine.refuses(HarnessKind::GeminiCli), Some(Refusal::TerminalLibrary));
465 assert_eq!(Os::Alpine.refuses(HarnessKind::QwenCode), Some(Refusal::TerminalLibrary));
466 assert_eq!(Os::Alpine.refuses(HarnessKind::KimiCode), None);
467 assert!(!Os::Alpine.recommended());
468 for os in [Os::Debian, Os::Arch, Os::Ubuntu] {
469 assert!(os.recommended(), "{os:?}");
470 for harness in HarnessKind::TERMINAL {
471 assert_eq!(os.refuses(harness), None, "{os:?} {harness:?}");
472 }
473 }
474 for harness in HarnessKind::ALL {
475 assert_eq!(Os::Debian.refuses(harness), None, "Debian runs everything: {harness:?}");
476 }
477 }
478}