use std::path::{Path, PathBuf};
use alba_core::{SourceId, load_project};
fn write(path: PathBuf, content: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, content).unwrap();
}
#[test]
fn imports_namespace_beams_and_resolve_local_needs() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path().join("Beamfile"),
"import \"api/Beamfile\" as api\nbeam all { needs [api:test] run \"echo ok\" }",
);
write(
dir.path().join("api/Beamfile"),
"beam build { run \"echo b\" }\nbeam test { needs [build] run \"echo t\" }",
);
let (project, _) = load_project(&dir.path().join("Beamfile")).unwrap();
let ids: Vec<&str> = project.beams.iter().map(|b| b.id.0.as_str()).collect();
assert!(ids.contains(&"api:build") && ids.contains(&"api:test") && ids.contains(&"all"));
let api_test = project.beams.iter().find(|b| b.id.0 == "api:test").unwrap();
assert_eq!(api_test.needs[0].value.0, "api:build"); }
#[test]
fn import_cycle_is_reported_with_chain() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path().join("a/Beamfile"),
"import \"../b/Beamfile\" as b\nbeam x { needs [b:y] run \"echo a\" }",
);
write(
dir.path().join("b/Beamfile"),
"import \"../a/Beamfile\" as a\nbeam y { needs [a:x] run \"echo b\" }",
);
let err = load_project(&dir.path().join("a/Beamfile")).unwrap_err();
assert!(err.error.message.contains("import cycle"));
assert_eq!(err.error.message.matches("Beamfile").count(), 3);
assert_eq!(err.error.message.matches(" -> ").count(), 2);
}
#[test]
fn imported_default_is_ignored() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path().join("Beamfile"),
"import \"api/Beamfile\" as api\nbeam all { run \"echo ok\" }",
);
write(
dir.path().join("api/Beamfile"),
"default build\nbeam build { run \"echo b\" }",
);
let (project, _) = load_project(&dir.path().join("Beamfile")).unwrap();
assert!(project.default.is_none());
}
#[test]
fn root_default_is_kept() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path().join("Beamfile"),
"default all\nbeam all { run \"echo ok\" }",
);
let (project, _) = load_project(&dir.path().join("Beamfile")).unwrap();
assert_eq!(
project.default.as_ref().map(|d| d.value.0.as_str()),
Some("all")
);
}
#[test]
fn nested_imports_join_namespaces_with_colon() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path().join("Beamfile"),
"import \"api/Beamfile\" as api\n\
beam all { needs [api:build, api:db:migrate] run \"echo ok\" }",
);
write(
dir.path().join("api/Beamfile"),
"import \"db/Beamfile\" as db\nbeam build { needs [db:migrate] run \"echo b\" }",
);
write(
dir.path().join("api/db/Beamfile"),
"beam migrate { run \"echo m\" }",
);
let (project, _) = load_project(&dir.path().join("Beamfile")).unwrap();
let ids: Vec<&str> = project.beams.iter().map(|b| b.id.0.as_str()).collect();
assert!(ids.contains(&"api:db:migrate"));
let build = project
.beams
.iter()
.find(|b| b.id.0 == "api:build")
.unwrap();
assert_eq!(build.needs[0].value.0, "api:db:migrate");
let all = project.beams.iter().find(|b| b.id.0 == "all").unwrap();
let needs: Vec<&str> = all.needs.iter().map(|n| n.value.0.as_str()).collect();
assert_eq!(needs, ["api:build", "api:db:migrate"]);
}
#[test]
fn a_file_imported_from_two_sites_is_loaded_once() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path().join("Beamfile"),
"import \"left/Beamfile\" as l\nimport \"right/Beamfile\" as r\n\
beam all { needs [l:x, r:x] run \"echo ok\" }",
);
write(
dir.path().join("left/Beamfile"),
"import \"../shared/Beamfile\" as s\nbeam x { needs [s:base] run \"echo l\" }",
);
write(
dir.path().join("right/Beamfile"),
"import \"../shared/Beamfile\" as s\nbeam x { needs [s:base] run \"echo r\" }",
);
write(
dir.path().join("shared/Beamfile"),
"beam base { run \"echo s\" }",
);
let (project, sources) = load_project(&dir.path().join("Beamfile")).unwrap();
let mut ids: Vec<&str> = project.beams.iter().map(|b| b.id.0.as_str()).collect();
ids.sort_unstable();
assert_eq!(ids, ["all", "l:s:base", "l:x", "r:s:base", "r:x"]);
assert!(
sources.get(SourceId(3)).is_some(),
"four distinct files were read"
);
assert!(
sources.get(SourceId(4)).is_none(),
"`shared/Beamfile` must be read once, not once per import site"
);
}
#[test]
fn both_copies_of_a_shared_import_keep_their_own_namespace() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path().join("Beamfile"),
"import \"shared/Beamfile\" as a\nimport \"shared/Beamfile\" as b\n\
beam all { needs [a:base, b:base] run \"echo ok\" }",
);
write(
dir.path().join("shared/Beamfile"),
"beam base { run \"echo s\" }",
);
let (project, _) = load_project(&dir.path().join("Beamfile")).unwrap();
let shared: Vec<&alba_core::Beam> = project
.beams
.iter()
.filter(|beam| beam.id.0.ends_with(":base"))
.collect();
let ids: Vec<&str> = shared.iter().map(|beam| beam.id.0.as_str()).collect();
assert_eq!(ids, ["a:base", "b:base"]);
assert_eq!(shared[0].source, shared[1].source);
}
#[test]
fn duplicate_import_alias_is_an_error() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path().join("Beamfile"),
"import \"a/Beamfile\" as x\nimport \"b/Beamfile\" as x\nbeam all { run \"echo ok\" }",
);
write(dir.path().join("a/Beamfile"), "beam one { run \"echo 1\" }");
write(dir.path().join("b/Beamfile"), "beam two { run \"echo 2\" }");
let err = load_project(&dir.path().join("Beamfile")).unwrap_err();
assert!(err.error.message.contains("duplicate import alias"));
}
#[test]
fn missing_import_reports_span_and_importing_source_id() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path().join("Beamfile"),
"import \"missing/Beamfile\" as m\nbeam all { run \"echo ok\" }",
);
let err = load_project(&dir.path().join("Beamfile")).unwrap_err();
assert_eq!(err.error.source_id, SourceId(0));
assert!(err.error.span.expect("the import path has a span").start > 0);
assert!(err.sources.get(err.error.source_id).is_some());
}
#[test]
fn beam_dir_is_its_defining_files_directory() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path().join("Beamfile"),
"import \"api/Beamfile\" as api\nbeam all { run \"echo ok\" }",
);
write(
dir.path().join("api/Beamfile"),
"beam build { run \"echo b\" }",
);
let (project, _) = load_project(&dir.path().join("Beamfile")).unwrap();
let build = project
.beams
.iter()
.find(|b| b.id.0 == "api:build")
.unwrap();
assert_eq!(build.dir, dir.path().join("api"));
let all = project.beams.iter().find(|b| b.id.0 == "all").unwrap();
assert_eq!(all.dir, dir.path());
}
#[test]
fn nested_relative_imports_avoid_verbatim_windows_paths() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path().join("Beamfile"),
"import \"a/b/Beamfile\" as ab\nbeam all { run \"echo ok\" }",
);
write(
dir.path().join("a/b/Beamfile"),
"import \"../../back/Beamfile\" as back\nbeam here { run \"echo h\" }",
);
write(
dir.path().join("back/Beamfile"),
"beam there { run \"echo t\" }",
);
let (project, _) = load_project(&dir.path().join("Beamfile")).unwrap();
let ids: Vec<&str> = project.beams.iter().map(|b| b.id.0.as_str()).collect();
assert!(ids.contains(&"ab:here") && ids.contains(&"ab:back:there"));
for beam in &project.beams {
let dir_str = beam.dir.to_string_lossy();
assert!(
!dir_str.starts_with(r"\\?\"),
"beam `{}`'s dir must not be a verbatim path, got {dir_str}",
beam.id.0
);
}
}
#[test]
fn source_map_tracks_path_and_text_per_source_id() {
let dir = tempfile::tempdir().unwrap();
let root_src = "import \"api/Beamfile\" as api\nbeam all { run \"echo ok\" }";
let api_src = "beam build { run \"echo b\" }";
write(dir.path().join("Beamfile"), root_src);
write(dir.path().join("api/Beamfile"), api_src);
let (project, sources) = load_project(&dir.path().join("Beamfile")).unwrap();
let build = project
.beams
.iter()
.find(|b| b.id.0 == "api:build")
.unwrap();
let (_, text) = sources.get(build.source).unwrap();
assert_eq!(text, api_src);
let all = project.beams.iter().find(|b| b.id.0 == "all").unwrap();
let (path, text) = sources.get(all.source).unwrap();
assert_eq!(text, root_src);
assert!(path.ends_with("Beamfile"));
}
fn git(dir: &Path, args: &[&str]) {
let output = std::process::Command::new("git")
.current_dir(dir)
.args(args)
.output()
.unwrap();
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn repository() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
git(dir.path(), &["init", "-q", "-b", "main"]);
git(dir.path(), &["config", "user.email", "alba@example.com"]);
git(dir.path(), &["config", "user.name", "Alba"]);
git(dir.path(), &["config", "commit.gpgsign", "false"]);
std::fs::create_dir(dir.path().join("src")).unwrap();
std::fs::write(dir.path().join("src/lib.rs"), "fn a() {}").unwrap();
std::fs::write(dir.path().join("README.md"), "# x").unwrap();
git(dir.path(), &["add", "-A"]);
git(dir.path(), &["commit", "-q", "-m", "init"]);
dir
}
#[test]
fn git_fields_evaluate_from_the_repository_holding_the_beamfile() {
let dir = repository();
write(
dir.path().join("Beamfile"),
"let tag = git.branch + \"-\" + git.short_sha\n\
beam b { description \"{tag} {if git.dirty then 'dirty' else 'clean'}\" run \"echo {git.sha}\" }",
);
let (project, _) = load_project(&dir.path().join("Beamfile")).unwrap();
let description = project.beams[0].description.as_deref().unwrap();
assert!(description.starts_with("main-"), "{description}");
assert!(description.ends_with(" dirty"), "{description}");
let sha =
alba_core::render_template(&project.beams[0].run[0], &project.beams[0].scope).unwrap();
assert_eq!(sha.len(), "echo ".len() + 40, "{sha}");
let short_sha = description
.strip_prefix("main-")
.and_then(|rest| rest.strip_suffix(" dirty"))
.unwrap_or_else(|| panic!("unexpected description shape: {description}"));
assert!(!short_sha.is_empty(), "{description}");
assert!(
short_sha.chars().all(|c| c.is_ascii_hexdigit()),
"{description}"
);
}
#[test]
fn git_is_only_spawned_when_an_expression_reads_it() {
let dir = tempfile::tempdir().unwrap();
write(dir.path().join("Beamfile"), "beam b { run \"echo plain\" }");
load_project(&dir.path().join("Beamfile")).unwrap();
}
#[test]
fn git_behind_a_statically_known_untaken_branch_is_never_spawned() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path().join("Beamfile"),
"beam x { run \"{if true then 'a' else git.branch}\" }",
);
load_project(&dir.path().join("Beamfile")).unwrap();
}
#[test]
fn reading_git_outside_a_repository_points_at_the_expression() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path().join("Beamfile"),
"let b = git.branch\nbeam x { run \"echo\" }",
);
let err = load_project(&dir.path().join("Beamfile")).unwrap_err();
assert!(
err.error.message.contains("cannot read `git.branch`"),
"{}",
err.error.message
);
let source = "let b = git.branch";
let span = err.error.span.unwrap();
assert_eq!(&source[span.start..span.end], "git.branch");
}
#[test]
fn an_unknown_git_field_is_rejected_without_spawning_git() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path().join("Beamfile"),
"let b = git.tag\nbeam x { run \"echo\" }",
);
let err = load_project(&dir.path().join("Beamfile")).unwrap_err();
assert!(
err.error.message.contains("unknown git field `tag`"),
"{}",
err.error.message
);
assert!(err.error.help.as_deref().unwrap().contains("short_sha"));
}
#[test]
fn only_git_has_fields() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path().join("Beamfile"),
"let a = \"x\"\nlet b = a.len\nbeam x { run \"echo\" }",
);
let err = load_project(&dir.path().join("Beamfile")).unwrap_err();
assert!(
err.error.message.contains("`a` has no fields"),
"{}",
err.error.message
);
}
#[test]
fn git_dirty_is_a_boolean_usable_in_a_condition() {
let dir = repository();
write(
dir.path().join("Beamfile"),
"let flag = git.dirty == true\nbeam x { run \"echo {flag}\" }",
);
let (project, _) = load_project(&dir.path().join("Beamfile")).unwrap();
let rendered =
alba_core::render_template(&project.beams[0].run[0], &project.beams[0].scope).unwrap();
assert_eq!(rendered, "echo true");
}
#[test]
fn hooks_load_with_their_target_and_arity() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path().join("Beamfile"),
"import \"api/Beamfile\" as api\n\
hook pre-commit { beam check }\n\
hook commit-msg { beam api:lint_message }\n\
beam check { run \"x\" }",
);
write(
dir.path().join("api/Beamfile"),
"hook pre-push { beam lint_message }\n\
beam lint_message(path) { run \"lint {path}\" }",
);
let (project, _) = load_project(&dir.path().join("Beamfile")).unwrap();
let hooks: Vec<(&str, &str, usize)> = project
.hooks
.iter()
.map(|hook| (hook.name.as_str(), hook.beam.value.0.as_str(), hook.arity))
.collect();
assert_eq!(
hooks,
[
("pre-commit", "check", 0),
("commit-msg", "api:lint_message", 1)
]
);
}
#[test]
fn an_unknown_hook_name_is_rejected_with_a_suggestion() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path().join("Beamfile"),
"hook pre-comit { beam check }\nbeam check { run \"x\" }",
);
let err = load_project(&dir.path().join("Beamfile")).unwrap_err();
assert!(
err.error.message.contains("unknown git hook `pre-comit`"),
"{}",
err.error.message
);
assert!(err.error.help.as_deref().unwrap().contains("pre-commit"));
}
#[test]
fn a_duplicate_hook_is_rejected() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path().join("Beamfile"),
"hook pre-commit { beam a }\nhook pre-commit { beam b }\nbeam a { run \"x\" }\nbeam b { run \"x\" }",
);
let err = load_project(&dir.path().join("Beamfile")).unwrap_err();
assert!(
err.error.message.contains("duplicate hook `pre-commit`"),
"{}",
err.error.message
);
}
#[test]
fn a_hook_naming_an_unknown_beam_is_rejected_at_the_reference() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path().join("Beamfile"),
"hook pre-commit { beam chekc }\nbeam check { run \"x\" }",
);
let err = load_project(&dir.path().join("Beamfile")).unwrap_err();
assert!(
err.error.message.contains("unknown beam `chekc`"),
"{}",
err.error.message
);
let source = std::fs::read_to_string(dir.path().join("Beamfile")).unwrap();
let span = err.error.span.unwrap();
assert_eq!(&source[span.start..span.end], "chekc");
}
#[test]
fn a_hook_target_with_too_many_parameters_is_rejected() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path().join("Beamfile"),
"hook commit-msg { beam lint }\nbeam lint(path, extra) { run \"x {path} {extra}\" }",
);
let err = load_project(&dir.path().join("Beamfile")).unwrap_err();
assert_eq!(
err.error.message,
"hook `commit-msg` passes 1 argument, but beam `lint` declares 2 parameters"
);
}
#[test]
fn a_hook_target_with_fewer_parameters_than_arguments_is_fine() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path().join("Beamfile"),
"hook pre-push { beam lint }\nbeam lint { run \"x\" }",
);
load_project(&dir.path().join("Beamfile")).unwrap();
}
#[test]
fn an_invalid_hook_in_an_imported_file_is_ignored_not_rejected() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path().join("Beamfile"),
"import \"api/Beamfile\" as api\nbeam all { run \"echo ok\" }",
);
write(
dir.path().join("api/Beamfile"),
"hook pre-comit { beam build }\n\
hook pre-commit { beam build }\n\
hook pre-commit { beam build }\n\
beam build { run \"echo b\" }",
);
let (project, _) = load_project(&dir.path().join("Beamfile")).unwrap();
assert!(project.hooks.is_empty());
}