bakery: add stable/beta/dev build tracks
Adds a track concept to bakery (separate from the existing bakery/pacman distribution channel): stable (unchanged tag-triggered releases), beta (deliberate beta-v* tag promotion), and dev (published on every push to dev). Each track gets its own signed index + artifact tree under dl.breadway.dev so stable's paths and existing installs are untouched. - bakery: new Track type, a global track preference in installed.json (defaults to stable via serde, no migration needed), `bakery track show`/`set`, a BAKERY_INDEX_BASE_URL override for testing, and a real semver comparison in `update` (was a plain string-equality check before). ANSI-colored/aligned CLI output (TTY + NO_COLOR aware). - gen-index.sh: TRACK env var selects which subtree to read/write. - CI: dev-bakery.yml/beta-bakery.yml/dev-bread-theme.yml/beta-bread-theme.yml publish those two products on the new tracks; dev/beta skip the GitHub Release upload step (no per-commit release spam). - docs/release-channels.md documents the three-track policy.
This commit is contained in:
parent
db2fa3c4b4
commit
4ac54c610d
15 changed files with 771 additions and 67 deletions
|
|
@ -1,3 +1,4 @@
|
|||
use crate::ui;
|
||||
use anyhow::Result;
|
||||
use std::process::Command;
|
||||
|
||||
|
|
@ -52,28 +53,37 @@ fn pkg_config_exists(lib: &str) -> bool {
|
|||
/// Returns true if all *required* deps are satisfied.
|
||||
pub fn report(package_name: &str, required: &[String], optional: &[String]) -> bool {
|
||||
if required.is_empty() && optional.is_empty() {
|
||||
println!(" {package_name}: no system deps required");
|
||||
println!(" {}", ui::ok(&format!("{package_name}: no system deps required")));
|
||||
return true;
|
||||
}
|
||||
match check_deps(required, optional) {
|
||||
Err(e) => {
|
||||
eprintln!(" error running doctor for {package_name}: {e}");
|
||||
eprintln!(" {}", ui::fail(&format!("error running doctor for {package_name}: {e}")));
|
||||
false
|
||||
}
|
||||
Ok(rep) => {
|
||||
for warn in &rep.warnings {
|
||||
eprintln!(
|
||||
" {package_name}: optional dep not found: {warn} \
|
||||
(install for full functionality)"
|
||||
" {}",
|
||||
ui::style(
|
||||
&format!(
|
||||
"{package_name}: optional dep not found: {warn} \
|
||||
(install for full functionality)"
|
||||
),
|
||||
ui::YELLOW
|
||||
)
|
||||
);
|
||||
}
|
||||
if rep.missing.is_empty() {
|
||||
println!(" {package_name}: all required system deps satisfied");
|
||||
println!(" {}", ui::ok(&format!("{package_name}: all required system deps satisfied")));
|
||||
true
|
||||
} else {
|
||||
eprintln!(
|
||||
" {package_name}: missing system deps: {}",
|
||||
rep.missing.join(", ")
|
||||
" {}",
|
||||
ui::fail(&format!(
|
||||
"{package_name}: missing system deps: {}",
|
||||
rep.missing.join(", ")
|
||||
))
|
||||
);
|
||||
eprintln!(" install with: sudo pacman -S {}", rep.missing.join(" "));
|
||||
false
|
||||
|
|
|
|||
|
|
@ -3,11 +3,14 @@ mod download;
|
|||
mod install;
|
||||
mod manifest;
|
||||
mod state;
|
||||
mod track;
|
||||
mod ui;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use track::Track;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "bakery", about = "Package manager for the bread ecosystem", version)]
|
||||
|
|
@ -54,6 +57,20 @@ enum Cmd {
|
|||
/// Package to check; omit to check all installed packages
|
||||
package: Option<String>,
|
||||
},
|
||||
/// View or switch which build track bakery follows (stable/beta/dev)
|
||||
Track {
|
||||
#[command(subcommand)]
|
||||
action: TrackCmd,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum TrackCmd {
|
||||
/// Show the currently selected track
|
||||
Show,
|
||||
/// Switch tracks. Only changes the preference — run `bakery update --all`
|
||||
/// afterwards to actually install builds from the new track.
|
||||
Set { track: Track },
|
||||
}
|
||||
|
||||
fn default_bin_dir() -> PathBuf {
|
||||
|
|
@ -65,23 +82,52 @@ fn default_bin_dir() -> PathBuf {
|
|||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let bin_dir = cli.bin_dir.unwrap_or_else(default_bin_dir);
|
||||
let track = state::State::load()?.track;
|
||||
|
||||
match cli.command {
|
||||
Cmd::Install { packages } => {
|
||||
let index = manifest::load(true)?;
|
||||
let index = manifest::load(true, track)?;
|
||||
for pkg in &packages {
|
||||
cmd_install(&index, pkg, &bin_dir)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Remove { package } => cmd_remove(&package, &bin_dir),
|
||||
Cmd::Update { package, all } => cmd_update(package.as_deref(), all, &bin_dir),
|
||||
Cmd::List { installed } => cmd_list(installed),
|
||||
Cmd::Info { package } => cmd_info(&package),
|
||||
Cmd::Doctor { package } => cmd_doctor(package.as_deref()),
|
||||
Cmd::Update { package, all } => cmd_update(package.as_deref(), all, &bin_dir, track),
|
||||
Cmd::List { installed } => cmd_list(installed, track),
|
||||
Cmd::Info { package } => cmd_info(&package, track),
|
||||
Cmd::Doctor { package } => cmd_doctor(package.as_deref(), track),
|
||||
Cmd::Track { action } => cmd_track(action),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_track(action: TrackCmd) -> Result<()> {
|
||||
let mut state = state::State::load()?;
|
||||
match action {
|
||||
TrackCmd::Show => {
|
||||
println!("current track: {}", ui::style(state.track.as_str(), ui::CYAN));
|
||||
}
|
||||
TrackCmd::Set { track } => {
|
||||
if state.track == track {
|
||||
println!("already on track {track}");
|
||||
return Ok(());
|
||||
}
|
||||
// Fail fast on a bad/unreachable track rather than silently
|
||||
// recording a preference bakery can't actually serve.
|
||||
manifest::load(true, track)
|
||||
.with_context(|| format!("could not validate {track} track, not switching"))?;
|
||||
state.set_track(track);
|
||||
state.save()?;
|
||||
println!(
|
||||
"switched to {} — run 'bakery update --all' to install {} builds",
|
||||
ui::style(track.as_str(), ui::CYAN),
|
||||
track
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_install(index: &manifest::Index, name: &str, bin_dir: &std::path::Path) -> Result<()> {
|
||||
let mut visited = HashSet::new();
|
||||
install_with_deps(index, name, bin_dir, &mut visited)
|
||||
|
|
@ -130,8 +176,8 @@ fn cmd_remove(name: &str, bin_dir: &std::path::Path) -> Result<()> {
|
|||
install::remove_package(name, bin_dir)
|
||||
}
|
||||
|
||||
fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path) -> Result<()> {
|
||||
let index = manifest::load(true)?;
|
||||
fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path, track: Track) -> Result<()> {
|
||||
let index = manifest::load(true, track)?;
|
||||
let state = state::State::load()?;
|
||||
|
||||
let targets: Vec<String> = if all || name.is_none() {
|
||||
|
|
@ -162,14 +208,16 @@ fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path) -> Resul
|
|||
}
|
||||
};
|
||||
|
||||
if installed.version == latest.version {
|
||||
println!("{pkg_name} is already at {}", installed.version);
|
||||
if !is_newer(&installed.version, &latest.version) {
|
||||
println!("{}", ui::style(&format!("{pkg_name} is already at {}", installed.version), ui::GREEN));
|
||||
continue;
|
||||
}
|
||||
|
||||
println!(
|
||||
"updating {pkg_name} {} → {}",
|
||||
installed.version, latest.version
|
||||
"updating {pkg_name} {} {} {}",
|
||||
ui::style(&installed.version, ui::DIM),
|
||||
ui::style("→", ui::CYAN),
|
||||
ui::style(&latest.version, ui::BOLD)
|
||||
);
|
||||
|
||||
let rep = match doctor::check_deps(&latest.system_deps, &latest.optional_system_deps) {
|
||||
|
|
@ -204,7 +252,28 @@ fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path) -> Resul
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_list(installed_only: bool) -> Result<()> {
|
||||
/// Is `latest` newer than `installed`? Real semver comparison — the
|
||||
/// previous plain string-equality check couldn't tell "different" from
|
||||
/// "actually newer", so it would happily "update" a package to a lexically
|
||||
/// different but not-newer version. Falls back to a simple inequality check
|
||||
/// (with a warning) for any version string that isn't valid semver, rather
|
||||
/// than hard-erroring on packages built before this convention existed.
|
||||
fn is_newer(installed: &str, latest: &str) -> bool {
|
||||
match (semver::Version::parse(installed), semver::Version::parse(latest)) {
|
||||
(Ok(i), Ok(l)) => l > i,
|
||||
_ => {
|
||||
if installed != latest {
|
||||
eprintln!(
|
||||
" warning: '{installed}' or '{latest}' is not valid semver, \
|
||||
falling back to a plain inequality check"
|
||||
);
|
||||
}
|
||||
installed != latest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_list(installed_only: bool, track: Track) -> Result<()> {
|
||||
let state = state::State::load()?;
|
||||
|
||||
if installed_only {
|
||||
|
|
@ -217,35 +286,39 @@ fn cmd_list(installed_only: bool) -> Result<()> {
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
let index = manifest::load(false)?;
|
||||
if !matches!(track, Track::Stable) {
|
||||
println!("tracking:{}\n", ui::track_badge(track));
|
||||
}
|
||||
|
||||
let index = manifest::load(false, track)?;
|
||||
let mut names: Vec<&str> = index.packages.keys().map(|s| s.as_str()).collect();
|
||||
names.sort();
|
||||
for name in names {
|
||||
let pkg = &index.packages[name];
|
||||
let tag = if state.is_installed(name) {
|
||||
format!(" [installed {}]", state.packages[name].version)
|
||||
ui::style(&format!(" [installed {}]", state.packages[name].version), ui::GREEN)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
println!(" {} {} — {}{}", pkg.name, pkg.version, pkg.description, tag);
|
||||
println!(" {:<14} {:<10} — {}{}", pkg.name, pkg.version, pkg.description, tag);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_info(name: &str) -> Result<()> {
|
||||
let index = manifest::load(false)?;
|
||||
fn cmd_info(name: &str, track: Track) -> Result<()> {
|
||||
let index = manifest::load(false, track)?;
|
||||
let pkg = index
|
||||
.get(name)
|
||||
.ok_or_else(|| anyhow::anyhow!("unknown package: {name}"))?;
|
||||
|
||||
let state = state::State::load()?;
|
||||
let status = if let Some(inst) = state.packages.get(name) {
|
||||
format!("installed ({})", inst.version)
|
||||
ui::style(&format!("installed ({})", inst.version), ui::GREEN)
|
||||
} else {
|
||||
"not installed".to_string()
|
||||
ui::style("not installed", ui::DIM)
|
||||
};
|
||||
|
||||
println!("{} {}", pkg.name, pkg.version);
|
||||
println!("{}{} {}", ui::style(&pkg.name, ui::BOLD), ui::track_badge(track), pkg.version);
|
||||
println!(" {}", pkg.description);
|
||||
println!(" status: {status}");
|
||||
println!(
|
||||
|
|
@ -278,8 +351,8 @@ fn cmd_info(name: &str) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_doctor(name: Option<&str>) -> Result<()> {
|
||||
let index = manifest::load(false)?;
|
||||
fn cmd_doctor(name: Option<&str>, track: Track) -> Result<()> {
|
||||
let index = manifest::load(false, track)?;
|
||||
let state = state::State::load()?;
|
||||
|
||||
let targets: Vec<String> = match name {
|
||||
|
|
@ -310,7 +383,41 @@ fn cmd_doctor(name: Option<&str>) -> Result<()> {
|
|||
}
|
||||
|
||||
if all_ok {
|
||||
println!("all checks passed");
|
||||
println!("{}", ui::ok("all checks passed"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_newer_detects_real_semver_increase() {
|
||||
assert!(is_newer("0.3.1", "0.3.2"));
|
||||
assert!(is_newer("0.3.1", "0.4.0"));
|
||||
assert!(!is_newer("0.3.2", "0.3.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_newer_false_when_equal() {
|
||||
assert!(!is_newer("0.3.1", "0.3.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_newer_orders_dev_prereleases_within_a_track() {
|
||||
// Two dev builds of the same upcoming patch, ordered by their
|
||||
// timestamp+sha build suffix.
|
||||
assert!(is_newer(
|
||||
"0.3.2-dev.20260722120000+aaa1111",
|
||||
"0.3.2-dev.20260722130000+bbb2222"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_newer_falls_back_to_inequality_on_unparseable_versions() {
|
||||
// Pre-semver version strings should never hard-fail an update check.
|
||||
assert!(is_newer("weird-version-1", "weird-version-2"));
|
||||
assert!(!is_newer("weird-version-1", "weird-version-1"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,34 @@
|
|||
use crate::track::Track;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use minisign_verify::{PublicKey, Signature};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
const PRIMARY_URL: &str = "https://dl.breadway.dev/index.json";
|
||||
const SIG_URL: &str = "https://dl.breadway.dev/index.json.minisig";
|
||||
const DEFAULT_BASE_URL: &str = "https://dl.breadway.dev";
|
||||
const CACHE_MAX_AGE: Duration = Duration::from_secs(24 * 3600);
|
||||
|
||||
/// The `https://dl.breadway.dev` base can be overridden for local/staging
|
||||
/// testing (e.g. serving a fake index from `python3 -m http.server`) without
|
||||
/// rebuilding bakery — same pattern as `main.rs`'s `BAKERY_BIN_DIR` override.
|
||||
fn base_url() -> String {
|
||||
std::env::var("BAKERY_INDEX_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string())
|
||||
}
|
||||
|
||||
/// Index URL for `track`. `Stable` keeps the exact pre-track path
|
||||
/// (`{base}/index.json`) so existing infra and warm caches are unaffected;
|
||||
/// `Beta`/`Dev` live under a track-prefixed subpath.
|
||||
fn primary_url(track: Track) -> String {
|
||||
match track {
|
||||
Track::Stable => format!("{}/index.json", base_url()),
|
||||
Track::Beta | Track::Dev => format!("{}/{}/index.json", base_url(), track.as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
fn sig_url(track: Track) -> String {
|
||||
format!("{}.minisig", primary_url(track))
|
||||
}
|
||||
|
||||
/// The bakery index-signing public key.
|
||||
///
|
||||
/// The matching secret key is used offline (never on this machine, never in
|
||||
|
|
@ -120,8 +141,8 @@ impl Index {
|
|||
}
|
||||
}
|
||||
|
||||
/// Load the manifest, using the on-disk cache when it is fresh enough.
|
||||
/// Always fetches if `force_refresh` is true.
|
||||
/// Load the manifest for `track`, using the on-disk cache when it is fresh
|
||||
/// enough. Always fetches if `force_refresh` is true.
|
||||
///
|
||||
/// Every path — fresh fetch or cached read — verifies the minisign
|
||||
/// signature over the raw `index.json` bytes before the JSON is parsed or
|
||||
|
|
@ -130,12 +151,12 @@ impl Index {
|
|||
/// (possibly tampered, possibly just stale-format) cache and triggers one
|
||||
/// re-fetch from the network rather than bricking the CLI outright; if the
|
||||
/// freshly fetched copy also fails to verify, that's a hard error.
|
||||
pub fn load(force_refresh: bool) -> Result<Index> {
|
||||
let cache_path = cache_path();
|
||||
pub fn load(force_refresh: bool, track: Track) -> Result<Index> {
|
||||
let cache_path = cache_path(track);
|
||||
let sig_cache_path = sig_cache_path(&cache_path);
|
||||
|
||||
if !force_refresh && cache_is_fresh(&cache_path) {
|
||||
match read_and_verify_cache(&cache_path, &sig_cache_path) {
|
||||
match read_and_verify_cache(&cache_path, &sig_cache_path, track) {
|
||||
Ok(index) => return Ok(index),
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
|
|
@ -145,14 +166,20 @@ pub fn load(force_refresh: bool) -> Result<Index> {
|
|||
}
|
||||
}
|
||||
|
||||
fetch_and_cache(&cache_path, &sig_cache_path)
|
||||
fetch_and_cache(&cache_path, &sig_cache_path, track)
|
||||
}
|
||||
|
||||
fn read_and_verify_cache(cache_path: &PathBuf, sig_cache_path: &PathBuf) -> Result<Index> {
|
||||
fn read_and_verify_cache(
|
||||
cache_path: &PathBuf,
|
||||
sig_cache_path: &PathBuf,
|
||||
track: Track,
|
||||
) -> Result<Index> {
|
||||
let bytes = std::fs::read(cache_path).context("reading cached index")?;
|
||||
let sig_text = std::fs::read_to_string(sig_cache_path)
|
||||
.context("reading cached index.json.minisig (cache predates signing support)")?;
|
||||
verify_index_signature(&bytes, &sig_text)?;
|
||||
verify_index_signature(&bytes, &sig_text).with_context(|| {
|
||||
format!("cached {track} index failed signature verification")
|
||||
})?;
|
||||
serde_json::from_slice(&bytes).context("parsing cached index")
|
||||
}
|
||||
|
||||
|
|
@ -163,13 +190,18 @@ fn cache_is_fresh(path: &PathBuf) -> bool {
|
|||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn fetch_and_cache(cache_path: &PathBuf, sig_cache_path: &PathBuf) -> Result<Index> {
|
||||
let bytes = fetch_bytes(PRIMARY_URL)?;
|
||||
let sig_text = fetch_text(SIG_URL).context(
|
||||
fn fetch_and_cache(cache_path: &PathBuf, sig_cache_path: &PathBuf, track: Track) -> Result<Index> {
|
||||
let bytes = fetch_bytes(&primary_url(track)).with_context(|| {
|
||||
format!(
|
||||
"fetching {track} index — has a {track} build been published yet? \
|
||||
run 'bakery track set stable' to switch back"
|
||||
)
|
||||
})?;
|
||||
let sig_text = fetch_text(&sig_url(track)).context(
|
||||
"fetching index.json.minisig — the index must be signed before it can be trusted",
|
||||
)?;
|
||||
verify_index_signature(&bytes, &sig_text)
|
||||
.context("freshly fetched index.json failed signature verification")?;
|
||||
.with_context(|| format!("freshly fetched {track} index failed signature verification"))?;
|
||||
|
||||
if let Some(dir) = cache_path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
|
|
@ -193,10 +225,19 @@ fn fetch_text(url: &str) -> Result<String> {
|
|||
.context("reading response body")
|
||||
}
|
||||
|
||||
pub fn cache_path() -> PathBuf {
|
||||
/// Cache filename for `track`. `Stable` keeps the pre-track filename
|
||||
/// (`index.json`) so an existing warm cache survives an upgrade to a
|
||||
/// track-aware bakery; `Beta`/`Dev` get their own sibling files so switching
|
||||
/// tracks doesn't clobber each other's cache.
|
||||
pub fn cache_path(track: Track) -> PathBuf {
|
||||
let file_name = match track {
|
||||
Track::Stable => "index.json".to_string(),
|
||||
Track::Beta | Track::Dev => format!("index-{}.json", track.as_str()),
|
||||
};
|
||||
dirs::cache_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.cache"))
|
||||
.join("bakery/index.json")
|
||||
.join("bakery")
|
||||
.join(file_name)
|
||||
}
|
||||
|
||||
/// Download a binary blob from `primary_url`, falling back to `fallback_url`
|
||||
|
|
@ -276,4 +317,37 @@ znmVfINB4jFDR2a4wuY8rOKlUBeSDOFjMkHYDXV3vxvAjK+r4V12ae9ZRQkfVtQ1YIEmFXbnJfbxywg+
|
|||
// it must at least parse as a valid minisign public key.
|
||||
PublicKey::from_base64(PUBKEY).expect("PUBKEY must be a valid minisign public key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_cache_path_matches_pre_track_filename() {
|
||||
// Must stay exactly "index.json" so an existing warm cache from a
|
||||
// pre-track bakery binary is still used after an upgrade.
|
||||
assert_eq!(
|
||||
cache_path(Track::Stable).file_name().unwrap(),
|
||||
"index.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beta_and_dev_cache_paths_are_distinct_siblings() {
|
||||
let stable = cache_path(Track::Stable);
|
||||
let beta = cache_path(Track::Beta);
|
||||
let dev = cache_path(Track::Dev);
|
||||
assert_ne!(stable, beta);
|
||||
assert_ne!(stable, dev);
|
||||
assert_ne!(beta, dev);
|
||||
assert_eq!(beta.parent(), stable.parent());
|
||||
assert_eq!(dev.parent(), stable.parent());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_url_has_no_track_prefix() {
|
||||
assert_eq!(primary_url(Track::Stable), format!("{}/index.json", base_url()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beta_and_dev_urls_are_track_prefixed() {
|
||||
assert_eq!(primary_url(Track::Beta), format!("{}/beta/index.json", base_url()));
|
||||
assert_eq!(primary_url(Track::Dev), format!("{}/dev/index.json", base_url()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use crate::track::Track;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
|
@ -14,6 +15,11 @@ pub struct InstalledPackage {
|
|||
|
||||
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||
pub struct State {
|
||||
// `#[serde(default)]` lets an installed.json written by a pre-track
|
||||
// bakery binary deserialize straight into Track::Stable with no
|
||||
// migration step.
|
||||
#[serde(default)]
|
||||
pub track: Track,
|
||||
pub packages: HashMap<String, InstalledPackage>,
|
||||
}
|
||||
|
||||
|
|
@ -52,6 +58,10 @@ impl State {
|
|||
pub fn remove(&mut self, name: &str) -> Option<InstalledPackage> {
|
||||
self.packages.remove(name)
|
||||
}
|
||||
|
||||
pub fn set_track(&mut self, track: Track) {
|
||||
self.track = track;
|
||||
}
|
||||
}
|
||||
|
||||
fn state_path() -> PathBuf {
|
||||
|
|
@ -101,6 +111,23 @@ mod tests {
|
|||
assert!(state.remove("nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_defaults_to_stable_on_old_shape_json() {
|
||||
// Simulates installed.json written before the track field existed.
|
||||
let old_shape = r#"{"packages":{}}"#;
|
||||
let state: State = serde_json::from_str(old_shape).unwrap();
|
||||
assert_eq!(state.track, Track::Stable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_track_updates_and_roundtrips() {
|
||||
let mut state = State::default();
|
||||
state.set_track(Track::Dev);
|
||||
let json = serde_json::to_string(&state).unwrap();
|
||||
let restored: State = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(restored.track, Track::Dev);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_roundtrip() {
|
||||
let mut state = State::default();
|
||||
|
|
|
|||
90
bakery/src/track.rs
Normal file
90
bakery/src/track.rs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
use clap::ValueEnum;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Which build of a package bakery follows: the tagged stable release, a
|
||||
/// deliberately-promoted beta, or the continuously-published `dev` branch
|
||||
/// build. Not to be confused with the *distribution* channel (bakery vs.
|
||||
/// pacman) documented in `docs/release-channels.md` — that's an orthogonal,
|
||||
/// pre-existing use of the word "channel", which is why this is called a
|
||||
/// "track" instead.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, ValueEnum)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Track {
|
||||
Stable,
|
||||
Beta,
|
||||
Dev,
|
||||
}
|
||||
|
||||
impl Default for Track {
|
||||
fn default() -> Self {
|
||||
Track::Stable
|
||||
}
|
||||
}
|
||||
|
||||
impl Track {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Track::Stable => "stable",
|
||||
Track::Beta => "beta",
|
||||
Track::Dev => "dev",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Track {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Track {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"stable" => Ok(Track::Stable),
|
||||
"beta" => Ok(Track::Beta),
|
||||
"dev" => Ok(Track::Dev),
|
||||
other => Err(format!("unknown track '{other}' — expected stable, beta, or dev")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_is_stable() {
|
||||
assert_eq!(Track::default(), Track::Stable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_roundtrips_through_from_str() {
|
||||
for track in [Track::Stable, Track::Beta, Track::Dev] {
|
||||
let s = track.to_string();
|
||||
assert_eq!(s.parse::<Track>().unwrap(), track);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_str_is_case_insensitive() {
|
||||
assert_eq!("DEV".parse::<Track>().unwrap(), Track::Dev);
|
||||
assert_eq!("Beta".parse::<Track>().unwrap(), Track::Beta);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_str_rejects_unknown() {
|
||||
assert!("nightly".parse::<Track>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_roundtrip_uses_lowercase() {
|
||||
let json = serde_json::to_string(&Track::Dev).unwrap();
|
||||
assert_eq!(json, "\"dev\"");
|
||||
let back: Track = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, Track::Dev);
|
||||
}
|
||||
}
|
||||
60
bakery/src/ui.rs
Normal file
60
bakery/src/ui.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
use crate::track::Track;
|
||||
use std::io::IsTerminal;
|
||||
|
||||
pub const RESET: &str = "\x1b[0m";
|
||||
pub const BOLD: &str = "\x1b[1m";
|
||||
pub const DIM: &str = "\x1b[2m";
|
||||
pub const RED: &str = "\x1b[31m";
|
||||
pub const GREEN: &str = "\x1b[32m";
|
||||
pub const YELLOW: &str = "\x1b[33m";
|
||||
pub const CYAN: &str = "\x1b[36m";
|
||||
pub const MAGENTA: &str = "\x1b[35m";
|
||||
|
||||
/// Colors are on only when stdout is a real terminal and `NO_COLOR` isn't
|
||||
/// set — the ecosystem's existing CLI (breadcrumbs) hardcodes ANSI
|
||||
/// unconditionally, which leaks escape codes into piped/logged output; this
|
||||
/// is the hardening fix for that gap.
|
||||
pub fn colors_enabled() -> bool {
|
||||
std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal()
|
||||
}
|
||||
|
||||
pub fn style(s: &str, code: &str) -> String {
|
||||
if colors_enabled() {
|
||||
format!("{code}{s}{RESET}")
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// `" [beta]"` / `" [dev]"`, colored — empty string for `Stable` so the
|
||||
/// common-case output is unchanged.
|
||||
pub fn track_badge(track: Track) -> String {
|
||||
match track {
|
||||
Track::Stable => String::new(),
|
||||
Track::Beta => format!(" {}", style("[beta]", YELLOW)),
|
||||
Track::Dev => format!(" {}", style("[dev]", MAGENTA)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ok(s: &str) -> String {
|
||||
style(&format!("✓ {s}"), GREEN)
|
||||
}
|
||||
|
||||
pub fn fail(s: &str) -> String {
|
||||
style(&format!("✗ {s}"), RED)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stable_badge_is_empty() {
|
||||
assert_eq!(track_badge(Track::Stable), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dev_badge_is_nonempty() {
|
||||
assert!(!track_badge(Track::Dev).is_empty());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue