use std::collections::{BTreeMap, BTreeSet};
use std::io::IsTerminal;
use crate::cli::VersionsArgs;
use crate::error::{Error, Result};
use crate::io as rio;
use crate::registry;
use crate::state::{self, Source};
const DEFAULT_VERSION: &str = "24.04";
type SourceVersions = (String, Vec<(u32, u32, String)>);
type ImageCells = (String, Vec<SourceVersions>);
pub fn run(args: VersionsArgs) -> Result<u8> {
let sources = select_sources(args.source.as_deref())?;
let images = select_images(args.image.as_deref())?;
if images.len() <= 1 {
let image = images
.first()
.map(String::as_str)
.unwrap_or(state::DEFAULT_IMAGE);
run_single_image(image, &sources)
} else {
run_matrix(&images, &sources)
}
}
fn run_single_image(image: &str, sources: &[Source]) -> Result<u8> {
let multi = sources.len() > 1;
let mut per_source: Vec<SourceVersions> = Vec::new();
let mut errors: Vec<(String, String)> = Vec::new();
for s in sources {
match registry::image_versions(s, image) {
Ok(vs) => per_source.push((s.label().to_string(), vs)),
Err(e) => errors.push((s.label().to_string(), e.message)),
}
}
if per_source.is_empty() {
let detail = errors
.iter()
.map(|(l, e)| format!("{l}: {e}"))
.collect::<Vec<_>>()
.join("; ");
return Err(Error::msg(format!(
"could not list versions from any source ({detail})"
)));
}
if multi {
for (label, e) in &errors {
rio::skip(&format!("source '{label}' unreachable, skipping: {e}"));
}
}
let (green, reset) = colors();
for line in render_versions(&per_source, DEFAULT_VERSION, green, reset, multi) {
println!("{line}");
}
Ok(0)
}
fn run_matrix(images: &[String], sources: &[Source]) -> Result<u8> {
let mut per_image: Vec<ImageCells> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
let mut any_ok = false;
for image in images {
let mut per_source: Vec<SourceVersions> = Vec::new();
for s in sources {
match registry::image_versions(s, image) {
Ok(vs) => {
any_ok = true;
per_source.push((s.label().to_string(), vs));
}
Err(e) => {
if !is_absent_error(&e.message) {
warnings.push(format!(
"source '{}' image '{image}' unreachable, skipping: {}",
s.label(),
e.message
));
}
}
}
}
per_image.push((image.clone(), per_source));
}
if !any_ok {
return Err(Error::msg(
"could not list versions for any configured image/source".to_string(),
));
}
for w in &warnings {
rio::skip(w);
}
let (green, reset) = colors();
for line in render_matrix(&per_image, DEFAULT_VERSION, green, reset) {
println!("{line}");
}
Ok(0)
}
fn colors() -> (&'static str, &'static str) {
if std::io::stdout().is_terminal() {
("\x1b[0;32m", "\x1b[0m")
} else {
("", "")
}
}
fn is_absent_error(msg: &str) -> bool {
let m = msg.to_ascii_lowercase();
m.contains("404") || m.contains("name_unknown") || m.contains("not found") || m.contains("denied")
}
fn select_sources(filter: Option<&str>) -> Result<Vec<Source>> {
let all = state::sources();
match filter {
None => Ok(all),
Some(reg) => {
let norm = registry::normalize_registry(reg);
all.into_iter()
.find(|s| s.registry == norm || s.label() == reg)
.map(|s| vec![s])
.ok_or_else(|| {
Error::msg(format!(
"source '{reg}' is not configured (see `rusta source list`)"
))
})
}
}
}
fn select_images(filter: Option<&str>) -> Result<Vec<String>> {
match filter {
None => Ok(state::images()),
Some(name) => Ok(vec![registry::validate_image(name)?]),
}
}
pub fn render_versions(
per_source: &[SourceVersions],
default_version: &str,
green: &str,
reset: &str,
multi: bool,
) -> Vec<String> {
let mut providers: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut order: Vec<(u32, u32, String)> = Vec::new();
let mut seen: BTreeSet<String> = BTreeSet::new();
for (label, versions) in per_source {
for (maj, min, tag) in versions {
providers
.entry(tag.clone())
.or_default()
.push(label.clone());
if seen.insert(tag.clone()) {
order.push((*maj, *min, tag.clone()));
}
}
}
order.sort();
let mut lines = Vec::new();
for (_, _, v) in &order {
let is_default = v == default_version;
let vstr = if is_default {
format!("{green}{v}{reset}")
} else {
v.clone()
};
let default_suffix = if is_default { " (default)" } else { "" };
if !multi {
lines.push(format!("{vstr}{default_suffix}"));
} else {
let provs = providers.get(v).cloned().unwrap_or_default();
let chosen = if provs.len() > 1 {
format!(" (create uses {})", provs[0])
} else {
String::new()
};
lines.push(format!(
"{vstr}{default_suffix} from: {}{chosen}",
provs.join(", ")
));
}
}
lines
}
pub fn render_matrix(
per_image: &[ImageCells],
default_version: &str,
green: &str,
reset: &str,
) -> Vec<String> {
let mut order: Vec<(u32, u32, String)> = Vec::new();
let mut seen: BTreeSet<String> = BTreeSet::new();
for (_image, per_source) in per_image {
for (_label, versions) in per_source {
for (maj, min, tag) in versions {
if seen.insert(tag.clone()) {
order.push((*maj, *min, tag.clone()));
}
}
}
}
order.sort();
let mut lines = Vec::new();
for (_, _, v) in &order {
let is_default = v == default_version;
let vstr = if is_default {
format!("{green}{v}{reset}")
} else {
v.clone()
};
let default_suffix = if is_default { " (default)" } else { "" };
let mut segments = Vec::new();
for (image, per_source) in per_image {
let labels: Vec<String> = per_source
.iter()
.filter(|(_l, versions)| versions.iter().any(|(_, _, t)| t == v))
.map(|(l, _)| l.clone())
.collect();
if labels.is_empty() {
continue;
}
let mut seg = format!("{image}: {}", labels.join(", "));
if labels.len() > 1 {
seg.push_str(&format!(" (create uses {})", labels[0]));
}
segments.push(seg);
}
lines.push(format!("{vstr}{default_suffix} {}", segments.join(" ")));
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
fn v(tag: &str) -> (u32, u32, String) {
registry::parse_version(tag).unwrap()
}
#[test]
fn single_source_uses_legacy_format() {
let per = vec![(
"cirruslabs".to_string(),
vec![v("20.04"), v("22.04"), v("24.04")],
)];
let lines = render_versions(&per, "24.04", "", "", false);
assert_eq!(lines, vec!["20.04", "22.04", "24.04 (default)"]);
}
#[test]
fn multi_source_annotates_and_marks_chosen() {
let per = vec![
("cirruslabs".to_string(), vec![v("22.04"), v("24.04")]),
("pallewela".to_string(), vec![v("22.04"), v("25.04")]),
];
let lines = render_versions(&per, "24.04", "", "", true);
assert!(
lines
.iter()
.any(|l| l == "22.04 from: cirruslabs, pallewela (create uses cirruslabs)"),
"{lines:?}"
);
assert!(
lines
.iter()
.any(|l| l == "24.04 (default) from: cirruslabs"),
"{lines:?}"
);
assert!(
lines.iter().any(|l| l == "25.04 from: pallewela"),
"{lines:?}"
);
}
#[test]
fn versions_sorted_ascending_across_sources() {
let per = vec![
("a".to_string(), vec![v("24.04")]),
("b".to_string(), vec![v("20.04")]),
];
let lines = render_versions(&per, "24.04", "", "", true);
assert!(lines[0].starts_with("20.04"), "{lines:?}");
assert!(lines[1].starts_with("24.04"), "{lines:?}");
}
#[test]
fn matrix_groups_by_image_and_marks_conflicts() {
let per_image = vec![
(
"ubuntu".to_string(),
vec![
("cirruslabs".to_string(), vec![v("22.04"), v("24.04")]),
("pallewela".to_string(), vec![v("22.04")]),
],
),
(
"ubuntu-desktop".to_string(),
vec![("pallewela".to_string(), vec![v("24.04"), v("25.04")])],
),
];
let lines = render_matrix(&per_image, "24.04", "", "");
assert_eq!(
lines,
vec![
"22.04 ubuntu: cirruslabs, pallewela (create uses cirruslabs)".to_string(),
"24.04 (default) ubuntu: cirruslabs ubuntu-desktop: pallewela".to_string(),
"25.04 ubuntu-desktop: pallewela".to_string(),
]
);
}
#[test]
fn matrix_omits_images_without_a_version() {
let per_image = vec![
("ubuntu".to_string(), vec![("cirruslabs".to_string(), vec![v("24.04")])]),
("ubuntu-desktop".to_string(), vec![("pallewela".to_string(), vec![])]),
];
let lines = render_matrix(&per_image, "24.04", "", "");
assert_eq!(
lines,
vec!["24.04 (default) ubuntu: cirruslabs".to_string()]
);
}
#[test]
fn absent_error_distinguished_from_unreachable() {
assert!(is_absent_error("ghcr.io tags request failed: status code 404"));
assert!(is_absent_error("denied: requested access to the resource is denied"));
assert!(is_absent_error("NAME_UNKNOWN"));
assert!(!is_absent_error("ghcr.io token request failed: connection refused"));
assert!(!is_absent_error("dns error: failed to lookup address"));
}
}