use std::collections::{BTreeMap, BTreeSet};
use anyhow::{Result, bail, ensure};
use crate::config::{Config, Runner};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Machine {
pub name: String,
pub labels: Vec<String>,
}
pub fn declared(config: &Config) -> Result<()> {
for (name, runner) in &config.runners {
validate(name, runner)?;
}
Ok(())
}
fn validate(name: &str, runner: &Runner) -> Result<()> {
ensure!(
!runner.labels.is_empty(),
"runner {name:?}: labels must name at least one label"
);
let mut seen = BTreeSet::new();
for label in &runner.labels {
ensure!(
!label.trim().is_empty(),
"runner {name:?}: labels has an empty label"
);
ensure!(
seen.insert(label.as_str()),
"runner {name:?}: label {label:?} is repeated"
);
}
Ok(())
}
pub fn of(
config: &Config,
kind: &str,
job: &str,
named: Option<&Vec<String>>,
default_name: &str,
default_labels: &[&str],
) -> Result<Vec<Machine>> {
let Some(named) = named else {
return Ok(vec![Machine {
name: default_name.to_owned(),
labels: default_labels
.iter()
.map(|label| (*label).to_owned())
.collect(),
}]);
};
ensure!(
!named.is_empty(),
"{kind} {job:?}: runners must name at least one machine"
);
let mut machines = Vec::new();
let mut seen = BTreeSet::new();
for name in named {
ensure!(
seen.insert(name.as_str()),
"{kind} {job:?}: machine {name:?} is named twice"
);
machines.push(one(config, kind, job, name)?);
}
Ok(machines)
}
pub fn one(config: &Config, kind: &str, job: &str, name: &str) -> Result<Machine> {
let Some(runner) = config.runners.get(name) else {
bail!(
"{kind} {job:?}: machine {name:?} is not declared in runners ({})",
declared_names(&config.runners)
);
};
validate(name, runner)?;
Ok(Machine {
name: name.to_owned(),
labels: runner.labels.clone(),
})
}
fn declared_names(runners: &BTreeMap<String, Runner>) -> String {
if runners.is_empty() {
return "none declared".to_owned();
}
format!(
"declared: {}",
runners.keys().cloned().collect::<Vec<_>>().join(", ")
)
}
#[must_use]
pub fn check_name(job: &str, machine: &str, fanned_out: bool) -> String {
if fanned_out {
format!("{job} ({machine})")
} else {
job.to_owned()
}
}
#[cfg(test)]
mod tests {
use super::{Machine, check_name, declared, of, one};
use crate::config::Config;
fn load(yaml: &str) -> Config {
Config::parse(yaml).expect("parse")
}
fn raw(yaml: &str) -> Config {
ctl_core::input::Input::new(".ctl/ver.yaml", yaml)
.parse()
.expect("parse")
}
const REGISTRY: &str = indoc::indoc! {"
packages:
- name: verctl
path: Cargo.toml
runners:
linux:
labels: [ubuntu-latest]
big:
labels: [self-hosted, linux, x64]
"};
fn named(names: &[&str]) -> Vec<String> {
names.iter().map(|name| (*name).to_owned()).collect()
}
#[test]
fn omitted_takes_the_default_machine() {
let machines = of(
&load(REGISTRY),
"ci job",
"verify",
None,
"hosted",
&["ubuntu-latest"],
)
.unwrap();
assert_eq!(
machines,
[Machine {
name: "hosted".to_owned(),
labels: vec!["ubuntu-latest".to_owned()],
}]
);
}
#[test]
fn labels_reach_the_caller_verbatim_and_in_order() {
let machines = of(
&load(REGISTRY),
"ci job",
"verify",
Some(&named(&["big"])),
"hosted",
&[],
)
.unwrap();
assert_eq!(machines[0].labels, ["self-hosted", "linux", "x64"]);
}
#[test]
fn one_machine_with_three_labels_is_one_check() {
let machines = of(
&load(REGISTRY),
"ci job",
"verify",
Some(&named(&["big"])),
"hosted",
&[],
)
.unwrap();
assert_eq!(machines.len(), 1);
}
#[test]
fn two_machines_are_two_checks() {
let machines = of(
&load(REGISTRY),
"ci job",
"verify",
Some(&named(&["linux", "big"])),
"hosted",
&[],
)
.unwrap();
assert_eq!(machines.len(), 2);
}
#[test]
fn an_undeclared_machine_names_the_declared_ones() {
let err = one(&load(REGISTRY), "ci job", "verify", "macos").unwrap_err();
let text = format!("{err:#}");
assert!(text.contains("not declared in runners"), "{text}");
assert!(text.contains("declared: big, linux"), "{text}");
}
#[test]
fn an_empty_registry_says_so_rather_than_listing_nothing() {
let config = load(indoc::indoc! {"
packages:
- name: verctl
path: Cargo.toml
"});
let err = one(&config, "ci job", "verify", "linux").unwrap_err();
assert!(format!("{err:#}").contains("none declared"), "{err:#}");
}
#[test]
fn an_empty_runners_list_is_not_a_default() {
let err = of(
&load(REGISTRY),
"ci job",
"verify",
Some(&Vec::new()),
"hosted",
&["ubuntu-latest"],
)
.unwrap_err();
assert!(
format!("{err:#}").contains("at least one machine"),
"{err:#}"
);
}
#[test]
fn naming_one_machine_twice_is_rejected() {
let err = of(
&load(REGISTRY),
"ci job",
"verify",
Some(&named(&["linux", "linux"])),
"hosted",
&[],
)
.unwrap_err();
assert!(format!("{err:#}").contains("named twice"), "{err:#}");
}
#[test]
fn a_machine_without_labels_is_rejected_even_when_unused() {
let config = raw(indoc::indoc! {"
packages:
- name: verctl
path: Cargo.toml
runners:
broken:
labels: []
"});
let err = declared(&config).unwrap_err();
assert!(format!("{err:#}").contains("at least one label"), "{err:#}");
}
#[test]
fn a_blank_label_is_rejected() {
let config = load(indoc::indoc! {r#"
packages:
- name: verctl
path: Cargo.toml
runners:
broken:
labels: [" "]
"#});
let err = declared(&config).unwrap_err();
assert!(format!("{err:#}").contains("empty label"), "{err:#}");
}
#[test]
fn a_repeated_label_inside_one_machine_is_rejected() {
let config = load(indoc::indoc! {"
packages:
- name: verctl
path: Cargo.toml
runners:
broken:
labels: [linux, linux]
"});
let err = declared(&config).unwrap_err();
assert!(format!("{err:#}").contains("repeated"), "{err:#}");
}
#[test]
fn a_bare_label_list_is_not_a_machine() {
let err = Config::parse(indoc::indoc! {"
packages:
- name: verctl
path: Cargo.toml
runners:
linux: [ubuntu-latest]
"})
.unwrap_err();
assert!(
format!("{err:#}").contains(".ctl/ver.yaml:5:10: expected mapping"),
"{err:#}"
);
}
#[test]
fn only_a_fanned_out_job_carries_its_machine_in_the_name() {
assert_eq!(check_name("verify", "linux", false), "verify");
assert_eq!(check_name("verify", "linux", true), "verify (linux)");
}
}