use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use alba_core::{
Beam, BeamId, CoreError, ExecutorKind, OptionValue, Project, execution_subgraph, expand_globs,
outputs_satisfied, render_template,
};
use alba_executors::{
BeamContext, CommandSpec, ExecContext, Executor, OutputLine, PluginExecutor, Stream,
};
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
use tokio::sync::{OwnedSemaphorePermit, Semaphore, watch};
use tokio_util::sync::CancellationToken;
use crate::EngineError;
use crate::cache::{
BeamFacts, CacheOptions, CacheStore, FORMAT_VERSION, Manifest, fingerprint, hash_file,
static_contribution,
};
use crate::event::{BeamStatus, RunEvent, RunSummary};
#[derive(Debug, Clone)]
pub struct RunOptions {
pub jobs: usize,
pub keep_going: bool,
pub params: Vec<String>,
pub cache: Option<CacheOptions>,
pub extra_env: Vec<(String, String)>,
}
#[derive(Clone)]
pub struct Executors {
pub embedded: Arc<dyn Executor>,
pub system: Arc<dyn Executor>,
pub docker: Arc<dyn Executor>,
}
impl Executors {
pub fn uniform(executor: Arc<dyn Executor>) -> Self {
Self {
embedded: Arc::clone(&executor),
system: Arc::clone(&executor),
docker: executor,
}
}
fn for_beam(&self, kind: &ExecutorKind) -> Arc<dyn Executor> {
match kind {
ExecutorKind::Shell => Arc::clone(&self.embedded),
ExecutorKind::SystemShell => Arc::clone(&self.system),
ExecutorKind::Docker { .. } => Arc::clone(&self.docker),
ExecutorKind::Plugin { .. } => {
unreachable!("plugin beams are dispatched in `run`, never through `for_beam`")
}
}
}
}
fn executor_label(kind: &ExecutorKind) -> String {
match kind {
ExecutorKind::Shell => "embedded".to_string(),
ExecutorKind::SystemShell => "system".to_string(),
ExecutorKind::Docker {
image,
volumes,
workdir,
} => {
let mut label = String::from("docker");
push_field(&mut label, image);
push_field(&mut label, &volumes.len().to_string());
for volume in volumes {
push_field(&mut label, volume);
}
push_field(&mut label, workdir.as_deref().unwrap_or(""));
label
}
ExecutorKind::Plugin { name, options } => {
let mut label = String::from("plugin");
push_field(&mut label, name);
push_field(&mut label, &options.len().to_string());
for (key, value) in options {
push_field(&mut label, key);
push_option_value(&mut label, value);
}
label
}
}
}
fn push_field(label: &mut String, value: &str) {
label.push_str(&format!("|{}:{value}", value.len()));
}
fn push_option_value(label: &mut String, value: &OptionValue) {
match value {
OptionValue::Str(v) => {
push_field(label, "str");
push_field(label, v);
}
OptionValue::Bool(v) => {
push_field(label, "bool");
push_field(label, if *v { "true" } else { "false" });
}
OptionValue::List(v) => {
push_field(label, "list");
push_field(label, &v.len().to_string());
for item in v {
push_field(label, item);
}
}
}
}
fn executor_options(kind: &ExecutorKind) -> serde_json::Value {
match kind {
ExecutorKind::Shell | ExecutorKind::SystemShell => serde_json::Value::Null,
ExecutorKind::Docker {
image,
volumes,
workdir,
} => serde_json::json!({
"image": image,
"volumes": volumes,
"workdir": workdir,
}),
ExecutorKind::Plugin { options, .. } => serde_json::Value::Object(
options
.iter()
.map(|(key, value)| (key.clone(), option_json(value)))
.collect(),
),
}
}
fn option_json(value: &OptionValue) -> serde_json::Value {
match value {
OptionValue::Str(v) => serde_json::Value::String(v.clone()),
OptionValue::Bool(v) => serde_json::Value::Bool(*v),
OptionValue::List(v) => v.iter().cloned().map(serde_json::Value::String).collect(),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Targets {
pub beams: Vec<BeamId>,
pub affected_by: Option<String>,
}
impl Targets {
pub fn beam(id: BeamId) -> Self {
Self {
beams: vec![id],
affected_by: None,
}
}
}
pub async fn run(
project: &Project,
targets: &Targets,
options: RunOptions,
executors: Executors,
events: UnboundedSender<RunEvent>,
cancel: CancellationToken,
) -> Result<RunSummary, EngineError> {
let started_at = Instant::now();
let (beams, plugins) = plan(project, &targets.beams, &options.params)?;
let _ = events.send(RunEvent::RunStarted {
targets: targets.beams.clone(),
affected_by: targets.affected_by.clone(),
beams: beams.iter().map(|beam| beam.id.clone()).collect(),
edges: beams
.iter()
.flat_map(|beam| {
beam.needs
.iter()
.map(|need| (beam.id.clone(), need.value.clone()))
})
.collect(),
});
let slots = Arc::new(Semaphore::new(options.jobs.max(1)));
let stop = cancel.child_token();
let cache = options
.cache
.as_ref()
.map(|options| Arc::new(CacheStore::new(options.dir.clone())));
let force = options.cache.as_ref().is_some_and(|options| options.force);
let mut senders = Vec::with_capacity(beams.len());
let mut receivers = HashMap::with_capacity(beams.len());
for beam in &beams {
let (sender, receiver) = watch::channel(None);
senders.push(sender);
receivers.insert(beam.id.0.as_str(), receiver);
}
let mut tasks = Vec::with_capacity(beams.len());
for (beam, status) in beams.iter().zip(senders) {
let dependencies: Vec<_> = beam
.needs
.iter()
.filter_map(|need| receivers.get(need.value.0.as_str()).cloned())
.collect();
debug_assert_eq!(
dependencies.len(),
beam.needs.len(),
"every `needs` entry of `{}` must resolve within its own subgraph",
beam.id.0
);
let task = BeamTask {
args: if targets.beams.len() == 1 && beam.id == targets.beams[0] {
options.params.clone()
} else {
Vec::new()
},
beam: (*beam).clone(),
dependencies,
status,
slots: Arc::clone(&slots),
executor: match &beam.executor {
ExecutorKind::Plugin { name, .. } => {
Arc::new(PluginExecutor::new(plugins[name.as_str()].clone()))
}
kind => executors.for_beam(kind),
},
events: events.clone(),
cancel: cancel.clone(),
stop: stop.clone(),
keep_going: options.keep_going,
cache: cache.clone(),
force,
extra_env: options.extra_env.clone(),
};
tasks.push((beam.id.clone(), tokio::spawn(run_beam(task))));
}
let mut summary = RunSummary::default();
let mut failure: Option<EngineError> = None;
for (id, task) in tasks {
match task.await {
Ok(status) => summary.record(id, &status),
Err(_) if failure.is_none() => failure = Some(EngineError::Panicked { beam: id }),
Err(_) => {}
}
}
if let Some(error) = failure {
return Err(error);
}
summary.duration = started_at.elapsed();
let _ = events.send(RunEvent::RunFinished {
summary: summary.clone(),
});
Ok(summary)
}
fn plan<'a>(
project: &'a Project,
targets: &[BeamId],
params: &[String],
) -> Result<(Vec<&'a Beam>, HashMap<String, PathBuf>), EngineError> {
let mut ids: HashSet<String> = HashSet::new();
for target in targets {
ids.extend(
execution_subgraph(project, target)?
.into_iter()
.map(|id| id.0),
);
}
let beams: Vec<&Beam> = project
.beams
.iter()
.filter(|beam| ids.contains(beam.id.0.as_str()))
.collect();
let plugins = resolve_plugins(&beams, |binary| which::which(binary).ok())?;
check_parameters(&beams, targets, params)?;
Ok((beams, plugins))
}
fn resolve_plugins(
beams: &[&Beam],
lookup: impl Fn(&str) -> Option<PathBuf>,
) -> Result<HashMap<String, PathBuf>, EngineError> {
let mut resolved = HashMap::new();
for beam in beams {
let ExecutorKind::Plugin { name, .. } = &beam.executor else {
continue;
};
if resolved.contains_key(name.as_str()) {
continue;
}
let binary = format!("alba-executor-{name}");
match lookup(&binary) {
Some(path) => {
resolved.insert(name.clone(), path);
}
None => {
let mut message = format!(
"beam `{}` uses executor `{name}`: `{name}` is neither a built-in \
executor nor `{binary}` on the PATH",
beam.id.0,
);
if let Some(candidate) =
alba_core::suggest(name, ["shell", "system_shell", "docker"].into_iter())
{
message.push_str(&format!(" — did you mean `{candidate}`?"));
}
return Err(EngineError::Unschedulable(message));
}
}
}
Ok(resolved)
}
fn check_parameters(
beams: &[&Beam],
targets: &[BeamId],
params: &[String],
) -> Result<(), EngineError> {
if !params.is_empty() && targets.len() != 1 {
return Err(EngineError::Unschedulable(format!(
"positional arguments need exactly one target beam, got {}",
targets.len()
)));
}
for beam in beams {
let is_target = targets.contains(&beam.id);
if !is_target && !beam.params.is_empty() {
return Err(EngineError::Unschedulable(format!(
"beam `{}` declares parameters, but only a target beam can be given any",
beam.id.0
)));
}
if is_target {
let given = if targets.len() == 1 { params.len() } else { 0 };
check_arity(beam, given)?;
}
}
Ok(())
}
fn check_arity(beam: &Beam, given: usize) -> Result<(), EngineError> {
let expected = beam.params.len();
if expected == given {
return Ok(());
}
Err(EngineError::Unschedulable(if expected == 0 {
format!("beam `{}` takes no parameters, got {given}", beam.id.0)
} else {
let names = beam
.params
.iter()
.map(|name| format!("`{name}`"))
.collect::<Vec<_>>()
.join(", ");
let plural = if expected == 1 { "" } else { "s" };
format!(
"beam `{}` expects {expected} parameter{plural} ({names}), got {given}",
beam.id.0
)
}))
}
#[derive(Debug, Clone)]
struct BeamOutcome {
status: BeamStatus,
contribution: Option<String>,
}
struct BeamTask {
beam: Beam,
args: Vec<String>,
dependencies: Vec<watch::Receiver<Option<BeamOutcome>>>,
status: watch::Sender<Option<BeamOutcome>>,
slots: Arc<Semaphore>,
executor: Arc<dyn Executor>,
events: UnboundedSender<RunEvent>,
cancel: CancellationToken,
stop: CancellationToken,
keep_going: bool,
cache: Option<Arc<CacheStore>>,
force: bool,
extra_env: Vec<(String, String)>,
}
async fn run_beam(mut task: BeamTask) -> BeamStatus {
let (status, duration, contribution) = match wait_for_dependencies(&mut task.dependencies).await
{
Some(contributions) => process(&task, &contributions).await,
None => (BeamStatus::Cancelled, Duration::ZERO, None),
};
if matches!(status, BeamStatus::Failed { .. }) && !task.keep_going {
task.stop.cancel();
}
let _ = task.events.send(RunEvent::BeamFinished {
id: task.beam.id.clone(),
status: status.clone(),
duration,
});
let _ = task.status.send(Some(BeamOutcome {
status: status.clone(),
contribution,
}));
status
}
async fn wait_for_dependencies(
dependencies: &mut [watch::Receiver<Option<BeamOutcome>>],
) -> Option<Vec<Option<String>>> {
let mut contributions = Vec::with_capacity(dependencies.len());
for dependency in dependencies {
let outcome = wait_for_outcome(dependency).await;
match outcome.status {
BeamStatus::Succeeded | BeamStatus::Cached | BeamStatus::FailedAllowed { .. } => {
contributions.push(outcome.contribution);
}
BeamStatus::Failed { .. } | BeamStatus::Cancelled => return None,
}
}
Some(contributions)
}
async fn wait_for_outcome(dependency: &mut watch::Receiver<Option<BeamOutcome>>) -> BeamOutcome {
let cancelled = BeamOutcome {
status: BeamStatus::Cancelled,
contribution: None,
};
match dependency.wait_for(Option::is_some).await {
Ok(seen) => match &*seen {
Some(outcome) => outcome.clone(),
None => cancelled,
},
Err(_) => cancelled,
}
}
struct Assessment {
fingerprint: String,
hit: Option<Manifest>,
}
async fn process(
task: &BeamTask,
contributions: &[Option<String>],
) -> (BeamStatus, Duration, Option<String>) {
let cancelled = (BeamStatus::Cancelled, Duration::ZERO, None);
if task.stop.is_cancelled() {
return cancelled;
}
let plan = render(&task.beam, &task.args).ok();
let Some(permit) = acquire_slot(task).await else {
return cancelled;
};
let (assessment, notice) = assess(task, plan.as_ref(), contributions).await;
if task.stop.is_cancelled() {
return cancelled;
}
if let Some(assessment) = &assessment
&& let Some(manifest) = &assessment.hit
{
drop(permit);
replay(task);
return (
BeamStatus::Cached,
Duration::from_millis(manifest.duration_ms),
Some(assessment.fingerprint.clone()),
);
}
let contribution = match (&assessment, &plan) {
(Some(assessment), _) => Some(assessment.fingerprint.clone()),
(None, Some(plan)) => Some(static_contribution(
&plan.commands,
&plan.cwd.to_string_lossy(),
&plan.env,
&task.args,
&executor_label(&task.beam.executor),
)),
(None, None) => None,
};
let (status, duration, lines) = execute(task, plan, notice, permit).await;
if status == BeamStatus::Succeeded
&& let (Some(assessment), Some(store)) = (&assessment, &task.cache)
{
store.store(
&task.beam.id,
&Manifest {
version: FORMAT_VERSION,
fingerprint: assessment.fingerprint.clone(),
outputs: task.beam.outputs.clone(),
duration_ms: u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
},
&lines,
);
}
(status, duration, contribution)
}
async fn assess(
task: &BeamTask,
plan: Option<&RenderedBeam>,
contributions: &[Option<String>],
) -> (Option<Assessment>, Option<String>) {
let (Some(store), Some(plan)) = (task.cache.as_ref(), plan) else {
return (None, None);
};
if task.beam.inputs.is_empty() {
return (None, None);
}
let Some(needs) = contributions
.iter()
.cloned()
.collect::<Option<Vec<String>>>()
else {
return (None, None);
};
let facts = CacheableBeam {
store: Arc::clone(store),
id: task.beam.id.clone(),
dir: task.beam.dir.clone(),
inputs: task.beam.inputs.clone(),
outputs: task.beam.outputs.clone(),
commands: plan.commands.clone(),
cwd: plan.cwd.to_string_lossy().into_owned(),
env: plan.env.clone(),
args: task.args.clone(),
needs,
force: task.force,
executor: executor_label(&task.beam.executor),
};
tokio::task::spawn_blocking(move || facts.decide())
.await
.unwrap_or((None, None))
}
struct CacheableBeam {
store: Arc<CacheStore>,
id: BeamId,
dir: PathBuf,
inputs: Vec<String>,
outputs: Vec<String>,
commands: Vec<String>,
cwd: String,
env: Vec<(String, String)>,
args: Vec<String>,
needs: Vec<String>,
force: bool,
executor: String,
}
impl CacheableBeam {
fn decide(&self) -> (Option<Assessment>, Option<String>) {
let matched = expand_globs(&self.dir, &self.inputs);
if matched.is_empty() {
return (
None,
Some("cache: `inputs` matched no file; running without cache".to_string()),
);
}
let mut files = Vec::new();
for (relative, absolute) in matched {
match hash_file(&absolute) {
Ok(hash) => files.push((relative, hash)),
Err(error) => {
return (
None,
Some(format!(
"cache: cannot hash input `{relative}` ({error}); running without cache"
)),
);
}
}
}
let fingerprint = fingerprint(&BeamFacts {
files: &files,
commands: &self.commands,
cwd: &self.cwd,
env: &self.env,
args: &self.args,
needs: &self.needs,
executor: &self.executor,
});
let hit = (!self.force)
.then(|| self.store.load(&self.id))
.flatten()
.filter(|manifest| manifest.fingerprint == fingerprint)
.filter(|_| outputs_satisfied(&self.dir, &self.outputs));
(Some(Assessment { fingerprint, hit }), None)
}
}
fn replay(task: &BeamTask) {
let _ = task.events.send(RunEvent::BeamCached {
id: task.beam.id.clone(),
});
if let Some(store) = &task.cache {
for line in store.load_logs(&task.beam.id) {
let _ = task.events.send(RunEvent::BeamOutput {
id: task.beam.id.clone(),
line,
replayed: true,
});
}
}
}
async fn acquire_slot(task: &BeamTask) -> Option<OwnedSemaphorePermit> {
tokio::select! {
biased;
() = task.stop.cancelled() => None,
permit = Arc::clone(&task.slots).acquire_owned() => permit.ok(),
}
}
async fn execute(
task: &BeamTask,
plan: Option<RenderedBeam>,
notice: Option<String>,
_permit: OwnedSemaphorePermit,
) -> (BeamStatus, Duration, Vec<OutputLine>) {
let started_at = Instant::now();
let _ = task.events.send(RunEvent::BeamStarted {
id: task.beam.id.clone(),
});
let (lines, output) = tokio::sync::mpsc::unbounded_channel();
let forwarder = tokio::spawn(forward_output(
task.beam.id.clone(),
output,
task.events.clone(),
));
if let Some(notice) = notice {
let _ = lines.send(OutputLine {
stream: Stream::Stderr,
text: notice,
});
}
let status = match plan.map_or_else(|| render(&task.beam, &task.args), Ok) {
Ok(plan) => run_commands(task, &plan, &lines).await,
Err(error) => {
let _ = lines.send(OutputLine {
stream: Stream::Stderr,
text: error.to_string(),
});
failure_status(task)
}
};
drop(lines);
let lines = forwarder.await.unwrap_or_default();
(status, started_at.elapsed(), lines)
}
fn failure_status(task: &BeamTask) -> BeamStatus {
if task.beam.allow_failure {
BeamStatus::FailedAllowed {
exit_code: NO_EXIT_CODE,
}
} else {
BeamStatus::Failed {
exit_code: NO_EXIT_CODE,
}
}
}
fn command_env(extra: &[(String, String)], beam: &[(String, String)]) -> Vec<(String, String)> {
let mut env: Vec<(String, String)> = extra
.iter()
.filter(|(name, _)| !beam.iter().any(|(own, _)| own == name))
.cloned()
.collect();
env.extend(beam.iter().cloned());
env
}
struct RenderedBeam {
commands: Vec<String>,
env: Vec<(String, String)>,
cwd: PathBuf,
}
fn render(beam: &Beam, args: &[String]) -> Result<RenderedBeam, EngineError> {
let scope = beam.scope.with_params(&beam.params, args);
let stamp = |error: CoreError| EngineError::Core(error.with_source_id(beam.source));
let mut commands = Vec::with_capacity(beam.run.len());
for template in &beam.run {
commands.push(render_template(template, &scope).map_err(stamp)?);
}
let mut env = Vec::with_capacity(beam.env.len());
for (name, template) in &beam.env {
env.push((
name.clone(),
render_template(template, &scope).map_err(stamp)?,
));
}
let cwd = match &beam.cwd {
Some(cwd) => beam.dir.join(cwd),
None => beam.dir.clone(),
};
Ok(RenderedBeam { commands, env, cwd })
}
const NO_EXIT_CODE: i32 = -1;
async fn run_commands(
task: &BeamTask,
plan: &RenderedBeam,
output: &UnboundedSender<OutputLine>,
) -> BeamStatus {
let context = BeamContext {
beam: task.beam.id.0.clone(),
dir: task.beam.dir.clone(),
options: executor_options(&task.beam.executor),
output: output.clone(),
cancel: task.cancel.clone(),
};
let mut session = match task.executor.open(context).await {
Ok(session) => session,
Err(error) => {
let _ = output.send(OutputLine {
stream: Stream::Stderr,
text: error.to_string(),
});
return if task.cancel.is_cancelled() {
BeamStatus::Cancelled
} else {
failure_status(task)
};
}
};
let mut status = BeamStatus::Succeeded;
for command in &plan.commands {
if task.cancel.is_cancelled() {
status = BeamStatus::Cancelled;
break;
}
let spec = CommandSpec {
command: command.clone(),
env: command_env(&task.extra_env, &plan.env),
cwd: plan.cwd.clone(),
};
let context = ExecContext {
output: output.clone(),
cancel: task.cancel.clone(),
};
let exit_code = match session.execute(spec, context).await {
Ok(result) if result.exit_code == 0 => continue,
Ok(result) => result.exit_code,
Err(error) => {
let _ = output.send(OutputLine {
stream: Stream::Stderr,
text: error.to_string(),
});
NO_EXIT_CODE
}
};
status = if task.cancel.is_cancelled() {
BeamStatus::Cancelled
} else if task.beam.allow_failure {
BeamStatus::FailedAllowed { exit_code }
} else {
BeamStatus::Failed { exit_code }
};
break;
}
if let Err(error) = session.close().await {
let _ = output.send(OutputLine {
stream: Stream::Stderr,
text: format!("session close: {error}"),
});
}
status
}
async fn forward_output(
id: BeamId,
mut output: UnboundedReceiver<OutputLine>,
events: UnboundedSender<RunEvent>,
) -> Vec<OutputLine> {
let mut seen = Vec::new();
while let Some(line) = output.recv().await {
let _ = events.send(RunEvent::BeamOutput {
id: id.clone(),
line: line.clone(),
replayed: false,
});
seen.push(line);
}
seen
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shell_labels_are_unchanged() {
assert_eq!(executor_label(&ExecutorKind::Shell), "embedded");
assert_eq!(executor_label(&ExecutorKind::SystemShell), "system");
}
#[test]
fn a_docker_label_changes_with_every_piece_of_its_configuration() {
let base = ExecutorKind::Docker {
image: "alpine:3".into(),
volumes: vec![],
workdir: None,
};
let other_image = ExecutorKind::Docker {
image: "alpine:4".into(),
volumes: vec![],
workdir: None,
};
let with_volume = ExecutorKind::Docker {
image: "alpine:3".into(),
volumes: vec!["h:/c".into()],
workdir: None,
};
let with_workdir = ExecutorKind::Docker {
image: "alpine:3".into(),
volumes: vec![],
workdir: Some("/w".into()),
};
let labels: Vec<String> = [&base, &other_image, &with_volume, &with_workdir]
.iter()
.map(|kind| executor_label(kind))
.collect();
let unique: std::collections::HashSet<&String> = labels.iter().collect();
assert_eq!(
unique.len(),
4,
"every configuration must label distinctly: {labels:?}"
);
}
#[test]
fn a_plugin_label_changes_with_its_options() {
let a = ExecutorKind::Plugin {
name: "podman".into(),
options: vec![("image".into(), OptionValue::Str("x".into()))],
};
let b = ExecutorKind::Plugin {
name: "podman".into(),
options: vec![("image".into(), OptionValue::Str("y".into()))],
};
assert_ne!(executor_label(&a), executor_label(&b));
}
#[test]
fn a_volume_cannot_forge_a_workdir_boundary() {
let injected_volume = ExecutorKind::Docker {
image: "img".into(),
volumes: vec!["a;workdir=X".into()],
workdir: None,
};
let real_workdir = ExecutorKind::Docker {
image: "img".into(),
volumes: vec!["a".into()],
workdir: Some("X;workdir=".into()),
};
assert_ne!(
executor_label(&injected_volume),
executor_label(&real_workdir)
);
}
#[test]
fn a_comma_inside_a_volume_cannot_forge_a_second_volume() {
let one_volume = ExecutorKind::Docker {
image: "img".into(),
volumes: vec!["a,b".into()],
workdir: None,
};
let two_volumes = ExecutorKind::Docker {
image: "img".into(),
volumes: vec!["a".into(), "b".into()],
workdir: None,
};
assert_ne!(executor_label(&one_volume), executor_label(&two_volumes));
}
#[test]
fn a_plugin_option_value_cannot_forge_a_second_option() {
let two_options = ExecutorKind::Plugin {
name: "podman".into(),
options: vec![
("a".into(), OptionValue::Str("1".into())),
("b".into(), OptionValue::Str("2".into())),
],
};
let injected_option = ExecutorKind::Plugin {
name: "podman".into(),
options: vec![("a".into(), OptionValue::Str("1;b=2".into()))],
};
assert_ne!(
executor_label(&two_options),
executor_label(&injected_option)
);
}
#[test]
fn a_comma_inside_a_list_option_value_cannot_forge_a_second_element() {
let one_element = ExecutorKind::Plugin {
name: "podman".into(),
options: vec![("flags".into(), OptionValue::List(vec!["a,b".into()]))],
};
let two_elements = ExecutorKind::Plugin {
name: "podman".into(),
options: vec![(
"flags".into(),
OptionValue::List(vec!["a".into(), "b".into()]),
)],
};
assert_ne!(executor_label(&one_element), executor_label(&two_elements));
}
#[test]
fn a_str_option_cannot_forge_a_list_option_of_the_same_text() {
let as_str = ExecutorKind::Plugin {
name: "podman".into(),
options: vec![("flags".into(), OptionValue::Str("a,b".into()))],
};
let as_list = ExecutorKind::Plugin {
name: "podman".into(),
options: vec![(
"flags".into(),
OptionValue::List(vec!["a".into(), "b".into()]),
)],
};
assert_ne!(executor_label(&as_str), executor_label(&as_list));
}
#[test]
fn a_missing_plugin_names_the_search_and_suggests_the_typo() {
let project =
alba_core::load_str(r#"beam d { executor dokcer { image "x" } run "x" }"#).unwrap();
let beams: Vec<&Beam> = project.beams.iter().collect();
let err = resolve_plugins(&beams, |_| None).unwrap_err();
let message = err.to_string();
assert!(
message.contains(
"`dokcer` is neither a built-in executor nor `alba-executor-dokcer` on the PATH"
),
"{message}"
);
assert!(message.contains("did you mean `docker`?"), "{message}");
}
#[test]
fn resolution_collects_one_path_per_distinct_plugin_name() {
let project = alba_core::load_str(
r#"
beam a { executor podman { image "x" } run "a" }
beam b { executor podman { image "y" } run "b" }
beam c { executor remote run "c" }
"#,
)
.unwrap();
let beams: Vec<&Beam> = project.beams.iter().collect();
let resolved = resolve_plugins(&beams, |binary| {
Some(PathBuf::from(format!("/bin/{binary}")))
})
.unwrap();
let expected: HashMap<String, PathBuf> = [
(
"podman".to_string(),
PathBuf::from("/bin/alba-executor-podman"),
),
(
"remote".to_string(),
PathBuf::from("/bin/alba-executor-remote"),
),
]
.into_iter()
.collect();
assert_eq!(resolved, expected);
}
}