use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use onelf_format::WorkingDir;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct Recipe {
pub package: Package,
#[serde(default)]
pub input: Option<Input>,
#[serde(default)]
pub entrypoint: Vec<Entrypoint>,
#[serde(default)]
pub compression: Compression,
pub update: Option<Update>,
#[serde(default)]
pub bundle: Bundle,
pub sysroot: Option<Sysroot>,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default)]
pub preload: Vec<String>,
#[serde(default)]
pub paths: BTreeMap<String, String>,
}
const MAX_INPUT_PATH: usize = 4096;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct Input {
pub binary: Option<PathBuf>,
}
impl Input {
pub fn validate(&self) -> std::io::Result<()> {
let Some(binary) = &self.binary else {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"[input] names no binary: there is nothing to import",
));
};
if binary.as_os_str().is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"[input] binary is empty",
));
}
if std::os::unix::ffi::OsStrExt::as_bytes(binary.as_os_str()).len() > MAX_INPUT_PATH {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("[input] binary is longer than the {MAX_INPUT_PATH} byte path limit"),
));
}
if binary.is_absolute() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"[input] binary must be relative to the recipe's directory",
));
}
Ok(())
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct Package {
pub name: Option<String>,
pub command: String,
pub output: Option<PathBuf>,
#[serde(default)]
pub working_dir: WorkingDirSpec,
pub host_libs: Option<HostLibsSpec>,
#[serde(default)]
pub cache: bool,
#[serde(default)]
pub prefer_cache: bool,
pub memfd: Option<bool>,
pub sets: Option<Vec<String>>,
#[serde(default)]
pub exclude: Vec<String>,
pub mtime: Option<u64>,
pub version: Option<String>,
pub description: Option<String>,
pub license: Option<String>,
pub homepage: Option<String>,
#[serde(default)]
pub needs_setuid: bool,
}
#[derive(Debug, Default, Clone, Copy, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum WorkingDirSpec {
#[default]
Inherit,
Package,
Command,
}
impl From<WorkingDirSpec> for WorkingDir {
fn from(s: WorkingDirSpec) -> Self {
match s {
WorkingDirSpec::Inherit => WorkingDir::Inherit,
WorkingDirSpec::Package => WorkingDir::PackageRoot,
WorkingDirSpec::Command => WorkingDir::EntrypointParent,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct Entrypoint {
pub name: String,
pub path: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub default: bool,
pub sets: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum SetSelection {
#[default]
All,
Names(Vec<String>),
}
impl SetSelection {
pub fn is_explicit(&self) -> bool {
matches!(self, SetSelection::Names(_))
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SetSelections {
pub primary: SetSelection,
pub named: BTreeMap<String, SetSelection>,
}
impl SetSelections {
pub fn normalize(recipe: &Recipe) -> std::io::Result<Self> {
if recipe.package.sets.is_some()
&& let Some(default) = recipe.entrypoint.iter().find(|e| e.default)
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"[package] sets has no entrypoint to apply to: entrypoint '{}' is the \
default and takes the package command's place. Write the list as \
that entrypoint's sets instead",
default.name
),
));
}
let declared: std::collections::BTreeSet<&str> = recipe
.sysroot
.as_ref()
.map(|sr| sr.sets.keys().map(String::as_str).collect())
.unwrap_or_default();
let primary = normalize_list("[package]", recipe.package.sets.as_deref(), &declared)?;
let mut named = BTreeMap::new();
for entrypoint in &recipe.entrypoint {
let what = format!("entrypoint '{}'", entrypoint.name);
let selection = normalize_list(&what, entrypoint.sets.as_deref(), &declared)?;
named.insert(entrypoint.name.clone(), selection);
}
Ok(Self { primary, named })
}
}
fn normalize_list(
what: &str,
written: Option<&[String]>,
declared: &std::collections::BTreeSet<&str>,
) -> std::io::Result<SetSelection> {
let Some(names) = written else {
return Ok(SetSelection::All);
};
let mut seen = std::collections::BTreeSet::new();
for name in names {
if !seen.insert(name.as_str()) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("{what}: duplicate set name '{name}'"),
));
}
if !declared.contains(name.as_str()) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("{what}: unknown set name '{name}': the recipe declares no such set"),
));
}
}
let mut canonical: Vec<String> = names.to_vec();
canonical.sort();
Ok(SetSelection::Names(canonical))
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct Compression {
#[serde(default = "default_level")]
pub level: i32,
pub block_size: Option<u64>,
#[serde(default)]
pub payload: PayloadSpec,
#[serde(default)]
pub dict: bool,
#[serde(default)]
pub store: bool,
pub prefetch: Option<PathBuf>,
}
impl Default for Compression {
fn default() -> Self {
Self {
level: default_level(),
block_size: None,
payload: PayloadSpec::default(),
dict: false,
store: false,
prefetch: None,
}
}
}
fn default_level() -> i32 {
19
}
#[derive(Debug, Clone, Copy, Default, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PayloadSpec {
#[default]
Blocks,
Stipple,
}
impl From<PayloadSpec> for crate::pack::Payload {
fn from(v: PayloadSpec) -> Self {
match v {
PayloadSpec::Blocks => crate::pack::Payload::Blocks,
PayloadSpec::Stipple => crate::pack::Payload::Stipple,
}
}
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HostLibsSpec {
Auto,
Always,
Never,
}
impl From<HostLibsSpec> for crate::pack::HostLibs {
fn from(v: HostLibsSpec) -> Self {
match v {
HostLibsSpec::Auto => crate::pack::HostLibs::Auto,
HostLibsSpec::Always => crate::pack::HostLibs::Always,
HostLibsSpec::Never => crate::pack::HostLibs::Never,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct Sysroot {
pub path: Option<PathBuf>,
pub archive: Option<PathBuf>,
pub hash: Option<String>,
pub platform: Option<String>,
#[serde(default)]
pub optional: Vec<String>,
pub platform_line: Option<PathBuf>,
pub policy: Option<PathBuf>,
pub trace: Option<PathBuf>,
pub keep: Option<PathBuf>,
pub platform_url: Option<String>,
pub platform_hash: Option<String>,
#[serde(default)]
pub sets: BTreeMap<String, SharedSet>,
}
impl Sysroot {
pub fn validate(&self) -> std::io::Result<()> {
if self.path.is_some() {
return Ok(());
}
let database_keys: &[(&str, bool)] = &[
("archive", self.archive.is_some()),
("hash", self.hash.is_some()),
("platform", self.platform.is_some()),
("optional", !self.optional.is_empty()),
("platform-line", self.platform_line.is_some()),
("policy", self.policy.is_some()),
("trace", self.trace.is_some()),
("keep", self.keep.is_some()),
];
for (name, present) in database_keys {
if *present {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"[sysroot] {name} is a sysroot-database concept and needs path: \
without it there is no package database to apply it to"
),
));
}
}
if self.sets.is_empty() && self.platform_url.is_none() && self.platform_hash.is_none() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"[sysroot] names neither path nor sets: there is nothing to record",
));
}
Ok(())
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct SharedSet {
pub packages: Vec<String>,
pub url: String,
pub blake3: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct Update {
pub url: String,
pub key: Option<PathBuf>,
pub revision: Option<u64>,
#[serde(default)]
pub unsigned: bool,
pub embed: Option<bool>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct Bundle {
pub lib_dirs: Option<Vec<String>>,
#[serde(default)]
pub search_paths: Vec<String>,
#[serde(default)]
pub exclude: Vec<String>,
#[serde(default)]
pub include: Vec<String>,
#[serde(default)]
pub gl: bool,
#[serde(default)]
pub dri: bool,
#[serde(default)]
pub vulkan: bool,
#[serde(default)]
pub wayland: bool,
#[serde(default)]
pub gtk: bool,
#[serde(default)]
pub no_gl: bool,
#[serde(default)]
pub no_dri: bool,
#[serde(default)]
pub no_vulkan: bool,
#[serde(default)]
pub no_wayland: bool,
#[serde(default)]
pub no_gtk: bool,
#[serde(default)]
pub strip: bool,
#[serde(default)]
pub strict_libc: bool,
#[serde(default)]
pub scan_dlopen: bool,
#[serde(default)]
pub dlopen: Vec<String>,
#[serde(default)]
pub trace_run: bool,
#[serde(default)]
pub trace_seconds: Option<u64>,
#[serde(default)]
pub trace_args: Vec<String>,
#[serde(default)]
pub trim_inferred: bool,
#[serde(default)]
pub skip: bool,
#[serde(default)]
pub rules: Vec<PathBuf>,
#[serde(default)]
pub disable_rules: Vec<String>,
}
pub fn load(path: &Path) -> std::io::Result<Recipe> {
let raw = std::fs::read_to_string(path).map_err(|e| match e.kind() {
std::io::ErrorKind::InvalidData => std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"{}: not a valid recipe (expected a TOML file)",
path.display()
),
),
_ => std::io::Error::new(e.kind(), format!("{}: {e}", path.display())),
})?;
let mut value: toml::Value = toml::from_str(&raw).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("{}: {e}", path.display()),
)
})?;
expand_value(&mut value);
let recipe: Recipe = value.try_into().map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("{}: {e}", path.display()),
)
})?;
if let Some(sr) = &recipe.sysroot {
sr.validate().map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("{}: {e}", path.display()),
)
})?;
}
if recipe.bundle.trim_inferred {
let traced = match &recipe.sysroot {
Some(sr) if sr.path.is_some() => sr.trace.is_some(),
_ => recipe.bundle.trace_run,
};
if !traced {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"{}: [bundle] trim-inferred is set, but no trace applies to it: \
set [sysroot] trace on a sysroot build, or trace-run on a host scan",
path.display()
),
));
}
}
if let Some(input) = &recipe.input {
input.validate().map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("{}: {e}", path.display()),
)
})?;
}
SetSelections::normalize(&recipe).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("{}: {e}", path.display()),
)
})?;
Ok(recipe)
}
fn expand_value(value: &mut toml::Value) {
match value {
toml::Value::String(s) => *s = expand_env(s),
toml::Value::Array(a) => a.iter_mut().for_each(expand_value),
toml::Value::Table(t) => t.iter_mut().for_each(|(_, v)| expand_value(v)),
_ => {}
}
}
pub fn resolve(spec: &Path) -> std::io::Result<PathBuf> {
if spec.is_dir() {
return Ok(spec.join("onelf.toml"));
}
if !spec.exists() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("{}: no such file or directory", spec.display()),
));
}
if spec.extension().and_then(|s| s.to_str()) != Some("toml") {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"{}: expected a directory containing onelf.toml or a .toml file",
spec.display()
),
));
}
Ok(spec.to_path_buf())
}
pub fn expand_env(s: &str) -> String {
let bytes = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(s.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'$' {
out.push(b'$');
i += 2;
continue;
}
if bytes[i] == b'$'
&& i + 1 < bytes.len()
&& bytes[i + 1] == b'{'
&& let Some(end) = bytes[i + 2..].iter().position(|&b| b == b'}')
{
let name = std::str::from_utf8(&bytes[i + 2..i + 2 + end]).unwrap_or("");
match std::env::var(name) {
Ok(val) => out.extend_from_slice(val.as_bytes()),
Err(_) => out.extend_from_slice(&bytes[i..i + 3 + end]),
}
i += 3 + end;
continue;
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8(out).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
}
#[cfg(test)]
mod package_tests {
use super::load;
fn load_result(body: &str) -> std::io::Result<super::Recipe> {
let dir = std::env::temp_dir().join(format!(
"onelf-recipe-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("onelf.toml");
std::fs::write(&path, body).unwrap();
let recipe = load(&path);
std::fs::remove_dir_all(&dir).unwrap();
recipe
}
fn load_str(body: &str) -> super::Recipe {
load_result(body).unwrap()
}
#[test]
fn a_sysroot_section_parses_with_relative_paths() {
let recipe = load_str(
"[package]\ncommand = \"bin/app\"\n\n[sysroot]\npath = \"sysroot\"\narchive = \"root.tar.zst\"\noptional = [\"extra\"]\nplatform-line = \"platform.txt\"\npolicy = \"policy.txt\"\n",
);
let sr = recipe.sysroot.expect("section present");
assert_eq!(sr.path, Some(std::path::PathBuf::from("sysroot")));
assert_eq!(
sr.archive.as_deref(),
Some(std::path::Path::new("root.tar.zst"))
);
assert_eq!(sr.optional, ["extra"]);
assert!(sr.trace.is_none());
assert!(sr.platform.is_none());
let recipe = load_str(
"[package]\ncommand = \"bin/app\"\n\n[sysroot]\npath = \"sysroot\"\nplatform = \"platform-1\"\n",
);
let sr = recipe.sysroot.unwrap();
assert_eq!(sr.platform.as_deref(), Some("platform-1"));
assert!(sr.platform_url.is_none() && sr.platform_hash.is_none());
let recipe = load_str(
"[package]\ncommand = \"bin/app\"\n\n[sysroot]\npath = \"sysroot\"\nplatform-url = \"https://e/gl.onelf\"\nplatform-hash = \"abc\"\n",
);
let sr = recipe.sysroot.unwrap();
assert_eq!(sr.platform_url.as_deref(), Some("https://e/gl.onelf"));
assert_eq!(sr.platform_hash.as_deref(), Some("abc"));
assert!(
load_str("[package]\ncommand = \"bin/app\"\n")
.sysroot
.is_none()
);
}
#[test]
fn cache_defaults_off_and_parses_on() {
let recipe = load_str("[package]\ncommand = \"bin/app\"\n");
assert!(!recipe.package.cache);
let recipe = load_str("[package]\ncommand = \"bin/app\"\ncache = true\n");
assert!(recipe.package.cache);
assert!(!recipe.package.prefer_cache);
let recipe = load_str("[package]\ncommand = \"bin/app\"\nprefer-cache = true\n");
assert!(recipe.package.prefer_cache);
}
const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
#[test]
fn a_sysroot_with_only_sets_parses() {
let recipe = load_str(&format!(
"[package]\ncommand = \"bin/app\"\n\n[sysroot.sets.gtk]\npackages = [\"gtk3\"]\nurl = \"https://e/gtk.onelf\"\nblake3 = \"{HASH}\"\n"
));
let sr = recipe.sysroot.expect("section present");
assert!(sr.path.is_none());
assert_eq!(sr.sets["gtk"].packages, ["gtk3"]);
assert_eq!(sr.sets["gtk"].url, "https://e/gtk.onelf");
assert_eq!(sr.sets["gtk"].blake3, HASH);
}
#[test]
fn a_sysroot_with_neither_path_nor_sets_is_refused() {
let err = load_result("[package]\ncommand = \"bin/app\"\n\n[sysroot]\n").unwrap_err();
assert!(err.to_string().contains("neither path nor sets"), "{err}");
}
#[test]
fn sysroot_database_keys_are_refused_without_a_path() {
let hash = HASH;
let bodies = [
"[sysroot]\narchive = \"root.tar.zst\"\n",
"[sysroot]\nhash = \"abc\"\n",
"[sysroot]\nplatform = \"platform-1\"\n",
"[sysroot]\noptional = [\"mesa\"]\n",
"[sysroot]\nplatform-line = \"line.txt\"\n",
"[sysroot]\npolicy = \"policy.txt\"\n",
"[sysroot]\ntrace = \"trace.txt\"\n",
"[sysroot]\nkeep = \"keep.txt\"\n",
];
for body in bodies {
let err =
load_result(&format!("[package]\ncommand = \"bin/app\"\n\n{body}")).unwrap_err();
assert!(err.to_string().contains("needs path"), "{body}: {err}");
}
let recipe = load_str(&format!(
"[package]\ncommand = \"bin/app\"\n\n[sysroot]\nplatform-url = \"https://e/gl.onelf\"\nplatform-hash = \"{hash}\"\n"
));
let sr = recipe.sysroot.unwrap();
assert!(sr.path.is_none());
assert_eq!(sr.platform_url.as_deref(), Some("https://e/gl.onelf"));
}
}
#[cfg(test)]
mod input_tests {
use super::load;
fn load_result(body: &str) -> std::io::Result<super::Recipe> {
let dir = std::env::temp_dir().join(format!(
"onelf-recipe-input-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("onelf.toml");
std::fs::write(&path, body).unwrap();
let recipe = load(&path);
std::fs::remove_dir_all(&dir).unwrap();
recipe
}
#[test]
fn an_input_section_parses_a_recipe_relative_binary() {
let recipe = load_result(
"[package]\ncommand = \"bin/app\"\n\n[input]\nbinary = \"../upstream/app\"\n",
)
.unwrap();
let input = recipe.input.expect("section present");
assert_eq!(
input.binary,
Some(std::path::PathBuf::from("../upstream/app"))
);
let bare = load_result("[package]\ncommand = \"bin/app\"\n").unwrap();
assert!(bare.input.is_none());
}
#[test]
fn an_unknown_input_field_is_refused() {
let err = load_result("[package]\ncommand = \"bin/app\"\n\n[input]\nbinaryy = \"app\"\n")
.unwrap_err();
assert!(err.to_string().contains("unknown field"), "{err}");
}
#[test]
fn an_input_that_names_nothing_is_refused() {
let err = load_result("[package]\ncommand = \"bin/app\"\n\n[input]\n").unwrap_err();
assert!(err.to_string().contains("names no binary"), "{err}");
}
#[test]
fn an_absolute_or_empty_binary_is_refused() {
let err =
load_result("[package]\ncommand = \"bin/app\"\n\n[input]\nbinary = \"/bin/app\"\n")
.unwrap_err();
assert!(err.to_string().contains("must be relative"), "{err}");
let err = load_result("[package]\ncommand = \"bin/app\"\n\n[input]\nbinary = \"\"\n")
.unwrap_err();
assert!(err.to_string().contains("is empty"), "{err}");
}
#[test]
fn an_over_long_binary_path_is_refused() {
let long = "a".repeat(super::MAX_INPUT_PATH + 1);
let body = format!("[package]\ncommand = \"bin/app\"\n\n[input]\nbinary = \"{long}\"\n");
let err = load_result(&body).unwrap_err();
assert!(err.to_string().contains("path limit"), "{err}");
}
}
#[cfg(test)]
mod expand_tests {
use super::expand_env;
#[test]
fn dollar_dollar_escapes_and_defers_to_runtime() {
assert_eq!(expand_env("a$$b"), "a$b");
assert_eq!(
expand_env("${ONELF_DIR}/bin:$${PATH}"),
"${ONELF_DIR}/bin:${PATH}"
);
assert_eq!(
expand_env("${ONELF_DIR}/bin:$${PATH:-/usr/bin:/bin}"),
"${ONELF_DIR}/bin:${PATH:-/usr/bin:/bin}"
);
}
#[test]
fn unset_vars_are_preserved_for_runtime() {
assert_eq!(expand_env("x${ONELF_DIR}y"), "x${ONELF_DIR}y");
assert_eq!(
expand_env("${ONELF_THIS_IS_NOT_SET_4f3a}"),
"${ONELF_THIS_IS_NOT_SET_4f3a}"
);
}
#[test]
fn expand_preserves_non_ascii() {
assert_eq!(
expand_env("café ☕ Ω ${ONELF_UNSET_9q}"),
"café ☕ Ω ${ONELF_UNSET_9q}"
);
}
}
#[cfg(test)]
mod selection_tests {
use super::{SetSelection, SetSelections, load};
fn load_result(body: &str) -> std::io::Result<super::Recipe> {
let dir = std::env::temp_dir().join(format!(
"onelf-recipe-sets-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("onelf.toml");
std::fs::write(&path, body).unwrap();
let recipe = load(&path);
std::fs::remove_dir_all(&dir).unwrap();
recipe
}
fn load_str(body: &str) -> super::Recipe {
load_result(body).unwrap()
}
fn selections(body: &str) -> SetSelections {
SetSelections::normalize(&load_str(body)).unwrap()
}
const SETS: &str = "[sysroot.sets.gtk]\npackages = [\"gtk3\"]\nurl = \"https://e/gtk.onelf\"\nblake3 = \"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"\n\n[sysroot.sets.audio]\npackages = [\"pipewire\"]\nurl = \"https://e/audio.onelf\"\nblake3 = \"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210\"\n";
fn recipe(body: &str) -> String {
format!("[package]\ncommand = \"bin/app\"\n{body}\n{SETS}\n")
}
#[test]
fn an_omitted_list_selects_all_and_an_empty_list_selects_none() {
let s = selections(&recipe(
"\n[[entrypoint]]\nname = \"cli\"\npath = \"bin/app\"\nsets = []\n",
));
assert_eq!(s.primary, SetSelection::All);
assert_eq!(s.named["cli"], SetSelection::Names(Vec::new()));
assert!(s.named["cli"].is_explicit());
let all = selections(&recipe(
"\n[[entrypoint]]\nname = \"cli\"\npath = \"bin/app\"\n",
));
assert_eq!(all.primary, SetSelection::All);
assert_eq!(all.named["cli"], SetSelection::All);
assert!(!all.named["cli"].is_explicit());
}
#[test]
fn an_explicit_list_is_normalized_to_canonical_order() {
let s = selections(&recipe("sets = [\"audio\", \"gtk\"]\n"));
assert_eq!(
s.primary,
SetSelection::Names(vec!["audio".to_string(), "gtk".to_string()])
);
assert!(s.primary.is_explicit());
}
#[test]
fn a_duplicate_set_name_is_refused_with_attribution() {
let err = load_result(&recipe("sets = [\"gtk\", \"gtk\"]\n")).unwrap_err();
assert!(
err.to_string()
.contains("[package]: duplicate set name 'gtk'"),
"{err}"
);
let err = load_result(&recipe(
"\n[[entrypoint]]\nname = \"cli\"\npath = \"bin/app\"\nsets = [\"audio\", \"audio\"]\n",
))
.unwrap_err();
assert!(
err.to_string()
.contains("entrypoint 'cli': duplicate set name 'audio'"),
"{err}"
);
}
#[test]
fn an_unknown_set_name_is_refused_with_attribution() {
let err = load_result(&recipe("sets = [\"gui\"]\n")).unwrap_err();
assert!(
err.to_string()
.contains("[package]: unknown set name 'gui'"),
"{err}"
);
let err = load_result(&recipe(
"\n[[entrypoint]]\nname = \"cli\"\npath = \"bin/app\"\nsets = [\"gtk\", \"gfx\"]\n",
))
.unwrap_err();
assert!(
err.to_string()
.contains("entrypoint 'cli': unknown set name 'gfx'"),
"{err}"
);
}
#[test]
fn a_named_omission_does_not_inherit_the_primary_list() {
let s = selections(&recipe(
"sets = [\"gtk\"]\n\n[[entrypoint]]\nname = \"cli\"\npath = \"bin/app\"\n",
));
assert_eq!(s.primary, SetSelection::Names(vec!["gtk".to_string()]));
assert_eq!(s.named["cli"], SetSelection::All);
}
#[test]
fn a_package_list_beside_a_default_entrypoint_is_refused() {
for own in ["", "sets = [\"audio\"]\n"] {
let err = load_result(&recipe(&format!(
"sets = [\"gtk\"]\n\n[[entrypoint]]\nname = \"gui\"\npath = \"bin/app\"\n\
default = true\n{own}"
)))
.unwrap_err();
let text = err.to_string();
assert!(text.contains("[package] sets"), "{text}");
assert!(text.contains("entrypoint 'gui' is the default"), "{text}");
}
let s = selections(&recipe(
"\n[[entrypoint]]\nname = \"gui\"\npath = \"bin/app\"\ndefault = true\nsets = [\"gtk\"]\n",
));
assert_eq!(s.named["gui"], SetSelection::Names(vec!["gtk".to_string()]));
}
#[test]
fn selections_without_declared_sets_stay_consistent() {
let s = selections(
"[package]\ncommand = \"bin/app\"\n\n[[entrypoint]]\nname = \"cli\"\npath = \"bin/app\"\n",
);
assert_eq!(s.primary, SetSelection::All);
assert_eq!(s.named["cli"], SetSelection::All);
let s = selections("[package]\ncommand = \"bin/app\"\nsets = []\n");
assert_eq!(s.primary, SetSelection::Names(Vec::new()));
let err = load_result("[package]\ncommand = \"bin/app\"\nsets = [\"gtk\"]\n").unwrap_err();
assert!(err.to_string().contains("unknown set name 'gtk'"), "{err}");
}
}