max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
4 files changed,
+296 insertions,
-47 deletions
| @@ -36,6 +36,8 @@ | |||
| 36 | 36 | ||
| 37 | 37 | **The two components version independently, ruled 2026-08-25.** The console's number comes from `crates/alloy/Cargo.toml` and shop's from its own `--version`, which is what `build/rpm/build.sh` already reads and why it reads them from different places. A hotfix to one republishes only that one. The cost is that the console and the terminal on a machine can sit at unrelated numbers, so no component answers "what version of Alloy is this"; the image does. The alternative was one shared number, which buys that single answer and pays for it by republishing an unchanged component every time the other takes a fix, and that is the normal life of a hotfix rather than an edge case. | |
| 38 | 38 | ||
| 39 | + | **`alloy --version` reports all three clocks, so a bug report does not have to know to ask.** The console's own version, then a line per component the image names in `/usr/share/alloy/components` other than the console itself, then the image. Each component is asked for its own `--version` rather than looked up in the image or in rpm, because a layered component is exactly the case where the image no longer knows and `build/rpm/build.sh` refuses to package a binary whose `--version` disagrees with what its RPM will claim. A component that is named but not installed is left out rather than reported as unknown, which is what a server profile (no shop) and a machine whose first boot has not laid the layers down both look like. The probes cost a process each, so they run only when the invocation asked for a version. | |
| 40 | + | ||
| 39 | 41 | **What a hotfix is allowed to be.** A hotfix exists because the shipped software does not work as that release intended. Anything that changes how the software is intended to work is a release, however small the diff, and rides the ordinary path rather than this one. The second half is the channel test: a hotfix is shipped out of band, so a fix that can wait is an ordinary patch and never touches the repo. Security fixes are fixes, on the standing intent that no release be exploitable. Every hotfix is a patch bump; every intent change is at least a minor. | |
| 40 | 42 | ||
| 41 | 43 | That definition is narrow on purpose, and here it is load-bearing rather than tidy. Every machine holding a layer is a machine carrying state outside its image, and the fewer of those there are at any moment the better. |
| @@ -10,6 +10,7 @@ | |||
| 10 | 10 | mod bind; | |
| 11 | 11 | mod bluetooth; | |
| 12 | 12 | mod cli; | |
| 13 | + | mod component; | |
| 13 | 14 | mod credits; | |
| 14 | 15 | mod disk; | |
| 15 | 16 | mod display; | |
| @@ -72,18 +73,67 @@ | |||
| 72 | 73 | /// Absent off an Alloy machine, where there is no image to have a version. See | |
| 73 | 74 | /// [`image::version`]. | |
| 74 | 75 | /// | |
| 76 | + | /// # The component lines | |
| 77 | + | /// | |
| 78 | + | /// A third clock, and the reason this is not simply two versions. Since | |
| 79 | + | /// `cad26d5` the console and shop are laid down as layers rather than carried | |
| 80 | + | /// in the base, and the hotfix channel replaces one of them on a machine that | |
| 81 | + | /// is already installed, so neither the image's version nor the console's | |
| 82 | + | /// answers what terminal is on the machine. Each component is asked for its | |
| 83 | + | /// own, because the components version independently (Max, 2026-08-25, GO alloy | |
| 84 | + | /// `142393e5`) and there is no number that stands for both. | |
| 85 | + | /// | |
| 86 | + | /// See [`component`] for where the list comes from and what it costs to ask. | |
| 87 | + | /// | |
| 75 | 88 | /// Built at runtime rather than `concat!`ed, which is why this is a function: | |
| 76 | - | /// the image version is read from a file. Interned so the `&'static str` clap | |
| 77 | - | /// wants outlives the call. | |
| 89 | + | /// the image version is read from a file and the component versions are read | |
| 90 | + | /// from the components themselves. Interned so the `&'static str` clap wants | |
| 91 | + | /// outlives the call. | |
| 78 | 92 | fn notice() -> &'static str { | |
| 79 | 93 | static NOTICE: std::sync::OnceLock<String> = std::sync::OnceLock::new(); | |
| 80 | - | NOTICE.get_or_init(|| notice_text(env!("CARGO_PKG_VERSION"), image::version().as_deref())) | |
| 94 | + | NOTICE.get_or_init(|| { | |
| 95 | + | notice_text( | |
| 96 | + | env!("CARGO_PKG_VERSION"), | |
| 97 | + | &component::versions(), | |
| 98 | + | image::version().as_deref(), | |
| 99 | + | ) | |
| 100 | + | }) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | /// Whether this invocation is asking for the version. | |
| 104 | + | /// | |
| 105 | + | /// [`notice`] runs a process per component, and clap wants `long_version` before | |
| 106 | + | /// it parses anything, so without this every `alloy net` would pay for a string | |
| 107 | + | /// only `alloy --version` prints. Reading argv directly is the only place the | |
| 108 | + | /// answer exists that early. | |
| 109 | + | /// | |
| 110 | + | /// Both spellings, because clap answers both. `-V` prints the short version and | |
| 111 | + | /// never reaches the notice, and it is included anyway: a person typing it is | |
| 112 | + | /// asking the same question, and a flag that quietly reports less than its | |
| 113 | + | /// sibling is the kind of difference nobody discovers until a bug report is | |
| 114 | + | /// missing the line that mattered. | |
| 115 | + | /// | |
| 116 | + | /// Only before the first `--`, and only as a whole argument, so a path or a | |
| 117 | + | /// theme id that happens to read like the flag does not spawn probes. | |
| 118 | + | fn wants_version<I: IntoIterator<Item = std::ffi::OsString>>(args: I) -> bool { | |
| 119 | + | args.into_iter() | |
| 120 | + | .take_while(|arg| arg != "--") | |
| 121 | + | .any(|arg| arg == "--version" || arg == "-V") | |
| 81 | 122 | } | |
| 82 | 123 | ||
| 83 | 124 | /// The assembly, split from the read so both shapes can be tested: the machine | |
| 84 | 125 | /// that has an image and the dev host that does not. | |
| 85 | - | fn notice_text(console: &str, image: Option<&str>) -> String { | |
| 126 | + | fn notice_text(console: &str, components: &[(String, String)], image: Option<&str>) -> String { | |
| 86 | 127 | let mut notice = String::from(console); | |
| 128 | + | // Components before the image, and after the console: the three are | |
| 129 | + | // ordered by what a support conversation asks about first, which is our own | |
| 130 | + | // software, and the image is the ground the other lines sit on. | |
| 131 | + | for (name, version) in components { | |
| 132 | + | notice.push('\n'); | |
| 133 | + | notice.push_str(name); | |
| 134 | + | notice.push(' '); | |
| 135 | + | notice.push_str(version); | |
| 136 | + | } | |
| 87 | 137 | if let Some(image) = image { | |
| 88 | 138 | notice.push_str("\nimage "); | |
| 89 | 139 | notice.push_str(image); | |
| @@ -318,8 +368,10 @@ | |||
| 318 | 368 | // Hidden from `--help`, still resolved. clap would accept the verb either | |
| 319 | 369 | // way, and a command that silently works while being undocumented is how | |
| 320 | 370 | // documentation becomes wrong; the match arms below say why instead. | |
| 321 | - | let command = profile::hide_unavailable(<Cli as clap::CommandFactory>::command(), profile) | |
| 322 | - | .long_version(notice()); | |
| 371 | + | let mut command = profile::hide_unavailable(<Cli as clap::CommandFactory>::command(), profile); | |
| 372 | + | if wants_version(std::env::args_os()) { | |
| 373 | + | command = command.long_version(notice()); | |
| 374 | + | } | |
| 323 | 375 | let cli = Cli::from_arg_matches(&command.get_matches())?; | |
| 324 | 376 | ||
| 325 | 377 | // Refused as well as hidden, and through the same table, so the two | |
| @@ -535,6 +587,14 @@ | |||
| 535 | 587 | /// `image`'s own tests cover. | |
| 536 | 588 | const IMAGE: &str = "0.1 (build 20260816.143012, Fedora 43)"; | |
| 537 | 589 | ||
| 590 | + | /// What a client machine's components read as: shop, at a version of its | |
| 591 | + | /// own. A literal for the same reason `IMAGE` is one — these tests are | |
| 592 | + | /// about the shape of the notice, and `component`'s own tests cover the | |
| 593 | + | /// reading. | |
| 594 | + | fn components() -> Vec<(String, String)> { | |
| 595 | + | vec![("shop".to_string(), "0.4.2".to_string())] | |
| 596 | + | } | |
| 597 | + | ||
| 538 | 598 | /// The invariant two build steps depend on and neither can state here: | |
| 539 | 599 | /// `Containerfile` asserts `alloy --version | grep -q '^alloy '` to prove | |
| 540 | 600 | /// it did not ship the stub main, and `build/rpm/build.sh` reads the first | |
| @@ -542,7 +602,7 @@ | |||
| 542 | 602 | /// break silently if anything is inserted above the version. | |
| 543 | 603 | #[test] | |
| 544 | 604 | fn the_version_stays_alone_on_the_first_line() { | |
| 545 | - | let notice = notice_text("0.1.0", Some(IMAGE)); | |
| 605 | + | let notice = notice_text("0.1.0", &components(), Some(IMAGE)); | |
| 546 | 606 | assert_eq!(notice.lines().next(), Some("0.1.0")); | |
| 547 | 607 | } | |
| 548 | 608 | ||
| @@ -550,7 +610,7 @@ | |||
| 550 | 610 | /// machine from a rebuilt one, which is the whole reason both are printed. | |
| 551 | 611 | #[test] | |
| 552 | 612 | fn a_machine_with_an_image_reports_both_versions() { | |
| 553 | - | let notice = notice_text("0.1.0", Some(IMAGE)); | |
| 613 | + | let notice = notice_text("0.1.0", &[], Some(IMAGE)); | |
| 554 | 614 | let mut lines = notice.lines(); | |
| 555 | 615 | assert_eq!(lines.next(), Some("0.1.0")); | |
| 556 | 616 | assert_eq!( | |
| @@ -559,11 +619,54 @@ | |||
| 559 | 619 | ); | |
| 560 | 620 | } | |
| 561 | 621 | ||
| 622 | + | /// The machine the hotfix channel is for: a console at one version, a | |
| 623 | + | /// terminal at another, and an image older than both. All three lines, or a | |
| 624 | + | /// bug report cannot say which of them the reporter is running. | |
| 625 | + | #[test] | |
| 626 | + | fn a_layered_component_gets_a_line_of_its_own() { | |
| 627 | + | let notice = notice_text("0.1.1", &components(), Some(IMAGE)); | |
| 628 | + | let mut lines = notice.lines(); | |
| 629 | + | assert_eq!(lines.next(), Some("0.1.1")); | |
| 630 | + | assert_eq!(lines.next(), Some("shop 0.4.2")); | |
| 631 | + | assert_eq!( | |
| 632 | + | lines.next(), | |
| 633 | + | Some("image 0.1 (build 20260816.143012, Fedora 43)") | |
| 634 | + | ); | |
| 635 | + | } | |
| 636 | + | ||
| 637 | + | /// The server profile lays down no shop, and a component named but not | |
| 638 | + | /// installed cannot be asked. Both arrive here as an empty list, and the | |
| 639 | + | /// notice says nothing rather than reporting a component it did not read. | |
| 640 | + | #[test] | |
| 641 | + | fn a_machine_with_no_components_to_report_says_nothing_about_them() { | |
| 642 | + | let notice = notice_text("0.1.0", &[], Some(IMAGE)); | |
| 643 | + | assert_eq!(notice.lines().count(), 6, "{notice}"); | |
| 644 | + | } | |
| 645 | + | ||
| 646 | + | /// Which invocations pay for the probes. `-V` is in because it is the same | |
| 647 | + | /// question; a bare verb is out because it would spawn a process per | |
| 648 | + | /// component for a string it never prints. | |
| 649 | + | #[test] | |
| 650 | + | fn only_a_version_request_builds_the_notice() { | |
| 651 | + | let argv = |args: &[&str]| { | |
| 652 | + | wants_version( | |
| 653 | + | args.iter() | |
| 654 | + | .map(std::ffi::OsString::from) | |
| 655 | + | .collect::<Vec<_>>(), | |
| 656 | + | ) | |
| 657 | + | }; | |
| 658 | + | assert!(argv(&["alloy", "--version"])); | |
| 659 | + | assert!(argv(&["alloy", "-V"])); | |
| 660 | + | assert!(!argv(&["alloy", "net"])); | |
| 661 | + | assert!(!argv(&["alloy", "config", "--version-of-something"])); | |
| 662 | + | assert!(!argv(&["alloy", "config", "--", "--version"])); | |
| 663 | + | } | |
| 664 | + | ||
| 562 | 665 | /// A dev host has no image, so the line is omitted rather than filled in | |
| 563 | 666 | /// with a placeholder that would read as a real answer. | |
| 564 | 667 | #[test] | |
| 565 | 668 | fn a_dev_host_reports_the_console_and_says_nothing_about_an_image() { | |
| 566 | - | let notice = notice_text("0.1.0", None); | |
| 669 | + | let notice = notice_text("0.1.0", &[], None); | |
| 567 | 670 | assert_eq!(notice.lines().next(), Some("0.1.0")); | |
| 568 | 671 | assert!(!notice.contains("image"), "{notice}"); | |
| 569 | 672 | } | |
| @@ -572,7 +675,7 @@ | |||
| 572 | 675 | #[test] | |
| 573 | 676 | fn the_copyright_notice_survives_either_shape() { | |
| 574 | 677 | for image in [Some(IMAGE), None] { | |
| 575 | - | let notice = notice_text("0.1.0", image); | |
| 678 | + | let notice = notice_text("0.1.0", &components(), image); | |
| 576 | 679 | assert!(notice.contains("Make Creative, LLC"), "{notice}"); | |
| 577 | 680 | assert!(notice.contains("License MIT"), "{notice}"); | |
| 578 | 681 | } |
| @@ -70,6 +70,7 @@ | |||
| 70 | 70 | use serde::Deserialize; | |
| 71 | 71 | ||
| 72 | 72 | use crate::cli::{CommandLog, Effect, Invocation}; | |
| 73 | + | use crate::component::{self, is_component}; | |
| 73 | 74 | use crate::shell::{Confirm, Flow, View, block_title, truncate}; | |
| 74 | 75 | use crate::stale::{self, Staleness}; | |
| 75 | 76 | ||
| @@ -1207,42 +1208,6 @@ | |||
| 1207 | 1208 | } | |
| 1208 | 1209 | } | |
| 1209 | 1210 | ||
| 1210 | - | /// Where the image names its own components, one per line. | |
| 1211 | - | /// | |
| 1212 | - | /// Written at build time rather than hardcoded here, because the profile | |
| 1213 | - | /// decides the list: `server` has no compositor and never lays shop down | |
| 1214 | - | /// (Containerfile, the components block). Reading it means a profile that ships | |
| 1215 | - | /// a different set does not make this screen lie about whose package is whose. | |
| 1216 | - | const COMPONENTS_PATH: &str = "/usr/share/alloy/components"; | |
| 1217 | - | ||
| 1218 | - | /// Alloy's own components, as this image names them. | |
| 1219 | - | /// | |
| 1220 | - | /// `None` when the file is absent, which is not a failure and is not rare: a | |
| 1221 | - | /// development box, a Fedora ostree machine that is not Alloy, or an image | |
| 1222 | - | /// built before the component flip (GO alloy `d866e125`). The caller says less | |
| 1223 | - | /// in that case rather than falling back to a guess, because the guess here is | |
| 1224 | - | /// labelling somebody else's package as ours. | |
| 1225 | - | fn components() -> Option<Vec<String>> { | |
| 1226 | - | let raw = std::fs::read_to_string(COMPONENTS_PATH).ok()?; | |
| 1227 | - | let names: Vec<String> = raw.split_whitespace().map(str::to_string).collect(); | |
| 1228 | - | (!names.is_empty()).then_some(names) | |
| 1229 | - | } | |
| 1230 | - | ||
| 1231 | - | /// Whether a layered entry is the component `name`. | |
| 1232 | - | /// | |
| 1233 | - | /// Two forms, because a request is recorded under the string that installed it. | |
| 1234 | - | /// `alloy-layer-components.service` installs by bare name, so that is what an | |
| 1235 | - | /// ordinary Alloy machine reports, and NEVRA is what a machine somebody layered | |
| 1236 | - | /// by hand reports. The version has to start with a digit for the second form | |
| 1237 | - | /// to match, so `alloy-utils` is not read as `alloy` wearing a version. | |
| 1238 | - | fn is_component(entry: &str, name: &str) -> bool { | |
| 1239 | - | entry == name | |
| 1240 | - | || entry | |
| 1241 | - | .strip_prefix(name) | |
| 1242 | - | .and_then(|rest| rest.strip_prefix('-')) | |
| 1243 | - | .is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit())) | |
| 1244 | - | } | |
| 1245 | - | ||
| 1246 | 1211 | /// What the booted deployment's layering means, as words. | |
| 1247 | 1212 | /// | |
| 1248 | 1213 | /// Empty when nothing is layered, which is the answer on every non-Alloy ostree | |
| @@ -1482,7 +1447,7 @@ | |||
| 1482 | 1447 | has_rpm: Rpm::present(), | |
| 1483 | 1448 | rpm: None, | |
| 1484 | 1449 | has_dnf: stale::available(), | |
| 1485 | - | components: components(), | |
| 1450 | + | components: component::names(), | |
| 1486 | 1451 | stale: None, | |
| 1487 | 1452 | last_checked: stale::last_checked(), | |
| 1488 | 1453 | }; |
| @@ -1,0 +1,179 @@ | |||
| 1 | + | //! Alloy's own components on this machine, and the version each one reports. | |
| 2 | + | //! | |
| 3 | + | //! A component is a piece of Make Creative's own software the image lays down | |
| 4 | + | //! as a layer rather than carrying in the base: the console and shop today. The | |
| 5 | + | //! hotfix channel exists to replace one of them on a machine that is already | |
| 6 | + | //! installed (docs/STACK.md, Hotfixes), so what a component is running is not | |
| 7 | + | //! answerable from the image it arrived on and has to be asked of the component. | |
| 8 | + | //! | |
| 9 | + | //! The two version rules this module implements, both ruled rather than chosen | |
| 10 | + | //! here: | |
| 11 | + | //! | |
| 12 | + | //! - **The components version independently** (Max, 2026-08-25, GO alloy | |
| 13 | + | //! `142393e5`). The console's number comes from its own crate and shop's from | |
| 14 | + | //! its own `--version`, and a hotfix to one republishes only that one. So this | |
| 15 | + | //! asks each component separately and composes nothing. | |
| 16 | + | //! - **A binary's own `--version` is the statement of record.** | |
| 17 | + | //! `build/rpm/build.sh` refuses to package a binary whose `--version` | |
| 18 | + | //! disagrees with the version the RPM will claim, precisely so that asking the | |
| 19 | + | //! binary and asking rpm cannot give different answers. Asking the binary is | |
| 20 | + | //! the cheaper of the two and needs no rpm database, so that is what this | |
| 21 | + | //! does, and the packaging guard is what makes the choice free. | |
| 22 | + | //! | |
| 23 | + | //! <!-- wiki: alloy-console --> | |
| 24 | + | ||
| 25 | + | use crate::cli::child_command; | |
| 26 | + | ||
| 27 | + | /// The console itself, as the components file names it. | |
| 28 | + | /// | |
| 29 | + | /// Excluded from [`versions`] rather than probed: `alloy --version` is where | |
| 30 | + | /// that list is printed, and the process asking is the answer. | |
| 31 | + | pub(crate) const CONSOLE: &str = "alloy"; | |
| 32 | + | ||
| 33 | + | /// Where the image names its own components, one per line. | |
| 34 | + | /// | |
| 35 | + | /// Written at build time rather than hardcoded here, because the profile | |
| 36 | + | /// decides the list: `server` has no compositor and never lays shop down | |
| 37 | + | /// (Containerfile, the components block). Reading it means a profile that ships | |
| 38 | + | /// a different set does not make a screen lie about whose package is whose. | |
| 39 | + | const COMPONENTS_PATH: &str = "/usr/share/alloy/components"; | |
| 40 | + | ||
| 41 | + | /// Alloy's own components, as this image names them. | |
| 42 | + | /// | |
| 43 | + | /// `None` when the file is absent, which is not a failure and is not rare: a | |
| 44 | + | /// development box, a Fedora ostree machine that is not Alloy, or an image | |
| 45 | + | /// built before the component flip (GO alloy `d866e125`). The caller says less | |
| 46 | + | /// in that case rather than falling back to a guess, because the guess here is | |
| 47 | + | /// labelling somebody else's package as ours. | |
| 48 | + | pub(crate) fn names() -> Option<Vec<String>> { | |
| 49 | + | parse(&std::fs::read_to_string(COMPONENTS_PATH).ok()?) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | /// The parse, split from the read so it can be tested without a components | |
| 53 | + | /// file on the host running the tests. | |
| 54 | + | fn parse(raw: &str) -> Option<Vec<String>> { | |
| 55 | + | let names: Vec<String> = raw.split_whitespace().map(str::to_string).collect(); | |
| 56 | + | (!names.is_empty()).then_some(names) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /// Whether a layered entry is the component `name`. | |
| 60 | + | /// | |
| 61 | + | /// Two forms, because a request is recorded under the string that installed it. | |
| 62 | + | /// `alloy-layer-components.service` installs by bare name, so that is what an | |
| 63 | + | /// ordinary Alloy machine reports, and NEVRA is what a machine somebody layered | |
| 64 | + | /// by hand reports. The version has to start with a digit for the second form | |
| 65 | + | /// to match, so `alloy-utils` is not read as `alloy` wearing a version. | |
| 66 | + | pub(crate) fn is_component(entry: &str, name: &str) -> bool { | |
| 67 | + | entry == name | |
| 68 | + | || entry | |
| 69 | + | .strip_prefix(name) | |
| 70 | + | .and_then(|rest| rest.strip_prefix('-')) | |
| 71 | + | .is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit())) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | /// Every component other than the console, with the version it reports. | |
| 75 | + | /// | |
| 76 | + | /// Empty off an Alloy machine, on a profile that lays down nothing but the | |
| 77 | + | /// console, and for any component that is named but not installed. Each of | |
| 78 | + | /// those is a real state and none of them is an error: a component that cannot | |
| 79 | + | /// be asked is left out of the list rather than listed as unknown, on the same | |
| 80 | + | /// rule the image line follows in [`crate::notice_text`]. | |
| 81 | + | /// | |
| 82 | + | /// **Costs a process per component**, which is why the caller is | |
| 83 | + | /// [`crate::wants_version`] and not every invocation of the console. | |
| 84 | + | pub(crate) fn versions() -> Vec<(String, String)> { | |
| 85 | + | let Some(names) = names() else { | |
| 86 | + | return Vec::new(); | |
| 87 | + | }; | |
| 88 | + | names | |
| 89 | + | .into_iter() | |
| 90 | + | .filter(|name| name != CONSOLE) | |
| 91 | + | .filter_map(|name| { | |
| 92 | + | let version = reported(&name)?; | |
| 93 | + | Some((name, version)) | |
| 94 | + | }) | |
| 95 | + | .collect() | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /// What `<name> --version` says, or `None` if it cannot be asked. | |
| 99 | + | /// | |
| 100 | + | /// Through [`child_command`] because that is the crate's only `Command::new` | |
| 101 | + | /// (`tests/sbin_path.rs` enforces it), and because the component is found on the | |
| 102 | + | /// child `PATH` the same way every other tool the console runs is. | |
| 103 | + | /// | |
| 104 | + | /// Not routed through [`crate::cli::Invocation`], which is the shape for a | |
| 105 | + | /// command a view runs and logs: nothing here is a view, and the log pane this | |
| 106 | + | /// crate commits to showing does not exist during argument parsing. | |
| 107 | + | fn reported(name: &str) -> Option<String> { | |
| 108 | + | let output = child_command(name).arg("--version").output().ok()?; | |
| 109 | + | if !output.status.success() { | |
| 110 | + | return None; | |
| 111 | + | } | |
| 112 | + | version_field(std::str::from_utf8(&output.stdout).ok()?).map(str::to_string) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /// The version out of a `--version` first line, as the rest of the build reads | |
| 116 | + | /// it: line one, field two. | |
| 117 | + | /// | |
| 118 | + | /// The same parse as `build/rpm/build.sh` (`head -1 | awk '{print $2}'`), and | |
| 119 | + | /// deliberately so. If this and the packaging script read a component | |
| 120 | + | /// differently, the version a machine reports and the version its RPM claims | |
| 121 | + | /// can disagree, and that disagreement is invisible afterwards — which is the | |
| 122 | + | /// one mistake that script exists to make impossible. | |
| 123 | + | fn version_field(stdout: &str) -> Option<&str> { | |
| 124 | + | stdout.lines().next()?.split_whitespace().nth(1) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | #[cfg(test)] | |
| 128 | + | mod tests { | |
| 129 | + | use super::*; | |
| 130 | + | ||
| 131 | + | /// The near-miss the NEVRA match has to refuse: a package whose name merely | |
| 132 | + | /// starts with ours is not ours. | |
| 133 | + | #[test] | |
| 134 | + | fn a_package_named_after_ours_is_not_ours() { | |
| 135 | + | assert!(is_component("alloy-0.1.0-1.fc43.x86_64", "alloy")); | |
| 136 | + | assert!(is_component("alloy", "alloy")); | |
| 137 | + | assert!(!is_component("alloy-utils", "alloy")); | |
| 138 | + | assert!(!is_component("alloys", "alloy")); | |
| 139 | + | assert!(!is_component("shop", "alloy")); | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | /// The client profile's file, and the server's, which names one component. | |
| 143 | + | #[test] | |
| 144 | + | fn the_components_file_is_read_as_whitespace_separated_names() { | |
| 145 | + | assert_eq!( | |
| 146 | + | parse("alloy\nshop\n"), | |
| 147 | + | Some(vec!["alloy".to_string(), "shop".to_string()]) | |
| 148 | + | ); | |
| 149 | + | assert_eq!(parse("alloy\n"), Some(vec!["alloy".to_string()])); | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | /// An empty file is a machine that named no components, which reads the | |
| 153 | + | /// same as having no file: say nothing rather than report an empty set as | |
| 154 | + | /// an answer. | |
| 155 | + | #[test] | |
| 156 | + | fn a_file_with_no_names_in_it_is_not_an_answer() { | |
| 157 | + | assert_eq!(parse(""), None); | |
| 158 | + | assert_eq!(parse(" \n\n"), None); | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | /// The shape every component the channel packages has to have, because the | |
| 162 | + | /// packaging script reads exactly this and refuses anything else. | |
| 163 | + | #[test] | |
| 164 | + | fn the_version_is_the_first_lines_second_field() { | |
| 165 | + | assert_eq!(version_field("shop 0.4.2\n"), Some("0.4.2")); | |
| 166 | + | assert_eq!( | |
| 167 | + | version_field("shop 0.4.2\nCopyright (c) 2026\n"), | |
| 168 | + | Some("0.4.2") | |
| 169 | + | ); | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | /// A binary that answers something else is not read for a version at all. | |
| 173 | + | /// Better to omit the component than to print the first word of an error. | |
| 174 | + | #[test] | |
| 175 | + | fn a_first_line_with_no_second_field_reports_nothing() { | |
| 176 | + | assert_eq!(version_field("shop\n"), None); | |
| 177 | + | assert_eq!(version_field(""), None); | |
| 178 | + | } | |
| 179 | + | } |