#![allow(
clippy::needless_raw_string_hashes,
clippy::too_many_lines,
clippy::missing_errors_doc,
clippy::missing_panics_doc,
clippy::doc_markdown,
clippy::stable_sort_primitive
)]
use std::fs;
use std::path::{Path, PathBuf};
use assert_cmd::Command;
use assert_fs::TempDir;
use assert_fs::prelude::*;
use cabin::Cli;
use clap::CommandFactory;
use predicates::prelude::*;
mod common;
use common::*;
const SKIP_EXTERNAL_TOOL_TESTS_ENV: &str = "CABIN_SKIP_EXTERNAL_TOOL_TESTS";
const CABIN_VERSION: &str = env!("CARGO_PKG_VERSION");
fn all_subcommand_names() -> Vec<String> {
Cli::command()
.get_subcommands()
.filter(|sub| sub.get_name() != "help")
.map(|sub| sub.get_name().to_owned())
.collect()
}
fn visible_subcommand_names() -> Vec<String> {
Cli::command()
.get_subcommands()
.filter(|sub| sub.get_name() != "help" && !sub.is_hide_set())
.map(|sub| sub.get_name().to_owned())
.collect()
}
fn hidden_subcommand_names() -> Vec<String> {
Cli::command()
.get_subcommands()
.filter(|sub| sub.is_hide_set())
.map(|sub| sub.get_name().to_owned())
.collect()
}
fn parse_help_commands_block(help: &str) -> Vec<String> {
let mut in_block = false;
let mut names = Vec::new();
for line in help.lines() {
let trimmed = line.trim_end();
if trimmed.starts_with("Commands:") {
in_block = true;
continue;
}
if !in_block {
continue;
}
if trimmed.is_empty() {
break;
}
let Some(content) = line.strip_prefix(" ") else {
break;
};
if let Some(name) = content.split_whitespace().next() {
names.push(name.to_owned());
}
}
names
}
const VALID_MANIFEST: &str = r#"[package]
name = "hello"
version = "0.1.0"
[target.hello]
type = "executable"
sources = ["src/main.cc"]
"#;
const HELLO_MAIN_CC: &str = "#include <iostream>\n\nint main() {\n std::cout << \"Hello from Cabin\\n\";\n return 0;\n}\n";
fn pin_test_user_config_home_to_empty(cmd: &mut Command) {
static CLEAR_STALE: std::sync::Once = std::sync::Once::new();
let base = std::env::temp_dir().join("cabin-tests-empty-home");
CLEAR_STALE.call_once(|| {
let _ = std::fs::remove_dir_all(&base);
});
cmd.env("HOME", &base);
cmd.env("XDG_CONFIG_HOME", base.join("xdg-config"));
}
fn use_fake_external_tools() -> bool {
std::env::var_os(SKIP_EXTERNAL_TOOL_TESTS_ENV).is_some_and(|value| !value.is_empty())
}
fn require_external_tool(name: &str) {
assert!(
command_exists(name),
"external tool `{name}` is required for this test; install it or set {SKIP_EXTERNAL_TOOL_TESTS_ENV}=1 to run the external-tool smoke tests against bundled fake tools"
);
}
fn workspace_test_bin(name: &str) -> PathBuf {
let test_exe = std::env::current_exe().expect("current_exe");
let mut dir = test_exe
.parent()
.expect("test exe should live in a directory")
.to_path_buf();
if dir.file_name().and_then(|n| n.to_str()) == Some("deps") {
dir.pop();
}
let candidate = dir.join(format!("{name}{}", std::env::consts::EXE_SUFFIX));
assert!(
candidate.is_file(),
"expected test helper binary at {}; build the workspace tests with the matching fake-tool feature enabled",
candidate.display()
);
candidate
}
fn skip(test_name: &str, reason: &str) {
eprintln!("test `{test_name}` skipped: {reason}");
}
macro_rules! skip_if {
($cond:expr, $name:expr, $reason:expr $(,)?) => {
if $cond {
skip($name, $reason);
return;
}
};
}
fn run_json(cmd: &mut Command) -> serde_json::Value {
let out = cmd.assert().success().get_output().clone();
serde_json::from_slice(&out.stdout).expect("command stdout should be valid JSON")
}
fn make_archive(path: &std::path::Path, entries: &[(&str, &str)]) -> String {
use std::io::Write as _;
if let Some(parent) = path.parent() {
assert_fs::fixture::ChildPath::new(parent)
.create_dir_all()
.unwrap();
}
let f = std::fs::File::create(path).unwrap();
let enc = flate2::write::GzEncoder::new(f, flate2::Compression::default());
let mut builder = tar::Builder::new(enc);
for (rel, body) in entries {
let bytes = body.as_bytes();
let mut header = tar::Header::new_gnu();
header.set_size(bytes.len() as u64);
header.set_mode(0o644);
header.set_entry_type(tar::EntryType::Regular);
header.set_cksum();
builder
.append_data(&mut header, rel, &mut std::io::Cursor::new(bytes))
.unwrap();
}
let enc = builder.into_inner().unwrap();
enc.finish().unwrap().flush().unwrap();
cabin_core::hash::hash_reader(std::fs::File::open(path).unwrap()).unwrap()
}
fn write_index_entry(
index_dir: &std::path::Path,
package: &str,
version: &str,
deps_json: &str,
checksum: &str,
source_path: &str,
) {
let body = format!(
r#"{{
"schema": 1,
"name": "{package}",
"versions": {{
"{version}": {{
"dependencies": {deps_json},
"yanked": false,
"checksum": "sha256:{checksum}",
"source": {{ "type": "archive", "path": "{source_path}", "format": "tar.gz" }}
}}
}}
}}"#
);
assert_fs::fixture::ChildPath::new(index_dir.join(format!("{package}.json")))
.write_str(&body)
.unwrap();
}
fn write_index_entry_no_source(
index_dir: &std::path::Path,
package: &str,
version: &str,
checksum: &str,
) {
let body = format!(
r#"{{
"schema": 1,
"name": "{package}",
"versions": {{
"{version}": {{
"dependencies": {{}},
"yanked": false,
"checksum": "sha256:{checksum}"
}}
}}
}}"#
);
assert_fs::fixture::ChildPath::new(index_dir.join(format!("{package}.json")))
.write_str(&body)
.unwrap();
}
mod external_tool_smoke {
use super::*;
fn external_tool(real: &str, fake: &str) -> PathBuf {
if use_fake_external_tools() {
workspace_test_bin(fake)
} else {
require_external_tool(real);
PathBuf::from(real)
}
}
fn assert_process_success(tool: &Path, args: &[&str], label: &str) {
let output = std::process::Command::new(tool)
.args(args)
.output()
.unwrap_or_else(|err| panic!("failed to spawn {label} at {}: {err}", tool.display()));
assert!(
output.status.success(),
"{label} at {} exited with {:?}\nstdout:\n{}\nstderr:\n{}",
tool.display(),
output.status.code(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
fn write_cpp_project(root: &TempDir, manifest_tail: &str, source: &str) {
root.child("cabin.toml")
.write_str(&format!("{VALID_MANIFEST}\n{manifest_tail}"))
.unwrap();
root.child("src/main.cc").write_str(source).unwrap();
}
#[test]
fn ninja_is_available_or_fake_backend_is_selected() {
let tool = external_tool("ninja", "cabin-ninja-fake-ninja");
assert_process_success(&tool, &["--version"], "ninja");
}
#[test]
fn pkg_config_is_available_or_fake_probe_is_selected() {
let tool = external_tool("pkg-config", "cabin-system-deps-fake-pkg-config");
assert_process_success(&tool, &["--version"], "pkg-config");
}
#[test]
fn cabin_fmt_reaches_real_formatter_or_fake_formatter() {
let dir = TempDir::new().unwrap();
let source = if use_fake_external_tools() {
"int main() { return 0; }\n/* FORMATTED */\n"
} else {
"int main() { return 0; }\n"
};
write_cpp_project(&dir, "", source);
dir.child(".clang-format")
.write_str("BasedOnStyle: LLVM\n")
.unwrap();
let mut cmd = cabin();
if use_fake_external_tools() {
cmd.env("CABIN_FMT", workspace_test_bin("cabin-fmt-fake-formatter"));
} else {
require_external_tool("clang-format");
}
cmd.current_dir(dir.path())
.args(["fmt", "--check"])
.assert()
.success();
}
#[test]
fn cabin_lint_subcommand_no_longer_exists() {
let dir = TempDir::new().unwrap();
write_cpp_project(&dir, "", "int main() { return 0; }\n");
cabin()
.current_dir(dir.path())
.arg("lint")
.assert()
.failure()
.stderr(predicate::str::contains("unrecognized subcommand"));
}
#[test]
fn cabin_tidy_reaches_real_tidy_or_fake_tidy() {
let dir = TempDir::new().unwrap();
write_cpp_project(&dir, "", "int main() { return 0; }\n");
dir.child(".clang-tidy")
.write_str("Checks: '-*,clang-diagnostic-*,clang-analyzer-core.*'\n")
.unwrap();
let mut cmd = cabin();
if use_fake_external_tools() {
cmd.env("CABIN_TIDY", workspace_test_bin("cabin-tidy-fake-tidy"));
let dummy_tool = workspace_test_bin("cabin-ninja-fake-ninja");
cmd.env("CXX", &dummy_tool);
cmd.env("CC", &dummy_tool);
cmd.env("AR", &dummy_tool);
} else {
require_external_tool("run-clang-tidy");
assert!(
build_tools_available(),
"real `cabin tidy` smoke test requires ninja and a C++ compiler; install them or set {SKIP_EXTERNAL_TOOL_TESTS_ENV}=1 to use bundled fake tools"
);
}
cmd.current_dir(dir.path()).arg("tidy").assert().success();
}
}
fn run_metadata(manifest_path: &Path) -> serde_json::Value {
let output = cabin()
.args(["metadata", "--manifest-path"])
.arg(manifest_path)
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf-8");
serde_json::from_str(&stdout)
.unwrap_or_else(|err| panic!("expected valid JSON, got error {err} for: {stdout}"))
}
fn package_in<'a>(meta: &'a serde_json::Value, name: &str) -> &'a serde_json::Value {
meta["packages"]
.as_array()
.expect("packages must be array")
.iter()
.find(|p| p["name"] == name)
.unwrap_or_else(|| panic!("package {name:?} not found in metadata: {meta}"))
}
#[test]
fn init_creates_manifest_and_main_cc() {
let dir = TempDir::new().expect("tempdir should be created");
cabin()
.current_dir(dir.path())
.args(["init", "--name", "hello"])
.assert()
.success();
let manifest_path = dir.path().join("cabin.toml");
let manifest = fs::read_to_string(&manifest_path).expect("cabin.toml should be readable");
assert!(manifest.contains("[package]"));
assert!(manifest.contains(r#"name = "hello""#));
assert!(manifest.contains("[target.hello]"));
let main_cc = dir.path().join("src").join("main.cc");
assert!(main_cc.is_file(), "src/main.cc should exist");
let main_contents = fs::read_to_string(&main_cc).unwrap();
assert!(main_contents.contains("int main"));
}
#[test]
fn init_fails_when_manifest_already_exists() {
let dir = TempDir::new().expect("tempdir should be created");
let manifest_path = dir.path().join("cabin.toml");
assert_fs::fixture::ChildPath::new(&manifest_path)
.write_str("# preexisting cabin.toml\n")
.unwrap();
cabin()
.current_dir(dir.path())
.args(["init", "--name", "hello"])
.assert()
.failure()
.stderr(predicate::str::contains("cabin.toml already exists"));
let after = fs::read_to_string(&manifest_path).unwrap();
assert_eq!(after, "# preexisting cabin.toml\n");
}
#[test]
fn metadata_prints_valid_json_for_single_package() {
let dir = TempDir::new().expect("tempdir should be created");
let manifest_path = dir.path().join("cabin.toml");
assert_fs::fixture::ChildPath::new(&manifest_path)
.write_str(VALID_MANIFEST)
.unwrap();
let value = run_metadata(&manifest_path);
assert!(value["workspace"].is_null());
let pkg = package_in(&value, "hello");
assert_eq!(pkg["version"], "0.1.0");
assert!(
pkg.get("language").is_none(),
"metadata must not surface a package-level language field; got {pkg}"
);
assert_eq!(pkg["is_root"], true);
assert_eq!(pkg["is_primary"], true);
let targets = pkg["targets"].as_array().unwrap();
assert_eq!(targets.len(), 1);
assert_eq!(targets[0]["name"], "hello");
assert_eq!(targets[0]["kind"], "executable");
}
#[test]
fn invalid_manifest_fails_with_useful_error() {
let dir = TempDir::new().expect("tempdir should be created");
let manifest_path = dir.path().join("cabin.toml");
assert_fs::fixture::ChildPath::new(&manifest_path)
.write_str("[package]\nname = \"x\"\nversion = [\n")
.unwrap();
cabin()
.args(["metadata", "--manifest-path"])
.arg(&manifest_path)
.assert()
.failure()
.stderr(predicate::str::contains("cabin::manifest::parse_error"))
.stderr(predicate::str::contains("could not parse Cabin manifest"));
}
#[test]
fn metadata_round_trips_default_init_template() {
let dir = TempDir::new().expect("tempdir should be created");
cabin()
.current_dir(dir.path())
.args(["init", "--name", "round-trip"])
.assert()
.success();
let value = run_metadata(&dir.path().join("cabin.toml"));
let pkg = package_in(&value, "round-trip");
assert_eq!(pkg["targets"][0]["kind"], "executable");
}
#[test]
fn new_creates_directory_with_manifest_and_main_cc() {
let parent = TempDir::new().expect("tempdir should be created");
let target = parent.path().join("hello");
cabin()
.current_dir(parent.path())
.args(["new", "hello"])
.assert()
.success()
.stdout(predicate::str::contains(
"Created binary (application) `hello` package",
));
assert!(target.is_dir(), "target directory should exist");
let manifest = fs::read_to_string(target.join("cabin.toml")).unwrap();
assert!(manifest.contains("[package]"));
assert!(manifest.contains(r#"name = "hello""#));
assert!(manifest.contains("[target.hello]"));
let main_cc = target.join("src").join("main.cc");
assert!(main_cc.is_file(), "src/main.cc should exist");
let main_contents = fs::read_to_string(&main_cc).unwrap();
assert!(main_contents.contains("int main"));
}
#[test]
fn new_uses_path_component_as_default_name() {
let parent = TempDir::new().expect("tempdir should be created");
let target = parent.path().join("derived-name");
cabin()
.current_dir(parent.path())
.args(["new", "derived-name"])
.assert()
.success();
let manifest = fs::read_to_string(target.join("cabin.toml")).unwrap();
assert!(manifest.contains(r#"name = "derived-name""#));
assert!(manifest.contains("[target.derived-name]"));
}
#[test]
fn new_supports_explicit_name_override() {
let parent = TempDir::new().expect("tempdir should be created");
let target = parent.path().join("dir-name");
cabin()
.current_dir(parent.path())
.args(["new", "dir-name", "--name", "override"])
.assert()
.success();
let manifest = fs::read_to_string(target.join("cabin.toml")).unwrap();
assert!(manifest.contains(r#"name = "override""#));
assert!(manifest.contains("[target.override]"));
}
#[test]
fn new_fails_when_destination_already_exists() {
let parent = TempDir::new().expect("tempdir should be created");
let target = parent.path().join("preexisting");
fs::create_dir(&target).unwrap();
let preexisting_file = target.join("user-file.txt");
assert_fs::fixture::ChildPath::new(&preexisting_file)
.write_str("user content\n")
.unwrap();
cabin()
.current_dir(parent.path())
.args(["new", "preexisting"])
.assert()
.failure()
.stderr(predicate::str::contains("already exists"))
.stderr(predicate::str::contains("cabin init"));
assert!(target.is_dir(), "destination should be left intact");
let after = fs::read_to_string(&preexisting_file).unwrap();
assert_eq!(after, "user content\n");
assert!(
!target.join("cabin.toml").exists(),
"no manifest should have been written"
);
}
#[test]
fn new_rejects_name_with_whitespace() {
let parent = TempDir::new().expect("tempdir should be created");
let target = parent.path().join("bad");
cabin()
.current_dir(parent.path())
.args(["new", "bad", "--name", "foo bar"])
.assert()
.failure()
.stderr(predicate::str::contains("invalid package name"));
assert!(
!target.exists(),
"directory should be cleaned up on validation failure"
);
}
#[test]
fn new_rejects_name_with_unsupported_characters() {
let parent = TempDir::new().expect("tempdir should be created");
let target = parent.path().join("dot-name");
cabin()
.current_dir(parent.path())
.args(["new", "dot-name", "--name", "foo.bar"])
.assert()
.failure()
.stderr(predicate::str::contains("not supported"));
assert!(
!target.exists(),
"directory should be cleaned up on validation failure"
);
}
#[test]
fn new_fails_when_parent_does_not_exist() {
let parent = TempDir::new().expect("tempdir should be created");
let target = parent.path().join("missing").join("hello");
cabin()
.current_dir(parent.path())
.args(["new"])
.arg(&target)
.assert()
.failure()
.stderr(predicate::str::contains("parent directory"))
.stderr(predicate::str::contains("does not exist"));
assert!(!target.exists());
}
#[test]
fn new_does_not_overwrite_existing_user_files_on_failure() {
let parent = TempDir::new().expect("tempdir should be created");
let target = parent.path().join("invalid");
cabin()
.current_dir(parent.path())
.args(["new", "invalid", "--name", "foo bar"])
.assert()
.failure();
assert!(
!target.exists(),
"directory created by new must not survive a failed scaffold"
);
}
#[test]
fn new_and_init_produce_identical_files() {
let init_dir = TempDir::new().expect("tempdir should be created");
cabin()
.current_dir(init_dir.path())
.args(["init", "--name", "twin"])
.assert()
.success();
let new_parent = TempDir::new().expect("tempdir should be created");
let new_target = new_parent.path().join("twin");
cabin()
.current_dir(new_parent.path())
.args(["new", "twin"])
.assert()
.success();
for relative in ["cabin.toml", "src/main.cc"] {
let init_bytes = fs::read(init_dir.path().join(relative)).unwrap();
let new_bytes = fs::read(new_target.join(relative)).unwrap();
assert_eq!(
init_bytes, new_bytes,
"byte mismatch for {relative} between init and new"
);
}
}
#[test]
fn new_generated_manifest_does_not_contain_absolute_paths() {
let parent = TempDir::new().expect("tempdir should be created");
let target = parent.path().join("portable");
cabin()
.current_dir(parent.path())
.args(["new", "portable"])
.assert()
.success();
let manifest = fs::read_to_string(target.join("cabin.toml")).unwrap();
let parent_str = parent.path().to_string_lossy().to_string();
assert!(
!manifest.contains(&*parent_str),
"manifest leaks absolute path: {manifest}"
);
let target_str = target.to_string_lossy().to_string();
assert!(
!manifest.contains(&*target_str),
"manifest leaks absolute path: {manifest}"
);
let main_cc = fs::read_to_string(target.join("src").join("main.cc")).unwrap();
assert!(
!main_cc.contains(&*parent_str),
"main.cc leaks absolute path: {main_cc}"
);
}
#[test]
fn new_help_describes_path_argument() {
let stdout = cabin()
.args(["new", "--help"])
.assert()
.success()
.get_output()
.clone();
let body = String::from_utf8(stdout.stdout).unwrap();
assert!(body.contains("PATH"), "new --help missing PATH:\n{body}");
assert!(
body.contains("--name"),
"new --help missing --name:\n{body}"
);
assert!(body.contains("--bin"), "new --help missing --bin:\n{body}");
assert!(body.contains("--lib"), "new --help missing --lib:\n{body}");
}
#[test]
fn init_help_describes_bin_and_lib_flags() {
let stdout = cabin()
.args(["init", "--help"])
.assert()
.success()
.get_output()
.clone();
let body = String::from_utf8(stdout.stdout).unwrap();
assert!(body.contains("--bin"), "init --help missing --bin:\n{body}");
assert!(body.contains("--lib"), "init --help missing --lib:\n{body}");
}
#[test]
fn new_with_explicit_bin_matches_default() {
let parent = TempDir::new().expect("tempdir should be created");
cabin()
.current_dir(parent.path())
.args(["new", "default-bin"])
.assert()
.success();
cabin()
.current_dir(parent.path())
.args(["new", "explicit-bin", "--bin"])
.assert()
.success();
let default = fs::read(parent.path().join("default-bin/cabin.toml")).unwrap();
let explicit = fs::read(parent.path().join("explicit-bin/cabin.toml")).unwrap();
let _ = default;
let body = String::from_utf8(explicit).unwrap();
assert!(body.contains(r#"type = "executable""#), "{body}");
assert!(
!body.contains(r#"type = "library""#),
"--bin must not produce a library manifest:\n{body}"
);
}
#[test]
fn new_with_lib_generates_library_layout() {
let parent = TempDir::new().expect("tempdir should be created");
let target = parent.path().join("greeter");
cabin()
.current_dir(parent.path())
.args(["new", "greeter", "--lib"])
.assert()
.success()
.stdout(predicate::str::contains(
"Created library `greeter` package",
));
let manifest = fs::read_to_string(target.join("cabin.toml")).unwrap();
assert!(manifest.contains(r#"type = "library""#));
assert!(manifest.contains(r#"sources = ["src/greeter.cc"]"#));
assert!(manifest.contains(r#"include_dirs = ["include"]"#));
let header = fs::read_to_string(target.join("include/greeter/greeter.hpp")).unwrap();
assert!(header.contains("#pragma once"));
assert!(header.contains("namespace greeter"));
let src = fs::read_to_string(target.join("src/greeter.cc")).unwrap();
assert!(src.contains(r#"#include "greeter/greeter.hpp""#));
assert!(src.contains("namespace greeter"));
assert!(
!target.join("src/main.cc").exists(),
"library scaffold must not emit src/main.cc"
);
}
#[test]
fn new_with_bin_and_lib_conflicts() {
let parent = TempDir::new().expect("tempdir should be created");
let target = parent.path().join("either");
cabin()
.current_dir(parent.path())
.args(["new", "either", "--bin", "--lib"])
.assert()
.failure()
.stderr(predicate::str::contains("--bin").and(predicate::str::contains("--lib")));
assert!(
!target.exists(),
"no directory should be created on conflict"
);
}
#[test]
fn init_with_lib_generates_library_layout() {
let dir = TempDir::new().expect("tempdir should be created");
cabin()
.current_dir(dir.path())
.args(["init", "--lib", "--name", "lib-pkg"])
.assert()
.success()
.stdout(predicate::str::contains(
"Created library `lib-pkg` package",
));
let manifest = fs::read_to_string(dir.path().join("cabin.toml")).unwrap();
assert!(manifest.contains(r#"type = "library""#));
assert!(manifest.contains(r#"sources = ["src/lib-pkg.cc"]"#));
assert!(manifest.contains(r#"include_dirs = ["include"]"#));
let header = fs::read_to_string(dir.path().join("include/lib-pkg/lib-pkg.hpp")).unwrap();
assert!(header.contains("namespace lib_pkg"));
let src = fs::read_to_string(dir.path().join("src/lib-pkg.cc")).unwrap();
assert!(src.contains("namespace lib_pkg"));
}
#[test]
fn init_with_bin_explicit_matches_default() {
let dir = TempDir::new().expect("tempdir should be created");
cabin()
.current_dir(dir.path())
.args(["init", "--bin", "--name", "explicit-bin"])
.assert()
.success();
let manifest = fs::read_to_string(dir.path().join("cabin.toml")).unwrap();
assert!(manifest.contains(r#"type = "executable""#));
assert!(dir.path().join("src/main.cc").exists());
}
#[test]
fn init_with_bin_and_lib_conflicts() {
let dir = TempDir::new().expect("tempdir should be created");
cabin()
.current_dir(dir.path())
.args(["init", "--bin", "--lib"])
.assert()
.failure()
.stderr(predicate::str::contains("--bin").and(predicate::str::contains("--lib")));
assert!(
!dir.path().join("cabin.toml").exists(),
"no manifest should be written on conflict"
);
}
#[test]
fn new_generates_gitignore_with_build_and_dist() {
let parent = TempDir::new().expect("tempdir should be created");
let target = parent.path().join("gi");
cabin()
.current_dir(parent.path())
.args(["new", "gi"])
.assert()
.success();
let body = fs::read_to_string(target.join(".gitignore")).unwrap();
assert!(body.contains("/build/"), "missing build/ ignore:\n{body}");
assert!(body.contains("/dist/"), "missing dist/ ignore:\n{body}");
assert!(
!body.contains("cabin.lock"),
".gitignore must not ignore cabin.lock:\n{body}"
);
}
#[test]
fn init_creates_gitignore_when_missing() {
let dir = TempDir::new().expect("tempdir should be created");
cabin()
.current_dir(dir.path())
.args(["init", "--name", "fresh"])
.assert()
.success();
let body = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(body.contains("/build/"), "{body}");
assert!(body.contains("/dist/"), "{body}");
}
#[test]
fn init_preserves_existing_gitignore() {
let dir = TempDir::new().expect("tempdir should be created");
let preexisting = "# user gitignore\nfoo.tmp\n";
assert_fs::fixture::ChildPath::new(dir.path().join(".gitignore"))
.write_str(preexisting)
.unwrap();
cabin()
.current_dir(dir.path())
.args(["init", "--name", "preserve"])
.assert()
.success();
let after = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert_eq!(after, preexisting);
}
#[test]
fn new_lib_with_hyphenated_name_uses_sanitized_namespace() {
let parent = TempDir::new().expect("tempdir should be created");
let target = parent.path().join("two-words");
cabin()
.current_dir(parent.path())
.args(["new", "two-words", "--lib"])
.assert()
.success();
let header = fs::read_to_string(target.join("include/two-words/two-words.hpp")).unwrap();
assert!(header.contains("namespace two_words"));
let src = fs::read_to_string(target.join("src/two-words.cc")).unwrap();
assert!(src.contains("namespace two_words"));
assert!(src.contains(r#"#include "two-words/two-words.hpp""#));
}
#[test]
fn new_lib_generated_files_are_deterministic() {
let parent_a = TempDir::new().expect("tempdir should be created");
let parent_b = TempDir::new().expect("tempdir should be created");
cabin()
.current_dir(parent_a.path())
.args(["new", "twin", "--lib"])
.assert()
.success();
cabin()
.current_dir(parent_b.path())
.args(["new", "twin", "--lib"])
.assert()
.success();
for relative in [
"cabin.toml",
"include/twin/twin.hpp",
"src/twin.cc",
".gitignore",
] {
let a = fs::read(parent_a.path().join("twin").join(relative)).unwrap();
let b = fs::read(parent_b.path().join("twin").join(relative)).unwrap();
assert_eq!(a, b, "byte mismatch for {relative} between two runs");
}
}
#[test]
fn new_lib_metadata_view_reports_library_target() {
let parent = TempDir::new().expect("tempdir should be created");
let target = parent.path().join("metalib");
cabin()
.current_dir(parent.path())
.args(["new", "metalib", "--lib"])
.assert()
.success();
let value = run_metadata(&target.join("cabin.toml"));
let pkg = package_in(&value, "metalib");
assert_eq!(pkg["targets"][0]["kind"], "library");
}
#[test]
fn new_lib_builds_successfully() {
skip_if!(
!build_tools_available(),
"new_lib_builds_successfully",
"ninja or a C++ compiler is not available"
);
let parent = TempDir::new().expect("tempdir should be created");
let target = parent.path().join("buildlib");
cabin()
.current_dir(parent.path())
.args(["new", "buildlib", "--lib"])
.assert()
.success();
let build_dir = target.join("build");
cabin()
.current_dir(&target)
.args(["build", "--build-dir"])
.arg(&build_dir)
.assert()
.success();
let lib_path = build_dir
.join("dev")
.join("packages")
.join("buildlib")
.join("libbuildlib.a");
assert!(
lib_path.is_file(),
"expected library archive at {lib_path:?}"
);
}
#[test]
fn new_bin_builds_successfully() {
skip_if!(
!build_tools_available(),
"new_bin_builds_successfully",
"ninja or a C++ compiler is not available"
);
let parent = TempDir::new().expect("tempdir should be created");
let target = parent.path().join("buildbin");
cabin()
.current_dir(parent.path())
.args(["new", "buildbin"])
.assert()
.success();
let build_dir = target.join("build");
cabin()
.current_dir(&target)
.args(["build", "--build-dir"])
.arg(&build_dir)
.assert()
.success();
let bin_path = build_dir
.join("dev")
.join("packages")
.join("buildbin")
.join("buildbin");
assert!(bin_path.is_file(), "expected executable at {bin_path:?}");
}
#[test]
fn new_bin_runs_and_prints_greeting() {
skip_if!(
!build_tools_available(),
"new_bin_runs_and_prints_greeting",
"ninja or a C++ compiler is not available"
);
let parent = TempDir::new().expect("tempdir should be created");
let target = parent.path().join("hello_world");
cabin()
.current_dir(parent.path())
.args(["new", "hello_world"])
.assert()
.success();
let build_dir = target.join("build");
let output = cabin()
.current_dir(&target)
.args(["run", "--build-dir"])
.arg(&build_dir)
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
stdout.contains("Hello from Cabin"),
"`cabin new` -> `cabin run` should print the scaffold greeting, got: {stdout}"
);
}
#[test]
fn new_verbose_lists_created_files() {
let parent = TempDir::new().expect("tempdir should be created");
let target = parent.path().join("verb");
let output = cabin()
.current_dir(parent.path())
.args(["new", "verb", "--lib", "--verbose"])
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
stdout.contains("cabin: wrote cabin.toml"),
"verbose stdout missing cabin.toml: {stdout}"
);
assert!(
stdout.contains("cabin: wrote include/verb/verb.hpp"),
"verbose stdout missing header path: {stdout}"
);
assert!(
stdout.contains("cabin: wrote src/verb.cc"),
"verbose stdout missing source path: {stdout}"
);
assert!(
stdout.contains("cabin: wrote .gitignore"),
"verbose stdout missing .gitignore: {stdout}"
);
assert!(target.join("cabin.toml").exists());
}
mod verbosity {
use super::*;
fn populate_project(dir: &Path) {
assert_fs::fixture::ChildPath::new(dir.join("cabin.toml"))
.write_str(VALID_MANIFEST)
.unwrap();
assert_fs::fixture::ChildPath::new(dir.join("src/main.cc"))
.write_str(HELLO_MAIN_CC)
.unwrap();
}
fn run_capture(cwd: &Path, args: &[&str]) -> (String, String) {
let output = cabin()
.current_dir(cwd)
.args(args)
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).expect("stdout utf-8");
let stderr = String::from_utf8(output.stderr).expect("stderr utf-8");
(stdout, stderr)
}
#[test]
fn quiet_flag_suppresses_init_status_message() {
let dir = TempDir::new().unwrap();
let (stdout, _) = run_capture(dir.path(), &["init", "--name", "hello", "--quiet"]);
assert!(
!stdout.contains("Created binary"),
"quiet must suppress init status:\n{stdout}"
);
assert!(dir.path().join("cabin.toml").exists());
}
#[test]
fn quiet_flag_suppresses_clean_status_message() {
let dir = TempDir::new().unwrap();
populate_project(dir.path());
let (stdout, _) = run_capture(dir.path(), &["clean", "--quiet"]);
assert!(
stdout.is_empty(),
"quiet must suppress clean status:\n{stdout}"
);
}
#[test]
fn quiet_short_flag_works_the_same() {
let dir = TempDir::new().unwrap();
populate_project(dir.path());
let (stdout, _) = run_capture(dir.path(), &["clean", "-q"]);
assert!(stdout.is_empty(), "stdout should be empty: {stdout}");
}
#[test]
fn quiet_does_not_suppress_errors() {
let dir = TempDir::new().unwrap();
let assertion = cabin()
.current_dir(dir.path())
.args(["clean", "--quiet"])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("error[cabin::workspace::manifest_not_found]")
|| stderr.contains("could not find a Cabin workspace"),
"errors must survive --quiet:\n{stderr}"
);
}
#[test]
fn verbose_flag_adds_build_dir_and_profile_lines_to_build() {
skip_if!(
!ninja_available() || !cxx_compiler_available(),
"verbose_flag_adds_build_dir_and_profile_lines_to_build",
"requires ninja + a C++ compiler"
);
let dir = TempDir::new().unwrap();
dir.child("cabin.toml").write_str(VALID_MANIFEST).unwrap();
dir.child("src/main.cc").write_str(HELLO_MAIN_CC).unwrap();
let build_dir = dir.path().join("build");
let (stdout, _) = run_capture(
dir.path(),
&[
"build",
"--verbose",
"--build-dir",
build_dir.to_str().unwrap(),
],
);
assert!(
stdout.contains("cabin: profile = "),
"verbose must add a profile line:\n{stdout}"
);
assert!(
stdout.contains("cabin: build dir = "),
"verbose must add a build dir line:\n{stdout}"
);
assert!(
stdout.contains("cabin: c++ compiler = "),
"verbose must add a c++ compiler line:\n{stdout}"
);
}
#[test]
fn very_verbose_flag_adds_archiver_line_to_build() {
skip_if!(
!ninja_available() || !cxx_compiler_available(),
"very_verbose_flag_adds_archiver_line_to_build",
"requires ninja + a C++ compiler"
);
let dir = TempDir::new().unwrap();
dir.child("cabin.toml").write_str(VALID_MANIFEST).unwrap();
dir.child("src/main.cc").write_str(HELLO_MAIN_CC).unwrap();
let build_dir = dir.path().join("build");
let (stdout, _) = run_capture(
dir.path(),
&["build", "-vv", "--build-dir", build_dir.to_str().unwrap()],
);
assert!(
stdout.contains("cabin: archiver = "),
"very verbose must add an archiver line:\n{stdout}"
);
}
#[test]
fn repeated_short_verbose_flags_clamp_to_very_verbose() {
skip_if!(
!ninja_available() || !cxx_compiler_available(),
"repeated_short_verbose_flags_clamp_to_very_verbose",
"requires ninja + a C++ compiler"
);
let dir = TempDir::new().unwrap();
dir.child("cabin.toml").write_str(VALID_MANIFEST).unwrap();
dir.child("src/main.cc").write_str(HELLO_MAIN_CC).unwrap();
let build_dir = dir.path().join("build");
let (stdout, _) = run_capture(
dir.path(),
&[
"build",
"-vvvvv",
"--build-dir",
build_dir.to_str().unwrap(),
],
);
assert!(
stdout.contains("cabin: archiver = "),
"five -v should clamp to very verbose:\n{stdout}"
);
}
#[test]
fn separate_verbose_flags_also_count() {
skip_if!(
!ninja_available() || !cxx_compiler_available(),
"separate_verbose_flags_also_count",
"requires ninja + a C++ compiler"
);
let dir = TempDir::new().unwrap();
dir.child("cabin.toml").write_str(VALID_MANIFEST).unwrap();
dir.child("src/main.cc").write_str(HELLO_MAIN_CC).unwrap();
let build_dir = dir.path().join("build");
let (stdout, _) = run_capture(
dir.path(),
&[
"build",
"-v",
"-v",
"--build-dir",
build_dir.to_str().unwrap(),
],
);
assert!(
stdout.contains("cabin: archiver = "),
"`-v -v` should reach very verbose:\n{stdout}"
);
}
#[test]
fn quiet_with_verbose_is_rejected_by_clap() {
let dir = TempDir::new().unwrap();
populate_project(dir.path());
let assertion = cabin()
.current_dir(dir.path())
.args(["clean", "--quiet", "--verbose"])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("cannot be used with"),
"clap must report the conflict:\n{stderr}"
);
}
#[test]
fn resolve_json_stdout_stays_clean_under_verbose() {
let dir = TempDir::new().unwrap();
populate_project(dir.path());
let manifest = dir.path().join("cabin.toml");
let (stdout, _stderr) = run_capture(
dir.path(),
&[
"resolve",
"--manifest-path",
manifest.to_str().unwrap(),
"--format",
"json",
"--verbose",
],
);
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("resolve --format json stdout must parse");
assert!(parsed.is_object(), "resolve returned non-object: {stdout}");
}
#[test]
fn metadata_stdout_stays_clean_under_verbose() {
let dir = TempDir::new().unwrap();
populate_project(dir.path());
let (stdout, _stderr) = run_capture(
dir.path(),
&[
"metadata",
"--manifest-path",
dir.path().join("cabin.toml").to_str().unwrap(),
"--verbose",
],
);
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("metadata stdout must be valid JSON under -v");
assert!(parsed.is_object(), "metadata returned non-object: {stdout}");
}
#[test]
fn env_var_verbose_takes_effect_when_cli_silent() {
skip_if!(
!ninja_available() || !cxx_compiler_available(),
"env_var_verbose_takes_effect_when_cli_silent",
"requires ninja + a C++ compiler"
);
let dir = TempDir::new().unwrap();
dir.child("cabin.toml").write_str(VALID_MANIFEST).unwrap();
dir.child("src/main.cc").write_str(HELLO_MAIN_CC).unwrap();
let build_dir = dir.path().join("build");
let output = cabin()
.current_dir(dir.path())
.env("CABIN_TERM_VERBOSE", "1")
.args(["build", "--build-dir"])
.arg(&build_dir)
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
stdout.contains("cabin: profile = "),
"CABIN_TERM_VERBOSE=1 must enable verbose:\n{stdout}"
);
}
#[test]
fn invalid_env_value_is_rejected_with_actionable_error() {
let dir = TempDir::new().unwrap();
let assertion = cabin()
.current_dir(dir.path())
.env("CABIN_TERM_VERBOSE", "loud")
.args(["init", "--name", "hello"])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("invalid CABIN_TERM_VERBOSE value 'loud'"),
"env error must name the variable and value:\n{stderr}"
);
}
#[test]
fn help_lists_global_verbose_and_quiet_flags() {
let output = cabin()
.args(["--help"])
.assert()
.success()
.get_output()
.clone();
let body = String::from_utf8(output.stdout).unwrap();
assert!(
body.contains("-v") && body.contains("--verbose"),
"top-level help missing -v / --verbose:\n{body}"
);
assert!(
body.contains("-q") && body.contains("--quiet"),
"top-level help missing -q / --quiet:\n{body}"
);
}
#[test]
fn subcommand_help_inherits_verbose_and_quiet_flags() {
for sub in ["build", "clean", "init", "new"] {
let output = cabin()
.args([sub, "--help"])
.assert()
.success()
.get_output()
.clone();
let body = String::from_utf8(output.stdout).unwrap();
assert!(
body.contains("--verbose"),
"{sub} --help missing --verbose:\n{body}"
);
assert!(
body.contains("--quiet"),
"{sub} --help missing --quiet:\n{body}"
);
}
}
}
mod clean_cmd {
use super::*;
fn populate_project(dir: &Path) {
assert_fs::fixture::ChildPath::new(dir.join("cabin.toml"))
.write_str(VALID_MANIFEST)
.unwrap();
assert_fs::fixture::ChildPath::new(dir.join("src/main.cc"))
.write_str(HELLO_MAIN_CC)
.unwrap();
assert_fs::fixture::ChildPath::new(dir.join("cabin.lock"))
.write_str("# lock\n")
.unwrap();
assert_fs::fixture::ChildPath::new(dir.join("vendor/.keep"))
.write_str("")
.unwrap();
assert_fs::fixture::ChildPath::new(
dir.join("build")
.join("dev")
.join("packages")
.join("hello")
.join("hello"),
)
.write_str("obj")
.unwrap();
assert_fs::fixture::ChildPath::new(dir.join("build").join("dev").join("build.ninja"))
.write_str("ninja")
.unwrap();
assert_fs::fixture::ChildPath::new(
dir.join("build")
.join("release")
.join("packages")
.join("hello")
.join("hello"),
)
.write_str("obj")
.unwrap();
}
#[test]
fn clean_removes_build_dir() {
let dir = TempDir::new().unwrap();
populate_project(dir.path());
cabin()
.current_dir(dir.path())
.args(["clean"])
.assert()
.success()
.stdout(predicate::str::contains("Removed"));
assert!(
!dir.path().join("build").exists(),
"build dir should be gone"
);
assert!(dir.path().join("cabin.toml").exists(), "manifest preserved");
assert!(
dir.path().join("src").join("main.cc").exists(),
"src preserved"
);
assert!(dir.path().join("cabin.lock").exists(), "lockfile preserved");
assert!(dir.path().join("vendor").exists(), "vendor preserved");
}
#[test]
fn clean_succeeds_when_build_dir_missing() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml").write_str(VALID_MANIFEST).unwrap();
dir.child("src/main.cc").write_str(HELLO_MAIN_CC).unwrap();
cabin()
.current_dir(dir.path())
.args(["clean"])
.assert()
.success()
.stdout(predicate::str::contains("does not exist"));
}
#[test]
fn clean_dry_run_lists_paths_and_keeps_files() {
let dir = TempDir::new().unwrap();
populate_project(dir.path());
let output = cabin()
.current_dir(dir.path())
.args(["clean", "--dry-run"])
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains("dry-run"));
assert!(stdout.contains("Removed"));
assert!(stdout.contains("/build"));
assert!(
dir.path().join("build").exists(),
"dry run must not delete files"
);
}
#[test]
fn clean_profile_narrows_to_one_profile_dir() {
let dir = TempDir::new().unwrap();
populate_project(dir.path());
cabin()
.current_dir(dir.path())
.args(["clean", "--profile", "release"])
.assert()
.success();
assert!(
!dir.path().join("build").join("release").exists(),
"release tree should be gone"
);
assert!(
dir.path().join("build").join("dev").exists(),
"dev tree must remain"
);
}
#[test]
fn clean_release_alias_matches_profile_release() {
let dir = TempDir::new().unwrap();
populate_project(dir.path());
cabin()
.current_dir(dir.path())
.args(["clean", "--release"])
.assert()
.success();
assert!(!dir.path().join("build").join("release").exists());
assert!(dir.path().join("build").join("dev").exists());
}
#[test]
fn clean_package_targets_per_package_paths_across_profiles() {
let dir = TempDir::new().unwrap();
populate_project(dir.path());
assert_fs::fixture::ChildPath::new(
dir.path()
.join("build")
.join("dev")
.join("packages")
.join("other")
.join("libother.a"),
)
.write_str("obj")
.unwrap();
cabin()
.current_dir(dir.path())
.args(["clean", "-p", "hello"])
.assert()
.success();
assert!(
!dir.path()
.join("build")
.join("dev")
.join("packages")
.join("hello")
.exists()
);
assert!(
!dir.path()
.join("build")
.join("release")
.join("packages")
.join("hello")
.exists()
);
assert!(
dir.path()
.join("build")
.join("dev")
.join("packages")
.join("other")
.exists(),
"non-selected package output must remain"
);
assert!(
dir.path()
.join("build")
.join("dev")
.join("build.ninja")
.exists(),
"profile-level files must remain"
);
}
#[test]
fn clean_profile_and_package_combine() {
let dir = TempDir::new().unwrap();
populate_project(dir.path());
cabin()
.current_dir(dir.path())
.args(["clean", "--profile", "dev", "-p", "hello"])
.assert()
.success();
assert!(
!dir.path()
.join("build")
.join("dev")
.join("packages")
.join("hello")
.exists()
);
assert!(
dir.path()
.join("build")
.join("release")
.join("packages")
.join("hello")
.exists(),
"release tree untouched when profile narrowed to dev"
);
}
#[test]
fn clean_respects_custom_build_dir() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml").write_str(VALID_MANIFEST).unwrap();
dir.child("src/main.cc").write_str(HELLO_MAIN_CC).unwrap();
let custom = dir.path().join("custom-build-dir");
assert_fs::fixture::ChildPath::new(custom.join("dev").join("build.ninja"))
.write_str("x")
.unwrap();
cabin()
.current_dir(dir.path())
.args(["clean", "--build-dir"])
.arg(&custom)
.assert()
.success();
assert!(!custom.exists(), "custom build dir should be removed");
assert!(!dir.path().join("build").exists());
}
#[test]
fn clean_rejects_build_dir_that_contains_source_files() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml").write_str(VALID_MANIFEST).unwrap();
dir.child("src/main.cc").write_str(HELLO_MAIN_CC).unwrap();
cabin()
.current_dir(dir.path())
.args(["clean", "--build-dir", "src"])
.assert()
.failure()
.stderr(predicate::str::contains("source file"));
assert!(
dir.path().join("src").join("main.cc").exists(),
"source file must not be removed"
);
}
#[test]
fn clean_rejects_workspace_root_as_build_dir() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml").write_str(VALID_MANIFEST).unwrap();
dir.child("src/main.cc").write_str(HELLO_MAIN_CC).unwrap();
cabin()
.current_dir(dir.path())
.args(["clean", "--build-dir"])
.arg(dir.path())
.assert()
.failure()
.stderr(predicate::str::contains("refusing to clean"));
assert!(dir.path().join("cabin.toml").exists());
assert!(dir.path().join("src").join("main.cc").exists());
}
fn populate_workspace(dir: &Path) {
assert_fs::fixture::ChildPath::new(dir.join("cabin.toml"))
.write_str("[workspace]\nmembers = [\"hello\", \"util\"]\n")
.unwrap();
assert_fs::fixture::ChildPath::new(dir.join("hello").join("cabin.toml"))
.write_str(
"[package]\n\
name = \"hello\"\n\
version = \"0.1.0\"\n\
\n\
[target.hello]\n\
type = \"executable\"\n\
sources = [\"src/main.cc\"]\n",
)
.unwrap();
assert_fs::fixture::ChildPath::new(dir.join("hello").join("src").join("main.cc"))
.write_str(HELLO_MAIN_CC)
.unwrap();
assert_fs::fixture::ChildPath::new(dir.join("util").join("cabin.toml"))
.write_str(
"[package]\n\
name = \"util\"\n\
version = \"0.1.0\"\n\
\n\
[target.util]\n\
type = \"library\"\n\
sources = [\"src/util.cc\"]\n",
)
.unwrap();
assert_fs::fixture::ChildPath::new(dir.join("util").join("src").join("util.cc"))
.write_str("void util(){}\n")
.unwrap();
for profile in ["dev", "release"] {
for pkg in ["hello", "util"] {
assert_fs::fixture::ChildPath::new(
dir.join("build")
.join(profile)
.join("packages")
.join(pkg)
.join("artifact"),
)
.write_str("x")
.unwrap();
}
}
}
#[test]
fn clean_workspace_with_exclude_skips_excluded_package() {
let dir = TempDir::new().unwrap();
populate_workspace(dir.path());
cabin()
.current_dir(dir.path())
.args(["clean", "--workspace", "--exclude", "hello"])
.assert()
.success();
for profile in ["dev", "release"] {
assert!(
dir.path()
.join("build")
.join(profile)
.join("packages")
.join("hello")
.exists(),
"excluded `hello` output must remain ({profile})"
);
assert!(
!dir.path()
.join("build")
.join(profile)
.join("packages")
.join("util")
.exists(),
"non-excluded `util` output should be removed ({profile})"
);
}
}
#[test]
fn clean_rejects_root_path() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml").write_str(VALID_MANIFEST).unwrap();
dir.child("src/main.cc").write_str(HELLO_MAIN_CC).unwrap();
cabin()
.current_dir(dir.path())
.args(["clean", "--build-dir", "/"])
.assert()
.failure()
.stderr(predicate::str::contains("root path"));
}
#[cfg(unix)]
#[test]
fn clean_rejects_symlink_build_dir() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml").write_str(VALID_MANIFEST).unwrap();
dir.child("src/main.cc").write_str(HELLO_MAIN_CC).unwrap();
let real = dir.path().join("real-build");
let link = dir.path().join("build");
fs::create_dir(&real).unwrap();
std::os::unix::fs::symlink(&real, &link).unwrap();
assert_fs::fixture::ChildPath::new(real.join("dev").join("build.ninja"))
.write_str("x")
.unwrap();
cabin()
.current_dir(dir.path())
.args(["clean"])
.assert()
.failure()
.stderr(predicate::str::contains("symlink"));
assert!(real.exists(), "real build dir untouched");
assert!(link.exists(), "symlink itself untouched");
}
#[test]
fn clean_dry_run_output_is_sorted_and_deterministic() {
let dir = TempDir::new().unwrap();
populate_project(dir.path());
assert_fs::fixture::ChildPath::new(
dir.path()
.join("build")
.join("dev")
.join("packages")
.join("zeta")
.join("libzeta.a"),
)
.write_str("x")
.unwrap();
assert_fs::fixture::ChildPath::new(
dir.path()
.join("build")
.join("dev")
.join("packages")
.join("alpha")
.join("libalpha.a"),
)
.write_str("x")
.unwrap();
let stdout = capture_dry_run(dir.path(), &["clean", "--dry-run"]);
let stdout_again = capture_dry_run(dir.path(), &["clean", "--dry-run"]);
assert_eq!(stdout, stdout_again, "dry-run output must be deterministic");
}
#[test]
fn clean_help_describes_dry_run_and_profile() {
let stdout = cabin()
.args(["clean", "--help"])
.assert()
.success()
.get_output()
.clone();
let body = String::from_utf8(stdout.stdout).unwrap();
for needle in ["--dry-run", "--profile", "--build-dir", "--package"] {
assert!(
body.contains(needle),
"clean --help missing `{needle}`:\n{body}"
);
}
}
fn capture_dry_run(cwd: &Path, args: &[&str]) -> String {
let output = cabin()
.current_dir(cwd)
.args(args)
.assert()
.success()
.get_output()
.clone();
String::from_utf8(output.stdout).unwrap()
}
}
fn build_simple_executable(dir: &Path, extra_args: &[&str]) {
assert_fs::fixture::ChildPath::new(dir.join("cabin.toml"))
.write_str(VALID_MANIFEST)
.unwrap();
assert_fs::fixture::ChildPath::new(dir.join("src/main.cc"))
.write_str(HELLO_MAIN_CC)
.unwrap();
let build_dir = dir.join("build");
let mut cmd = cabin();
cmd.current_dir(dir).arg("build");
cmd.args(extra_args);
cmd.arg("--build-dir").arg(&build_dir);
cmd.assert().success();
}
#[test]
fn build_writes_ninja_and_compile_commands_for_simple_executable() {
skip_if!(
!build_tools_available(),
"build_writes_ninja_and_compile_commands_for_simple_executable",
"ninja or a C++ compiler is not available"
);
let dir = TempDir::new().unwrap();
build_simple_executable(dir.path(), &[]);
let build_dir = dir.path().join("build");
let pkg_dir = build_dir.join("dev").join("packages").join("hello");
assert!(build_dir.join("dev").join("build.ninja").is_file());
assert!(
build_dir
.join("dev")
.join("compile_commands.json")
.is_file()
);
assert!(pkg_dir.join("hello").is_file(), "executable should exist");
let output = std::process::Command::new(pkg_dir.join("hello"))
.output()
.expect("running hello should succeed");
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains("Hello from Cabin"), "got: {stdout}");
}
#[test]
fn compile_commands_json_contains_expected_fields() {
skip_if!(
!build_tools_available(),
"compile_commands_json_contains_expected_fields",
"ninja or a C++ compiler is not available"
);
let dir = TempDir::new().unwrap();
build_simple_executable(dir.path(), &[]);
let cc_path = dir
.path()
.join("build")
.join("dev")
.join("compile_commands.json");
let body = fs::read_to_string(&cc_path).unwrap();
let value: serde_json::Value = serde_json::from_str(&body).expect("must be valid JSON");
let arr = value.as_array().expect("must be array");
assert_eq!(arr.len(), 1);
let entry = &arr[0];
assert!(entry["file"].as_str().unwrap().ends_with("src/main.cc"));
assert!(entry["output"].as_str().unwrap().ends_with("src/main.cc.o"));
let command = entry["command"].as_str().unwrap();
assert!(command.contains("-std=c++17"));
assert!(command.contains("src/main.cc"));
}
#[test]
fn build_links_executable_against_same_package_library() {
skip_if!(
!build_tools_available(),
"build_links_executable_against_same_package_library",
"ninja or a C++ compiler is not available"
);
let dir = TempDir::new().unwrap();
let manifest = r#"[package]
name = "hello"
version = "0.1.0"
[target.greet]
type = "library"
sources = ["src/greet.cc"]
include_dirs = ["include"]
[target.hello]
type = "executable"
sources = ["src/main.cc"]
deps = ["greet"]
"#;
let header = "#pragma once\nvoid greet();\n";
let lib_src = "#include <iostream>\n#include \"greet.h\"\nvoid greet() { std::cout << \"hello from greet\\n\"; }\n";
let main_src = "#include \"greet.h\"\nint main() { greet(); return 0; }\n";
dir.child("cabin.toml").write_str(manifest).unwrap();
dir.child("include/greet.h").write_str(header).unwrap();
dir.child("src/greet.cc").write_str(lib_src).unwrap();
dir.child("src/main.cc").write_str(main_src).unwrap();
let build_dir = dir.path().join("build");
cabin()
.current_dir(dir.path())
.args(["build", "--build-dir"])
.arg(&build_dir)
.assert()
.success();
let pkg_dir = build_dir.join("dev").join("packages").join("hello");
assert!(pkg_dir.join("libgreet.a").is_file());
assert!(pkg_dir.join("hello").is_file());
let output = std::process::Command::new(pkg_dir.join("hello"))
.output()
.expect("running hello should succeed");
assert!(String::from_utf8_lossy(&output.stdout).contains("hello from greet"));
}
#[test]
fn release_flag_changes_compile_commands() {
skip_if!(
!build_tools_available(),
"release_flag_changes_compile_commands",
"ninja or a C++ compiler is not available"
);
let dir = TempDir::new().unwrap();
build_simple_executable(dir.path(), &["--release"]);
let release_dir = dir.path().join("build").join("release");
let body = fs::read_to_string(release_dir.join("compile_commands.json"))
.expect("compile_commands.json should be readable");
assert!(body.contains("-O3"), "expected -O3 in: {body}");
assert!(body.contains("-DNDEBUG"), "expected -DNDEBUG in: {body}");
assert!(!body.contains("-O0"), "did not expect -O0 in: {body}");
let ninja_body = fs::read_to_string(release_dir.join("build.ninja")).unwrap();
assert!(ninja_body.contains("-O3"));
assert!(ninja_body.contains("-DNDEBUG"));
}
#[test]
fn cabin_build_rejects_target_flag_as_unknown_argument() {
cabin()
.args(["build", "--target", "foo"])
.assert()
.failure()
.code(2)
.stderr(predicate::str::contains(
"unexpected argument '--target' found",
));
}
#[test]
fn target_dependency_cycle_fails_with_clear_error() {
let dir = TempDir::new().unwrap();
let manifest = r#"[package]
name = "cyc"
version = "0.1.0"
[target.a]
type = "library"
sources = ["a.cc"]
deps = ["b"]
[target.b]
type = "library"
sources = ["b.cc"]
deps = ["a"]
"#;
dir.child("cabin.toml").write_str(manifest).unwrap();
dir.child("a.cc").write_str("// a\n").unwrap();
dir.child("b.cc").write_str("// b\n").unwrap();
let build_dir = dir.path().join("build");
cabin()
.current_dir(dir.path())
.args(["build", "--build-dir"])
.arg(&build_dir)
.assert()
.failure()
.stderr(predicate::str::contains("dependency cycle"));
}
const GREET_HEADER: &str = "#pragma once\nvoid greet();\n";
const GREET_SRC: &str = "#include <iostream>\n#include \"greet.h\"\nvoid greet() { std::cout << \"hello from greet\\n\"; }\n";
const APP_MAIN: &str = "#include \"greet.h\"\nint main() { greet(); return 0; }\n";
fn write_path_dep_project(root: &Path) {
assert_fs::fixture::ChildPath::new(root.join("greet/cabin.toml"))
.write_str(
r#"[package]
name = "greet"
version = "0.1.0"
[target.greet]
type = "library"
sources = ["src/greet.cc"]
include_dirs = ["include"]
"#,
)
.unwrap();
assert_fs::fixture::ChildPath::new(root.join("greet/include/greet.h"))
.write_str(GREET_HEADER)
.unwrap();
assert_fs::fixture::ChildPath::new(root.join("greet/src/greet.cc"))
.write_str(GREET_SRC)
.unwrap();
assert_fs::fixture::ChildPath::new(root.join("app/cabin.toml"))
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
greet = { path = "../greet" }
[target.app]
type = "executable"
sources = ["src/main.cc"]
deps = ["greet"]
"#,
)
.unwrap();
assert_fs::fixture::ChildPath::new(root.join("app/src/main.cc"))
.write_str(APP_MAIN)
.unwrap();
}
#[test]
fn build_with_local_path_dependency_builds_executable() {
skip_if!(
!build_tools_available(),
"build_with_local_path_dependency_builds_executable",
"ninja or a C++ compiler is not available"
);
let dir = TempDir::new().unwrap();
write_path_dep_project(dir.path());
let build_dir = dir.path().join("build");
cabin()
.args(["build", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--build-dir")
.arg(&build_dir)
.assert()
.success();
let app_pkg_dir = build_dir.join("dev").join("packages").join("app");
let greet_pkg_dir = build_dir.join("dev").join("packages").join("greet");
assert!(build_dir.join("dev").join("build.ninja").is_file());
assert!(
build_dir
.join("dev")
.join("compile_commands.json")
.is_file()
);
assert!(greet_pkg_dir.join("libgreet.a").is_file());
assert!(app_pkg_dir.join("app").is_file(), "app executable missing");
let output = std::process::Command::new(app_pkg_dir.join("app"))
.output()
.expect("running app should succeed");
assert!(String::from_utf8_lossy(&output.stdout).contains("hello from greet"));
}
#[test]
fn metadata_includes_local_path_dependency() {
let dir = TempDir::new().unwrap();
write_path_dep_project(dir.path());
let value = run_metadata(&dir.path().join("app/cabin.toml"));
assert!(value["workspace"].is_null());
let app = package_in(&value, "app");
let greet = package_in(&value, "greet");
let app_deps = app["dependencies"].as_array().unwrap();
assert_eq!(app_deps.len(), 1);
assert_eq!(app_deps[0]["name"], "greet");
assert_eq!(app_deps[0]["kind"], "path");
assert!(app_deps[0]["path"].as_str().unwrap().ends_with("greet"));
assert!(
greet["targets"]
.as_array()
.unwrap()
.iter()
.any(|t| t["name"] == "greet")
);
assert_eq!(app["is_primary"], true);
assert_eq!(greet["is_primary"], false);
}
#[test]
fn compile_commands_includes_dependency_sources() {
skip_if!(
!build_tools_available(),
"compile_commands_includes_dependency_sources",
"ninja or a C++ compiler is not available"
);
let dir = TempDir::new().unwrap();
write_path_dep_project(dir.path());
let build_dir = dir.path().join("build");
cabin()
.args(["build", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--build-dir")
.arg(&build_dir)
.assert()
.success();
let cc: serde_json::Value = serde_json::from_str(
&fs::read_to_string(build_dir.join("dev").join("compile_commands.json")).unwrap(),
)
.unwrap();
let arr = cc.as_array().unwrap();
let files: Vec<String> = arr
.iter()
.map(|e| e["file"].as_str().unwrap().to_owned())
.collect();
assert!(files.iter().any(|f| f.ends_with("greet/src/greet.cc")));
assert!(files.iter().any(|f| f.ends_with("app/src/main.cc")));
}
#[test]
fn workspace_metadata_lists_members() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/*"]
"#,
)
.unwrap();
dir.child("packages/a/cabin.toml")
.write_str(
r#"[package]
name = "a"
version = "0.1.0"
"#,
)
.unwrap();
dir.child("packages/b/cabin.toml")
.write_str(
r#"[package]
name = "b"
version = "0.1.0"
"#,
)
.unwrap();
let value = run_metadata(&dir.path().join("cabin.toml"));
let ws = &value["workspace"];
assert!(!ws.is_null(), "workspace section missing in {value}");
let members = ws["members"].as_array().unwrap();
let names: Vec<&str> = members.iter().map(|m| m.as_str().unwrap()).collect();
assert!(names.contains(&"a"));
assert!(names.contains(&"b"));
}
#[test]
fn package_dependency_cycle_fails_with_clear_error() {
let dir = TempDir::new().unwrap();
dir.child("a/cabin.toml")
.write_str(
r#"[package]
name = "a"
version = "0.1.0"
[dependencies]
b = { path = "../b" }
"#,
)
.unwrap();
dir.child("b/cabin.toml")
.write_str(
r#"[package]
name = "b"
version = "0.1.0"
[dependencies]
a = { path = "../a" }
"#,
)
.unwrap();
cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("a/cabin.toml"))
.assert()
.failure()
.stderr(predicate::str::contains("package dependency cycle"));
}
#[test]
fn duplicate_workspace_package_names_fail() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/*"]
"#,
)
.unwrap();
dir.child("packages/a/cabin.toml")
.write_str(
r#"[package]
name = "shared"
version = "0.1.0"
"#,
)
.unwrap();
dir.child("packages/b/cabin.toml")
.write_str(
r#"[package]
name = "shared"
version = "0.2.0"
"#,
)
.unwrap();
cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.failure()
.stderr(predicate::str::contains("duplicate package name"));
}
#[test]
fn dependency_name_mismatch_fails_with_clear_error() {
let dir = TempDir::new().unwrap();
dir.child("greet/cabin.toml")
.write_str(
r#"[package]
name = "actually-hello"
version = "0.1.0"
"#,
)
.unwrap();
dir.child("app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
greet = { path = "../greet" }
"#,
)
.unwrap();
cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.assert()
.failure()
.stderr(predicate::str::contains(
"dependency aliases are not supported",
));
}
const FMT_INDEX: &str = r#"{
"schema": 1,
"name": "fmt",
"versions": {
"10.2.1": { "dependencies": {}, "yanked": false, "checksum": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" },
"10.1.0": { "dependencies": {}, "yanked": false, "checksum": "sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" }
}
}"#;
const SPDLOG_INDEX: &str = r#"{
"schema": 1,
"name": "spdlog",
"versions": {
"1.13.0": {
"dependencies": { "fmt": ">=10.0.0 <11.0.0" },
"yanked": false,
"checksum": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
}
}"#;
#[test]
fn resolve_succeeds_for_direct_dependency() {
let dir = TempDir::new().unwrap();
write_app_with_dep(dir.path(), r#"fmt = ">=10.0.0 <11.0.0""#);
dir.child("index/fmt.json").write_str(FMT_INDEX).unwrap();
let output = cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(dir.path().join("index"))
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains("Resolved dependencies for app 0.1.0"));
assert!(stdout.contains("fmt 10.2.1"), "stdout: {stdout}");
}
#[test]
fn resolve_emits_valid_json() {
let dir = TempDir::new().unwrap();
write_app_with_dep(dir.path(), r#"fmt = ">=10.0.0 <11.0.0""#);
dir.child("index/fmt.json").write_str(FMT_INDEX).unwrap();
let value = run_json(
cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(dir.path().join("index"))
.args(["--format", "json"]),
);
assert_eq!(value["root"]["name"], "app");
assert_eq!(value["root"]["version"], "0.1.0");
let packages = value["packages"].as_array().unwrap();
assert_eq!(packages.len(), 1);
assert_eq!(packages[0]["name"], "fmt");
assert_eq!(packages[0]["version"], "10.2.1");
assert_eq!(packages[0]["source"], "index");
}
#[test]
fn resolve_handles_transitive_dependency() {
let dir = TempDir::new().unwrap();
write_app_with_dep(dir.path(), r#"spdlog = "^1.13.0""#);
dir.child("index/fmt.json").write_str(FMT_INDEX).unwrap();
dir.child("index/spdlog.json")
.write_str(SPDLOG_INDEX)
.unwrap();
let value = run_json(
cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(dir.path().join("index"))
.args(["--format", "json"]),
);
let names: Vec<&str> = value["packages"]
.as_array()
.unwrap()
.iter()
.map(|p| p["name"].as_str().unwrap())
.collect();
assert!(names.contains(&"spdlog"));
assert!(names.contains(&"fmt"));
}
#[test]
fn resolve_skips_yanked_versions() {
let dir = TempDir::new().unwrap();
write_app_with_dep(dir.path(), r#"fmt = ">=10.0.0 <11.0.0""#);
let yanked_index = r#"{
"schema": 1,
"name": "fmt",
"versions": {
"10.2.1": { "dependencies": {}, "yanked": true },
"10.1.0": { "dependencies": {}, "yanked": false }
}
}"#;
dir.child("index/fmt.json").write_str(yanked_index).unwrap();
let output = cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(dir.path().join("index"))
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains("fmt 10.1.0"), "stdout: {stdout}");
assert!(!stdout.contains("fmt 10.2.1"));
}
#[test]
fn resolve_reports_conflict_clearly() {
let dir = TempDir::new().unwrap();
let manifest = r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = "^10"
spdlog = "*"
"#;
dir.child("app/cabin.toml").write_str(manifest).unwrap();
dir.child("index/fmt.json")
.write_str(
r#"{
"schema": 1,
"name": "fmt",
"versions": { "10.2.1": { "dependencies": {} } }
}"#,
)
.unwrap();
dir.child("index/spdlog.json")
.write_str(
r#"{
"schema": 1,
"name": "spdlog",
"versions": {
"1.0.0": { "dependencies": { "fmt": ">=11, <12" } }
}
}"#,
)
.unwrap();
cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(dir.path().join("index"))
.assert()
.failure()
.stderr(predicate::str::contains("dependency resolution failed"));
}
#[test]
fn resolve_without_index_path_fails_clearly() {
let dir = TempDir::new().unwrap();
write_app_with_dep(dir.path(), r#"fmt = "^10""#);
cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.assert()
.failure()
.stderr(predicate::str::contains("--index-path"));
}
#[test]
fn resolve_missing_package_fails_clearly() {
let dir = TempDir::new().unwrap();
write_app_with_dep(dir.path(), r#"missing-pkg = "^1""#);
dir.child("index/fmt.json")
.write_str(r#"{ "schema": 1, "name": "fmt", "versions": {} }"#)
.unwrap();
cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(dir.path().join("index"))
.assert()
.failure()
.stderr(predicate::str::contains("missing-pkg"));
}
#[test]
fn build_with_versioned_dependency_requires_index_path() {
let dir = TempDir::new().unwrap();
write_app_with_dep(dir.path(), r#"fmt = "^10""#);
dir.child("app/src/main.cc")
.write_str(HELLO_MAIN_CC)
.unwrap();
let manifest = r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = "^10"
[target.app]
type = "executable"
sources = ["src/main.cc"]
"#;
dir.child("app/cabin.toml").write_str(manifest).unwrap();
let build_dir = dir.path().join("build");
cabin()
.args(["build", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--build-dir")
.arg(&build_dir)
.assert()
.failure()
.stderr(predicate::str::contains("--index-path"));
}
#[test]
fn metadata_records_versioned_dependency() {
let dir = TempDir::new().unwrap();
write_app_with_dep(dir.path(), r#"fmt = ">=10.0.0 <11.0.0""#);
let value = run_metadata(&dir.path().join("app/cabin.toml"));
let app = package_in(&value, "app");
let deps = app["dependencies"].as_array().unwrap();
assert_eq!(deps.len(), 1);
assert_eq!(deps[0]["name"], "fmt");
assert_eq!(deps[0]["kind"], "version");
assert!(
deps[0]["requirement"]
.as_str()
.unwrap()
.contains(">=10.0.0")
);
}
#[test]
fn resolve_with_no_versioned_deps_succeeds() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "alone"
version = "0.1.0"
"#,
)
.unwrap();
let value = run_json(
cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--format", "json"]),
);
assert_eq!(value["root"]["name"], "alone");
assert_eq!(value["packages"].as_array().unwrap().len(), 0);
}
const FMT_INDEX_TWO_VERSIONS: &str = r#"{
"schema": 1,
"name": "fmt",
"versions": {
"10.2.0": { "dependencies": {}, "yanked": false, "checksum": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" },
"10.1.0": { "dependencies": {}, "yanked": false, "checksum": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" }
}
}"#;
const FMT_INDEX_OLDER_ONLY: &str = r#"{
"schema": 1,
"name": "fmt",
"versions": {
"10.1.0": { "dependencies": {}, "yanked": false, "checksum": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" }
}
}"#;
fn write_app_with_dep(dir: &Path, dep: &str) {
assert_fs::fixture::ChildPath::new(dir.join("app/cabin.toml"))
.write_str(&format!(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
{dep}
"#
))
.unwrap();
}
#[test]
fn resolve_writes_lockfile() {
let dir = TempDir::new().unwrap();
write_app_with_dep(dir.path(), r#"fmt = ">=10.0.0 <11.0.0""#);
dir.child("index/fmt.json")
.write_str(FMT_INDEX_TWO_VERSIONS)
.unwrap();
cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(dir.path().join("index"))
.assert()
.success();
let lock_path = dir.path().join("app/cabin.lock");
assert!(lock_path.is_file(), "cabin.lock should exist");
let body = fs::read_to_string(&lock_path).unwrap();
assert!(body.contains("version = 1"));
assert!(body.contains(r#"name = "fmt""#));
assert!(body.contains(r#"version = "10.2.0""#));
assert!(body.contains(r#"source = "index""#));
}
#[test]
fn resolve_is_deterministic_across_runs() {
let dir = TempDir::new().unwrap();
write_app_with_dep(dir.path(), r#"fmt = ">=10.0.0 <11.0.0""#);
dir.child("index/fmt.json")
.write_str(FMT_INDEX_TWO_VERSIONS)
.unwrap();
let manifest = dir.path().join("app/cabin.toml");
let index = dir.path().join("index");
cabin()
.args(["resolve", "--manifest-path"])
.arg(&manifest)
.arg("--index-path")
.arg(&index)
.assert()
.success();
let first = fs::read_to_string(dir.path().join("app/cabin.lock")).unwrap();
cabin()
.args(["resolve", "--manifest-path"])
.arg(&manifest)
.arg("--index-path")
.arg(&index)
.assert()
.success();
let second = fs::read_to_string(dir.path().join("app/cabin.lock")).unwrap();
assert_eq!(first, second);
}
#[test]
fn resolve_prefers_existing_lockfile() {
let dir = TempDir::new().unwrap();
write_app_with_dep(dir.path(), r#"fmt = ">=10.0.0 <11.0.0""#);
let index_dir = dir.path().join("index");
assert_fs::fixture::ChildPath::new(index_dir.join("fmt.json"))
.write_str(FMT_INDEX_OLDER_ONLY)
.unwrap();
let manifest = dir.path().join("app/cabin.toml");
cabin()
.args(["resolve", "--manifest-path"])
.arg(&manifest)
.arg("--index-path")
.arg(&index_dir)
.assert()
.success();
assert_fs::fixture::ChildPath::new(index_dir.join("fmt.json"))
.write_str(FMT_INDEX_TWO_VERSIONS)
.unwrap();
cabin()
.args(["resolve", "--manifest-path"])
.arg(&manifest)
.arg("--index-path")
.arg(&index_dir)
.assert()
.success();
let body = fs::read_to_string(dir.path().join("app/cabin.lock")).unwrap();
assert!(
body.contains(r#"version = "10.1.0""#),
"lockfile body: {body}"
);
assert!(!body.contains(r#"version = "10.2.0""#));
}
#[test]
fn cabin_update_refreshes_lockfile() {
let dir = TempDir::new().unwrap();
write_app_with_dep(dir.path(), r#"fmt = ">=10.0.0 <11.0.0""#);
let index_dir = dir.path().join("index");
assert_fs::fixture::ChildPath::new(index_dir.join("fmt.json"))
.write_str(FMT_INDEX_OLDER_ONLY)
.unwrap();
let manifest = dir.path().join("app/cabin.toml");
cabin()
.args(["resolve", "--manifest-path"])
.arg(&manifest)
.arg("--index-path")
.arg(&index_dir)
.assert()
.success();
let before = fs::read_to_string(dir.path().join("app/cabin.lock")).unwrap();
assert!(before.contains(r#"version = "10.1.0""#));
assert_fs::fixture::ChildPath::new(index_dir.join("fmt.json"))
.write_str(FMT_INDEX_TWO_VERSIONS)
.unwrap();
cabin()
.args(["update", "--manifest-path"])
.arg(&manifest)
.arg("--index-path")
.arg(&index_dir)
.assert()
.success();
let after = fs::read_to_string(dir.path().join("app/cabin.lock")).unwrap();
assert!(after.contains(r#"version = "10.2.0""#), "after: {after}");
}
#[test]
fn locked_fails_when_lockfile_missing() {
let dir = TempDir::new().unwrap();
write_app_with_dep(dir.path(), r#"fmt = ">=10.0.0 <11.0.0""#);
dir.child("index/fmt.json")
.write_str(FMT_INDEX_TWO_VERSIONS)
.unwrap();
cabin()
.args(["resolve", "--locked", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(dir.path().join("index"))
.assert()
.failure()
.stderr(predicate::str::contains("--locked"))
.stderr(predicate::str::contains("does not exist"));
}
#[test]
fn locked_succeeds_when_lockfile_is_current() {
let dir = TempDir::new().unwrap();
write_app_with_dep(dir.path(), r#"fmt = ">=10.0.0 <11.0.0""#);
dir.child("index/fmt.json")
.write_str(FMT_INDEX_TWO_VERSIONS)
.unwrap();
let manifest = dir.path().join("app/cabin.toml");
let index = dir.path().join("index");
cabin()
.args(["resolve", "--manifest-path"])
.arg(&manifest)
.arg("--index-path")
.arg(&index)
.assert()
.success();
let snapshot = fs::read_to_string(dir.path().join("app/cabin.lock")).unwrap();
cabin()
.args(["resolve", "--locked", "--manifest-path"])
.arg(&manifest)
.arg("--index-path")
.arg(&index)
.assert()
.success();
let after = fs::read_to_string(dir.path().join("app/cabin.lock")).unwrap();
assert_eq!(snapshot, after);
}
#[test]
fn locked_fails_when_lockfile_is_stale() {
let dir = TempDir::new().unwrap();
write_app_with_dep(dir.path(), r#"fmt = ">=10.0.0 <11.0.0""#);
dir.child("index/fmt.json")
.write_str(FMT_INDEX_OLDER_ONLY)
.unwrap();
let manifest = dir.path().join("app/cabin.toml");
let index = dir.path().join("index");
cabin()
.args(["resolve", "--manifest-path"])
.arg(&manifest)
.arg("--index-path")
.arg(&index)
.assert()
.success();
write_app_with_dep(dir.path(), r#"fmt = ">=10.2.0 <11.0.0""#);
dir.child("index/fmt.json")
.write_str(FMT_INDEX_TWO_VERSIONS)
.unwrap();
cabin()
.args(["resolve", "--locked", "--manifest-path"])
.arg(&manifest)
.arg("--index-path")
.arg(&index)
.assert()
.failure()
.stderr(predicate::str::contains("does not satisfy"));
}
#[test]
fn frozen_does_not_write_lockfile() {
let dir = TempDir::new().unwrap();
write_app_with_dep(dir.path(), r#"fmt = ">=10.0.0 <11.0.0""#);
dir.child("index/fmt.json")
.write_str(FMT_INDEX_TWO_VERSIONS)
.unwrap();
cabin()
.args(["resolve", "--frozen", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(dir.path().join("index"))
.assert()
.failure();
assert!(
!dir.path().join("app/cabin.lock").exists(),
"cabin.lock must not be created in --frozen mode"
);
}
#[test]
fn resolve_conflict_renders_diagnostic_with_code_and_packages() {
let dir = TempDir::new().unwrap();
assert_fs::fixture::ChildPath::new(dir.path().join("app/cabin.toml"))
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
a = "^1"
b = "^1"
"#,
)
.unwrap();
dir.child("index/a.json")
.write_str(
r#"{
"schema": 1,
"name": "a",
"versions": {
"1.0.0": { "dependencies": {}, "yanked": false }
}
}"#,
)
.unwrap();
dir.child("index/b.json")
.write_str(
r#"{
"schema": 1,
"name": "b",
"versions": {
"1.0.0": { "dependencies": { "a": "^2" }, "yanked": false }
}
}"#,
)
.unwrap();
cabin()
.args(["resolve", "--color", "never", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(dir.path().join("index"))
.assert()
.failure()
.stderr(predicate::str::contains("cabin::resolver::error"))
.stderr(predicate::str::contains("a"))
.stderr(predicate::str::contains("b"));
}
#[test]
fn locked_failure_diagnostic_keeps_targeted_message_and_code() {
let dir = TempDir::new().unwrap();
write_app_with_dep(dir.path(), r#"fmt = ">=10.0.0 <11.0.0""#);
dir.child("index/fmt.json")
.write_str(FMT_INDEX_OLDER_ONLY)
.unwrap();
let manifest = dir.path().join("app/cabin.toml");
let index = dir.path().join("index");
cabin()
.args(["resolve", "--manifest-path"])
.arg(&manifest)
.arg("--index-path")
.arg(&index)
.assert()
.success();
write_app_with_dep(dir.path(), r#"fmt = ">=10.2.0 <11.0.0""#);
dir.child("index/fmt.json")
.write_str(FMT_INDEX_TWO_VERSIONS)
.unwrap();
cabin()
.args(["resolve", "--locked", "--color", "never", "--manifest-path"])
.arg(&manifest)
.arg("--index-path")
.arg(&index)
.assert()
.failure()
.stderr(predicate::str::contains("cabin::resolver::error"))
.stderr(predicate::str::contains("does not satisfy"))
.stderr(predicate::str::contains("cabin update"));
}
#[test]
fn metadata_includes_lockfile_when_present() {
let dir = TempDir::new().unwrap();
write_app_with_dep(dir.path(), r#"fmt = ">=10.0.0 <11.0.0""#);
dir.child("index/fmt.json")
.write_str(FMT_INDEX_TWO_VERSIONS)
.unwrap();
let manifest = dir.path().join("app/cabin.toml");
cabin()
.args(["resolve", "--manifest-path"])
.arg(&manifest)
.arg("--index-path")
.arg(dir.path().join("index"))
.assert()
.success();
let value = run_metadata(&manifest);
let lockfile = &value["lockfile"];
assert!(!lockfile.is_null(), "metadata should include lockfile");
assert_eq!(lockfile["version"], 1);
let pkgs = lockfile["packages"].as_array().unwrap();
assert_eq!(pkgs.len(), 1);
assert_eq!(pkgs[0]["name"], "fmt");
assert_eq!(pkgs[0]["version"], "10.2.0");
assert_eq!(pkgs[0]["source"], "index");
}
#[test]
fn metadata_lockfile_field_is_null_when_no_lockfile() {
let dir = TempDir::new().unwrap();
write_app_with_dep(dir.path(), r#"fmt = ">=10.0.0 <11.0.0""#);
let value = run_metadata(&dir.path().join("app/cabin.toml"));
assert!(value["lockfile"].is_null());
}
#[test]
fn resolve_json_format_still_emits_valid_json_with_lockfile() {
let dir = TempDir::new().unwrap();
write_app_with_dep(dir.path(), r#"fmt = ">=10.0.0 <11.0.0""#);
dir.child("index/fmt.json")
.write_str(FMT_INDEX_TWO_VERSIONS)
.unwrap();
let value = run_json(
cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(dir.path().join("index"))
.args(["--format", "json"]),
);
assert_eq!(value["root"]["name"], "app");
let pkgs = value["packages"].as_array().unwrap();
assert_eq!(pkgs.len(), 1);
assert_eq!(pkgs[0]["name"], "fmt");
}
#[test]
fn cabin_update_with_unknown_package_errors() {
let dir = TempDir::new().unwrap();
write_app_with_dep(dir.path(), r#"fmt = ">=10.0.0 <11.0.0""#);
dir.child("index/fmt.json")
.write_str(FMT_INDEX_TWO_VERSIONS)
.unwrap();
cabin()
.args(["update", "--package", "nope", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(dir.path().join("index"))
.assert()
.failure()
.stderr(predicate::str::contains("nope"));
}
mod artifact_fetch {
use super::*;
use flate2::Compression;
use flate2::write::GzEncoder;
use sha2::Digest;
use std::fs::File;
use std::io::Write;
fn manifest_for(name: &str, version: &str, deps: &[(&str, &str)]) -> String {
use std::fmt::Write as _;
let mut out = String::new();
writeln!(out, "[package]\nname = \"{name}\"\nversion = \"{version}\"").unwrap();
if !deps.is_empty() {
out.push_str("\n[dependencies]\n");
for (name, req) in deps {
writeln!(out, "{name} = \"{req}\"").unwrap();
}
}
out
}
fn make_archive_with_raw_name(
path: &Path,
raw_name: &str,
entry_type: tar::EntryType,
body: &[u8],
) -> String {
if let Some(parent) = path.parent() {
assert_fs::fixture::ChildPath::new(parent)
.create_dir_all()
.unwrap();
}
let f = File::create(path).unwrap();
let enc = GzEncoder::new(f, Compression::default());
let mut builder = tar::Builder::new(enc);
let mut header = tar::Header::new_old();
header.set_size(body.len() as u64);
header.set_mode(0o644);
header.set_entry_type(entry_type);
{
let bytes = raw_name.as_bytes();
let old = header.as_old_mut();
for b in &mut old.name[..] {
*b = 0;
}
let n = bytes.len().min(old.name.len());
old.name[..n].copy_from_slice(&bytes[..n]);
}
header.set_cksum();
builder.append(&header, body).unwrap();
let enc = builder.into_inner().unwrap();
enc.finish().unwrap().flush().unwrap();
sha256_hex(path)
}
fn sha256_hex(path: &Path) -> String {
let bytes = fs::read(path).unwrap();
let mut hasher = sha2::Sha256::new();
hasher.update(&bytes);
cabin_core::hash::hex_digest(&hasher.finalize())
}
fn fmt_archive_entries() -> Vec<(&'static str, &'static str)> {
vec![
("cabin.toml", FMT_PKG_MANIFEST),
("include/fmt.h", FMT_HEADER),
("src/fmt.cc", FMT_SRC),
]
}
const FMT_PKG_MANIFEST: &str = r#"[package]
name = "fmt"
version = "10.2.1"
[target.fmt]
type = "library"
sources = ["src/fmt.cc"]
include_dirs = ["include"]
"#;
const FMT_HEADER: &str = "#pragma once\nvoid say_hello();\n";
const FMT_SRC: &str = "#include <iostream>\n#include \"fmt.h\"\nvoid say_hello() { std::cout << \"hello from fmt\\n\"; }\n";
const APP_MAIN: &str = "#include \"fmt.h\"\nint main() { say_hello(); return 0; }\n";
fn write_app_using_fmt(dir: &Path) {
let manifest = r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=10.0.0 <11.0.0"
[target.app]
type = "executable"
sources = ["src/main.cc"]
deps = ["fmt"]
"#;
assert_fs::fixture::ChildPath::new(dir.join("app/cabin.toml"))
.write_str(manifest)
.unwrap();
assert_fs::fixture::ChildPath::new(dir.join("app/src/main.cc"))
.write_str(APP_MAIN)
.unwrap();
}
#[test]
fn fetch_extracts_registry_package_into_cache() {
let dir = TempDir::new().unwrap();
write_app_using_fmt(dir.path());
let archive = dir.path().join("artifacts/fmt-10.2.1.tar.gz");
let hex = make_archive(&archive, &fmt_archive_entries());
write_index_entry(
&dir.path().join("index"),
"fmt",
"10.2.1",
"{}",
&hex,
"../artifacts/fmt-10.2.1.tar.gz",
);
let cache = dir.path().join("cache");
cabin()
.args(["fetch", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(dir.path().join("index"))
.arg("--cache-dir")
.arg(&cache)
.assert()
.success();
let lock_path = dir.path().join("app/cabin.lock");
assert!(lock_path.is_file(), "cabin.lock should exist");
let lock_body = fs::read_to_string(&lock_path).unwrap();
assert!(lock_body.contains(r#"name = "fmt""#));
assert!(lock_body.contains(&format!("checksum = \"sha256:{hex}\"")));
let archive_in_cache = cache.join("archives/sha256").join(format!("{hex}.tar.gz"));
assert!(archive_in_cache.is_file(), "archive should be cached");
let source_in_cache = cache.join("sources/sha256").join(&hex);
assert!(source_in_cache.join("cabin.toml").is_file());
}
#[test]
fn fetch_emits_json_when_requested() {
let dir = TempDir::new().unwrap();
write_app_using_fmt(dir.path());
let archive = dir.path().join("artifacts/fmt-10.2.1.tar.gz");
let hex = make_archive(&archive, &fmt_archive_entries());
write_index_entry(
&dir.path().join("index"),
"fmt",
"10.2.1",
"{}",
&hex,
"../artifacts/fmt-10.2.1.tar.gz",
);
let cache = dir.path().join("cache");
let value = run_json(
cabin()
.args(["fetch", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(dir.path().join("index"))
.arg("--cache-dir")
.arg(&cache)
.args(["--format", "json"]),
);
let pkgs = value["packages"].as_array().unwrap();
assert_eq!(pkgs.len(), 1);
assert_eq!(pkgs[0]["name"], "fmt");
assert_eq!(pkgs[0]["version"], "10.2.1");
assert_eq!(pkgs[0]["checksum"], format!("sha256:{hex}"));
}
#[test]
fn build_links_against_registry_package() {
skip_if!(
!build_tools_available(),
"build_links_against_registry_package",
"ninja or a C++ compiler is not available"
);
let dir = TempDir::new().unwrap();
write_app_using_fmt(dir.path());
let archive = dir.path().join("artifacts/fmt-10.2.1.tar.gz");
let hex = make_archive(&archive, &fmt_archive_entries());
write_index_entry(
&dir.path().join("index"),
"fmt",
"10.2.1",
"{}",
&hex,
"../artifacts/fmt-10.2.1.tar.gz",
);
let build_dir = dir.path().join("build");
let cache = dir.path().join("cache");
cabin()
.args(["build", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(dir.path().join("index"))
.arg("--cache-dir")
.arg(&cache)
.arg("--build-dir")
.arg(&build_dir)
.assert()
.success();
assert!(build_dir.join("dev").join("build.ninja").is_file());
assert!(
build_dir
.join("dev")
.join("compile_commands.json")
.is_file()
);
let exe = build_dir.join("dev/packages/app/app");
assert!(exe.is_file(), "executable should exist at {exe:?}");
let output = std::process::Command::new(&exe).output().unwrap();
assert!(String::from_utf8_lossy(&output.stdout).contains("hello from fmt"));
}
#[test]
fn build_handles_transitive_registry_dependency() {
skip_if!(
!build_tools_available(),
"build_handles_transitive_registry_dependency",
"ninja or a C++ compiler is not available"
);
let dir = TempDir::new().unwrap();
let app_manifest = r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
spdlog = ">=1.0.0 <2.0.0"
[target.app]
type = "executable"
sources = ["src/main.cc"]
deps = ["spdlog"]
"#;
let app_main = "#include \"spdlog.h\"\nint main() { log_hello(); return 0; }\n";
dir.child("app/cabin.toml").write_str(app_manifest).unwrap();
dir.child("app/src/main.cc").write_str(app_main).unwrap();
let fmt_archive = dir.path().join("artifacts/fmt-10.2.1.tar.gz");
let fmt_hex = make_archive(&fmt_archive, &fmt_archive_entries());
let spdlog_manifest = r#"[package]
name = "spdlog"
version = "1.13.0"
[dependencies]
fmt = ">=10.0.0 <11.0.0"
[target.spdlog]
type = "library"
sources = ["src/spdlog.cc"]
include_dirs = ["include"]
deps = ["fmt"]
"#;
let spdlog_header = "#pragma once\nvoid log_hello();\n";
let spdlog_src =
"#include \"spdlog.h\"\n#include \"fmt.h\"\nvoid log_hello() { say_hello(); }\n";
let spdlog_archive = dir.path().join("artifacts/spdlog-1.13.0.tar.gz");
let spdlog_hex = make_archive(
&spdlog_archive,
&[
("cabin.toml", spdlog_manifest),
("include/spdlog.h", spdlog_header),
("src/spdlog.cc", spdlog_src),
],
);
write_index_entry(
&dir.path().join("index"),
"fmt",
"10.2.1",
"{}",
&fmt_hex,
"../artifacts/fmt-10.2.1.tar.gz",
);
write_index_entry(
&dir.path().join("index"),
"spdlog",
"1.13.0",
r#"{ "fmt": ">=10.0.0 <11.0.0" }"#,
&spdlog_hex,
"../artifacts/spdlog-1.13.0.tar.gz",
);
let build_dir = dir.path().join("build");
let cache = dir.path().join("cache");
cabin()
.args(["build", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(dir.path().join("index"))
.arg("--cache-dir")
.arg(&cache)
.arg("--build-dir")
.arg(&build_dir)
.assert()
.success();
assert!(
cache
.join("sources/sha256")
.join(&fmt_hex)
.join("cabin.toml")
.is_file()
);
assert!(
cache
.join("sources/sha256")
.join(&spdlog_hex)
.join("cabin.toml")
.is_file()
);
assert!(build_dir.join("dev/packages/fmt/libfmt.a").is_file());
assert!(build_dir.join("dev/packages/spdlog/libspdlog.a").is_file());
assert!(build_dir.join("dev/packages/app/app").is_file());
}
#[test]
fn fetch_fails_on_checksum_mismatch() {
let dir = TempDir::new().unwrap();
write_app_using_fmt(dir.path());
let archive = dir.path().join("artifacts/fmt-10.2.1.tar.gz");
make_archive(&archive, &fmt_archive_entries());
let bogus_hex = "0".repeat(64);
write_index_entry(
&dir.path().join("index"),
"fmt",
"10.2.1",
"{}",
&bogus_hex,
"../artifacts/fmt-10.2.1.tar.gz",
);
let cache = dir.path().join("cache");
cabin()
.args(["fetch", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(dir.path().join("index"))
.arg("--cache-dir")
.arg(&cache)
.assert()
.failure()
.stderr(predicate::str::contains("checksum mismatch"));
}
#[test]
fn fetch_rejects_unsafe_archive() {
let dir = TempDir::new().unwrap();
write_app_using_fmt(dir.path());
let archive = dir.path().join("artifacts/fmt-10.2.1.tar.gz");
let hex =
make_archive_with_raw_name(&archive, "../escape.txt", tar::EntryType::Regular, b"evil");
write_index_entry(
&dir.path().join("index"),
"fmt",
"10.2.1",
"{}",
&hex,
"../artifacts/fmt-10.2.1.tar.gz",
);
let cache = dir.path().join("cache");
cabin()
.args(["fetch", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(dir.path().join("index"))
.arg("--cache-dir")
.arg(&cache)
.assert()
.failure()
.stderr(predicate::str::contains("unsafe archive entry"));
assert!(!dir.path().join("escape.txt").exists());
}
#[test]
fn fetch_fails_when_index_has_no_source() {
let dir = TempDir::new().unwrap();
write_app_using_fmt(dir.path());
let archive = dir.path().join("artifacts/fmt-10.2.1.tar.gz");
let hex = make_archive(&archive, &fmt_archive_entries());
write_index_entry_no_source(&dir.path().join("index"), "fmt", "10.2.1", &hex);
let cache = dir.path().join("cache");
cabin()
.args(["fetch", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(dir.path().join("index"))
.arg("--cache-dir")
.arg(&cache)
.assert()
.failure()
.stderr(predicate::str::contains("no source artifact"));
}
#[test]
fn frozen_uses_cache_after_initial_fetch() {
let dir = TempDir::new().unwrap();
write_app_using_fmt(dir.path());
let archive = dir.path().join("artifacts/fmt-10.2.1.tar.gz");
let hex = make_archive(&archive, &fmt_archive_entries());
write_index_entry(
&dir.path().join("index"),
"fmt",
"10.2.1",
"{}",
&hex,
"../artifacts/fmt-10.2.1.tar.gz",
);
let cache = dir.path().join("cache");
cabin()
.args(["fetch", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(dir.path().join("index"))
.arg("--cache-dir")
.arg(&cache)
.assert()
.success();
fs::remove_file(&archive).unwrap();
cabin()
.args(["fetch", "--frozen", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(dir.path().join("index"))
.arg("--cache-dir")
.arg(&cache)
.assert()
.success();
}
#[test]
fn frozen_fails_on_cache_miss() {
let dir = TempDir::new().unwrap();
write_app_using_fmt(dir.path());
let archive = dir.path().join("artifacts/fmt-10.2.1.tar.gz");
let hex = make_archive(&archive, &fmt_archive_entries());
write_index_entry(
&dir.path().join("index"),
"fmt",
"10.2.1",
"{}",
&hex,
"../artifacts/fmt-10.2.1.tar.gz",
);
cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(dir.path().join("index"))
.assert()
.success();
let empty_cache = dir.path().join("empty-cache");
cabin()
.args(["fetch", "--frozen", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(dir.path().join("index"))
.arg("--cache-dir")
.arg(&empty_cache)
.assert()
.failure()
.stderr(predicate::str::contains("--frozen"))
.stderr(predicate::str::contains("not cached"));
}
#[test]
fn frozen_does_not_write_lockfile_or_cache() {
let dir = TempDir::new().unwrap();
write_app_using_fmt(dir.path());
let archive = dir.path().join("artifacts/fmt-10.2.1.tar.gz");
let hex = make_archive(&archive, &fmt_archive_entries());
write_index_entry(
&dir.path().join("index"),
"fmt",
"10.2.1",
"{}",
&hex,
"../artifacts/fmt-10.2.1.tar.gz",
);
let cache = dir.path().join("cache");
cabin()
.args(["fetch", "--frozen", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(dir.path().join("index"))
.arg("--cache-dir")
.arg(&cache)
.assert()
.failure();
assert!(!dir.path().join("app/cabin.lock").exists());
let archive_in_cache = cache.join("archives/sha256").join(format!("{hex}.tar.gz"));
assert!(!archive_in_cache.exists());
}
#[test]
fn fetch_fails_when_archive_manifest_disagrees() {
let dir = TempDir::new().unwrap();
write_app_using_fmt(dir.path());
let mut entries = fmt_archive_entries();
entries[0].1 = r#"[package]
name = "fmt"
version = "10.1.0"
"#;
let archive = dir.path().join("artifacts/fmt-10.2.1.tar.gz");
let hex = make_archive(&archive, &entries);
write_index_entry(
&dir.path().join("index"),
"fmt",
"10.2.1",
"{}",
&hex,
"../artifacts/fmt-10.2.1.tar.gz",
);
let cache = dir.path().join("cache");
cabin()
.args(["fetch", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(dir.path().join("index"))
.arg("--cache-dir")
.arg(&cache)
.assert()
.failure()
.stderr(predicate::str::contains("contains package"));
}
#[test]
fn fetch_with_no_versioned_deps_succeeds() {
let dir = TempDir::new().unwrap();
assert_fs::fixture::ChildPath::new(dir.path().join("cabin.toml"))
.write_str(&manifest_for("solo", "0.1.0", &[]))
.unwrap();
cabin()
.args(["fetch", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.success()
.stdout(predicate::str::contains("(no registry dependencies"));
}
#[test]
fn build_uses_separate_cache_dir_when_specified() {
let dir = TempDir::new().unwrap();
write_app_using_fmt(dir.path());
let archive = dir.path().join("artifacts/fmt-10.2.1.tar.gz");
let hex = make_archive(&archive, &fmt_archive_entries());
write_index_entry(
&dir.path().join("index"),
"fmt",
"10.2.1",
"{}",
&hex,
"../artifacts/fmt-10.2.1.tar.gz",
);
let cache = dir.path().join("alt-cache");
cabin()
.args(["fetch", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(dir.path().join("index"))
.arg("--cache-dir")
.arg(&cache)
.assert()
.success();
assert!(
cache
.join("archives/sha256")
.join(format!("{hex}.tar.gz"))
.is_file()
);
assert!(!dir.path().join("app/.cabin/cache").exists());
}
}
mod package_archive {
use super::*;
use flate2::read::GzDecoder;
use std::collections::BTreeSet;
fn write_simple_package(root: &Path) {
assert_fs::fixture::ChildPath::new(root.join("cabin.toml"))
.write_str(
r#"[package]
name = "fmt"
version = "10.2.1"
[target.fmt]
type = "library"
sources = ["src/fmt.cc"]
include_dirs = ["include"]
"#,
)
.unwrap();
assert_fs::fixture::ChildPath::new(root.join("include/example.h"))
.write_str("#pragma once\nvoid say_hello();\n")
.unwrap();
assert_fs::fixture::ChildPath::new(root.join("src/fmt.cc"))
.write_str("#include \"example.h\"\nvoid say_hello() {}\n")
.unwrap();
}
fn read_archive_entries(archive: &Path) -> BTreeSet<String> {
let f = fs::File::open(archive).unwrap();
let dec = GzDecoder::new(f);
let mut tar = tar::Archive::new(dec);
let mut out = BTreeSet::new();
for entry in tar.entries().unwrap() {
let entry = entry.unwrap();
out.insert(entry.path().unwrap().to_string_lossy().into_owned());
}
out
}
#[test]
fn package_creates_archive_and_metadata() {
let dir = TempDir::new().unwrap();
write_simple_package(dir.path());
let dist = dir.path().join("dist");
cabin()
.args(["package", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--output-dir")
.arg(&dist)
.assert()
.success();
let archive = dist.join("fmt-10.2.1.tar.gz");
let metadata = dist.join("fmt-10.2.1.json");
assert!(archive.is_file(), "archive missing: {archive:?}");
assert!(metadata.is_file(), "metadata missing: {metadata:?}");
let body = fs::read_to_string(&metadata).unwrap();
let value: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(value["schema"], 1);
assert_eq!(value["name"], "fmt");
assert_eq!(value["version"], "10.2.1");
assert_eq!(value["yanked"], false);
assert!(value["checksum"].as_str().unwrap().starts_with("sha256:"));
assert_eq!(value["source"]["type"], "archive");
assert_eq!(value["source"]["format"], "tar.gz");
assert!(
value["source"]["path"]
.as_str()
.unwrap()
.ends_with("fmt-10.2.1.tar.gz")
);
}
#[test]
fn package_metadata_preserves_manifest_compiler_cache_settings() {
let dir = TempDir::new().unwrap();
write_simple_package(dir.path());
let manifest_path = dir.path().join("cabin.toml");
let mut manifest = fs::read_to_string(&manifest_path).unwrap();
manifest.push_str(
r#"
[profile.cache]
compiler-wrapper = "ccache"
"#,
);
assert_fs::fixture::ChildPath::new(&manifest_path)
.write_str(&manifest)
.unwrap();
let dist = dir.path().join("dist");
cabin()
.args(["package", "--manifest-path"])
.arg(&manifest_path)
.arg("--output-dir")
.arg(&dist)
.assert()
.success();
let body = fs::read_to_string(dist.join("fmt-10.2.1.json")).unwrap();
let value: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(
value["compiler_wrapper"]["general"],
serde_json::json!({"kind": "use", "wrapper": "ccache"})
);
}
#[test]
fn package_json_format_emits_machine_readable_summary() {
let dir = TempDir::new().unwrap();
write_simple_package(dir.path());
let dist = dir.path().join("dist");
let value = run_json(
cabin()
.args(["package", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--output-dir")
.arg(&dist)
.args(["--format", "json"]),
);
assert_eq!(value["name"], "fmt");
assert_eq!(value["version"], "10.2.1");
assert!(
value["archive_path"]
.as_str()
.unwrap()
.ends_with("fmt-10.2.1.tar.gz")
);
assert!(
value["metadata_path"]
.as_str()
.unwrap()
.ends_with("fmt-10.2.1.json")
);
assert!(value["checksum"].as_str().unwrap().starts_with("sha256:"));
}
#[test]
fn package_excludes_generated_and_vcs_files() {
let dir = TempDir::new().unwrap();
write_simple_package(dir.path());
dir.child(".git/config").write_str("leak-this").unwrap();
dir.child("build/build.ninja")
.write_str("leak-this")
.unwrap();
dir.child("dist/old.tar.gz").write_str("leak-this").unwrap();
dir.child(".cabin/cache/x").write_str("leak-this").unwrap();
dir.child("node_modules/foo/x")
.write_str("leak-this")
.unwrap();
dir.child("compile_commands.json")
.write_str("leak-this")
.unwrap();
dir.child("cabin.lock").write_str("leak-this").unwrap();
dir.child("build.ninja").write_str("leak-this").unwrap();
let dist = dir.path().join("artifact-out");
cabin()
.args(["package", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--output-dir")
.arg(&dist)
.assert()
.success();
let entries = read_archive_entries(&dist.join("fmt-10.2.1.tar.gz"));
assert!(entries.contains("cabin.toml"));
assert!(entries.contains("src/fmt.cc"));
assert!(entries.contains("include/example.h"));
for forbidden in &[
".git/config",
"build/build.ninja",
"dist/old.tar.gz",
".cabin/cache/x",
"node_modules/foo/x",
"compile_commands.json",
"cabin.lock",
"build.ninja",
] {
assert!(
!entries.iter().any(|e| e == forbidden),
"archive leaked {forbidden}: {entries:?}"
);
}
}
#[test]
fn package_excludes_in_tree_custom_output_dir() {
let dir = TempDir::new().unwrap();
write_simple_package(dir.path());
let out = dir.path().join("myoutput");
cabin()
.args(["package", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--output-dir")
.arg(&out)
.assert()
.success();
cabin()
.args(["package", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--output-dir")
.arg(&out)
.assert()
.success();
let entries = read_archive_entries(&out.join("fmt-10.2.1.tar.gz"));
assert!(entries.contains("cabin.toml"));
assert!(entries.contains("src/fmt.cc"));
assert!(
!entries.iter().any(|e| e.starts_with("myoutput/")),
"custom output dir leaked into archive: {entries:?}"
);
}
#[test]
fn package_rejects_output_dir_equal_to_package_root() {
let dir = TempDir::new().unwrap();
write_simple_package(dir.path());
let assertion = cabin()
.args(["package", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--output-dir")
.arg(dir.path())
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("equals the package source root"),
"expected output_dir == package_root rejection, got: {stderr}"
);
}
#[test]
fn package_is_byte_deterministic_across_runs() {
let dir = TempDir::new().unwrap();
let pkg_a = dir.path().join("pkg-a");
let pkg_b = dir.path().join("pkg-b");
write_simple_package(&pkg_a);
write_simple_package(&pkg_b);
let dist_a = dir.path().join("dist-a");
let dist_b = dir.path().join("dist-b");
cabin()
.args(["package", "--manifest-path"])
.arg(pkg_a.join("cabin.toml"))
.arg("--output-dir")
.arg(&dist_a)
.assert()
.success();
cabin()
.args(["package", "--manifest-path"])
.arg(pkg_b.join("cabin.toml"))
.arg("--output-dir")
.arg(&dist_b)
.assert()
.success();
let bytes_a = fs::read(dist_a.join("fmt-10.2.1.tar.gz")).unwrap();
let bytes_b = fs::read(dist_b.join("fmt-10.2.1.tar.gz")).unwrap();
assert_eq!(bytes_a, bytes_b, "archives must be byte-identical");
}
#[test]
fn publish_dry_run_creates_archive_and_reports_no_registry_modified() {
let dir = TempDir::new().unwrap();
write_simple_package(dir.path());
let dist = dir.path().join("dist");
let output = cabin()
.args(["publish", "--dry-run", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--output-dir")
.arg(&dist)
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains("Publish dry-run"));
assert!(stdout.contains("No registry was modified"));
assert!(dist.join("fmt-10.2.1.tar.gz").is_file());
assert!(dist.join("fmt-10.2.1.json").is_file());
}
#[test]
fn publish_dry_run_json_format_is_valid_json() {
let dir = TempDir::new().unwrap();
write_simple_package(dir.path());
let dist = dir.path().join("dist");
let value = run_json(
cabin()
.args(["publish", "--dry-run", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--output-dir")
.arg(&dist)
.args(["--format", "json"]),
);
assert_eq!(value["dry_run"], true);
assert_eq!(value["registry_modified"], false);
assert_eq!(value["name"], "fmt");
assert_eq!(value["version"], "10.2.1");
}
#[test]
fn publish_without_dry_run_fails_clearly() {
let dir = TempDir::new().unwrap();
write_simple_package(dir.path());
cabin()
.args(["publish", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.failure()
.stderr(predicate::str::contains("--dry-run"));
}
#[test]
fn package_with_path_dependency_fails_clearly() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
local = { path = "../local" }
"#,
)
.unwrap();
cabin()
.args(["package", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.failure()
.stderr(predicate::str::contains("path dependencies"));
}
#[test]
fn package_workspace_root_without_project_fails_clearly() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/*"]
"#,
)
.unwrap();
dir.child("packages/a/cabin.toml")
.write_str(
r#"[package]
name = "a"
version = "0.1.0"
"#,
)
.unwrap();
cabin()
.args(["package", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.failure()
.stderr(predicate::str::contains("--package <name>"));
}
#[test]
fn package_overwrite_with_identical_bytes_succeeds() {
let dir = TempDir::new().unwrap();
write_simple_package(dir.path());
let dist = dir.path().join("dist");
cabin()
.args(["package", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--output-dir")
.arg(&dist)
.assert()
.success();
cabin()
.args(["package", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--output-dir")
.arg(&dist)
.assert()
.success();
}
#[test]
fn package_overwrite_with_different_bytes_fails() {
let dir = TempDir::new().unwrap();
write_simple_package(dir.path());
let dist = dir.path().join("dist");
cabin()
.args(["package", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--output-dir")
.arg(&dist)
.assert()
.success();
assert_fs::fixture::ChildPath::new(dist.join("fmt-10.2.1.tar.gz"))
.write_binary(b"not the same bytes")
.unwrap();
cabin()
.args(["package", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--output-dir")
.arg(&dist)
.assert()
.failure()
.stderr(predicate::str::contains("already exists"));
}
}
mod distribution_artifacts {
use super::*;
#[test]
fn compgen_bash_writes_completion_script_to_stdout() {
let output = cabin()
.args(["compgen", "bash"])
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(!stdout.is_empty(), "bash completion should be non-empty");
assert!(stdout.contains("cabin"), "bash completion mentions cabin");
assert!(stdout.contains("build"));
assert!(stdout.contains("package"));
}
#[test]
fn compgen_zsh_writes_completion_script_to_stdout() {
let output = cabin()
.args(["compgen", "zsh"])
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(!stdout.is_empty(), "zsh completion should be non-empty");
assert!(
stdout.contains("cabin") || stdout.contains("_cabin"),
"expected zsh script to reference cabin or _cabin"
);
}
#[test]
fn compgen_all_writes_files_for_every_supported_shell() {
let dir = TempDir::new().unwrap();
let out = dir.path().join("completions");
cabin()
.args(["compgen", "--all", "--output-dir"])
.arg(&out)
.assert()
.success();
for filename in &[
"cabin.bash",
"_cabin",
"cabin.fish",
"cabin.ps1",
"cabin.elv",
] {
let path = out.join(filename);
assert!(path.is_file(), "expected {filename} to exist at {path:?}");
let body = fs::read(&path).unwrap();
assert!(!body.is_empty(), "{filename} must be non-empty");
}
}
#[test]
fn compgen_all_without_output_dir_fails_clearly() {
cabin()
.args(["compgen", "--all"])
.assert()
.failure()
.stderr(predicate::str::contains("--output-dir"))
.stderr(predicate::str::contains("--all"));
}
#[test]
fn compgen_invalid_shell_fails() {
cabin()
.args(["compgen", "klingon"])
.assert()
.failure()
.stderr(predicate::str::contains("klingon"));
}
#[test]
fn compgen_writes_single_shell_to_output_dir() {
let dir = TempDir::new().unwrap();
let out = dir.path().join("completions");
cabin()
.args(["compgen", "fish", "--output-dir"])
.arg(&out)
.assert()
.success();
let path = out.join("cabin.fish");
assert!(path.is_file());
let body = fs::read(&path).unwrap();
assert!(!body.is_empty());
assert!(!out.join("cabin.bash").exists());
}
#[test]
fn mangen_writes_root_man_page_to_stdout() {
let output = cabin()
.args(["mangen"])
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(!stdout.is_empty(), "man page should be non-empty");
assert!(
stdout.contains(".TH"),
"stdout should look like a ROFF man page"
);
assert!(stdout.contains("cabin"));
}
#[test]
fn mangen_writes_root_and_subcommand_pages_to_output_dir() {
let dir = TempDir::new().unwrap();
let out = dir.path().join("man");
cabin()
.args(["mangen", "--output-dir"])
.arg(&out)
.assert()
.success();
let root = out.join("cabin.1");
assert!(root.is_file(), "expected cabin.1 at {root:?}");
let root_body = fs::read_to_string(&root).unwrap();
assert!(!root_body.is_empty());
for sub in all_subcommand_names() {
let path = out.join(format!("cabin-{sub}.1"));
assert!(path.is_file(), "expected cabin-{sub}.1");
let body = fs::read(&path).unwrap();
assert!(!body.is_empty(), "cabin-{sub}.1 must be non-empty");
}
}
#[test]
fn mangen_root_page_mentions_known_subcommands() {
let dir = TempDir::new().unwrap();
let out = dir.path().join("man");
cabin()
.args(["mangen", "--output-dir"])
.arg(&out)
.assert()
.success();
let body = fs::read_to_string(out.join("cabin.1")).unwrap();
assert!(body.contains("build"));
assert!(body.contains("package"));
}
}
mod file_registry {
use super::*;
fn write_simple_package(root: &Path) {
assert_fs::fixture::ChildPath::new(root.join("cabin.toml"))
.write_str(
r#"[package]
name = "fmt"
version = "10.2.1"
[target.fmt]
type = "library"
sources = ["src/fmt.cc"]
include_dirs = ["include"]
"#,
)
.unwrap();
assert_fs::fixture::ChildPath::new(root.join("include/fmt.h"))
.write_str("#pragma once\nvoid say_hello();\n")
.unwrap();
assert_fs::fixture::ChildPath::new(root.join("src/fmt.cc"))
.write_str("#include <iostream>\n#include \"fmt.h\"\nvoid say_hello() { std::cout << \"hello from fmt\\n\"; }\n")
.unwrap();
}
#[test]
fn publish_creates_registry_layout() {
let dir = TempDir::new().unwrap();
let pkg_root = dir.path().join("pkg");
write_simple_package(&pkg_root);
let registry = dir.path().join("registry");
cabin()
.args(["publish", "--manifest-path"])
.arg(pkg_root.join("cabin.toml"))
.arg("--registry-dir")
.arg(®istry)
.assert()
.success();
assert!(registry.join("config.json").is_file());
assert!(registry.join("packages/fmt.json").is_file());
assert!(registry.join("artifacts/fmt/fmt-10.2.1.tar.gz").is_file());
}
#[test]
fn published_package_index_is_well_formed() {
let dir = TempDir::new().unwrap();
let pkg_root = dir.path().join("pkg");
write_simple_package(&pkg_root);
let registry = dir.path().join("registry");
cabin()
.args(["publish", "--manifest-path"])
.arg(pkg_root.join("cabin.toml"))
.arg("--registry-dir")
.arg(®istry)
.assert()
.success();
let body = fs::read_to_string(registry.join("packages/fmt.json")).unwrap();
let value: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(value["schema"], 1);
assert_eq!(value["name"], "fmt");
let entry = &value["versions"]["10.2.1"];
assert_eq!(entry["yanked"], false);
assert!(entry["checksum"].as_str().unwrap().starts_with("sha256:"));
assert_eq!(entry["source"]["type"], "archive");
assert_eq!(entry["source"]["format"], "tar.gz");
assert_eq!(
entry["source"]["path"],
"../artifacts/fmt/fmt-10.2.1.tar.gz"
);
}
#[test]
fn published_index_preserves_manifest_compiler_cache_settings() {
let dir = TempDir::new().unwrap();
let pkg_root = dir.path().join("pkg");
write_simple_package(&pkg_root);
let manifest_path = pkg_root.join("cabin.toml");
let mut manifest = fs::read_to_string(&manifest_path).unwrap();
manifest.push_str(
r#"
[profile.cache]
compiler-wrapper = "sccache"
"#,
);
assert_fs::fixture::ChildPath::new(&manifest_path)
.write_str(&manifest)
.unwrap();
let registry = dir.path().join("registry");
cabin()
.args(["publish", "--manifest-path"])
.arg(&manifest_path)
.arg("--registry-dir")
.arg(®istry)
.assert()
.success();
let body = fs::read_to_string(registry.join("packages/fmt.json")).unwrap();
let value: serde_json::Value = serde_json::from_str(&body).unwrap();
let entry = &value["versions"]["10.2.1"];
assert_eq!(
entry["compiler_wrapper"]["general"],
serde_json::json!({"kind": "use", "wrapper": "sccache"})
);
}
#[test]
fn duplicate_publish_fails_clearly() {
let dir = TempDir::new().unwrap();
let pkg_root = dir.path().join("pkg");
write_simple_package(&pkg_root);
let registry = dir.path().join("registry");
cabin()
.args(["publish", "--manifest-path"])
.arg(pkg_root.join("cabin.toml"))
.arg("--registry-dir")
.arg(®istry)
.assert()
.success();
cabin()
.args(["publish", "--manifest-path"])
.arg(pkg_root.join("cabin.toml"))
.arg("--registry-dir")
.arg(®istry)
.assert()
.failure()
.stderr(predicate::str::contains("already exists"));
}
#[test]
fn publish_json_format_emits_machine_readable_summary() {
let dir = TempDir::new().unwrap();
let pkg_root = dir.path().join("pkg");
write_simple_package(&pkg_root);
let registry = dir.path().join("registry");
let value = run_json(
cabin()
.args(["publish", "--manifest-path"])
.arg(pkg_root.join("cabin.toml"))
.arg("--registry-dir")
.arg(®istry)
.args(["--format", "json"]),
);
assert_eq!(value["published"], true);
assert_eq!(value["dry_run"], false);
assert_eq!(value["registry_modified"], true);
assert_eq!(value["name"], "fmt");
assert_eq!(value["version"], "10.2.1");
assert!(
value["artifact_path"]
.as_str()
.unwrap()
.ends_with("fmt-10.2.1.tar.gz")
);
assert!(
value["package_index_path"]
.as_str()
.unwrap()
.ends_with("fmt.json")
);
assert!(value["checksum"].as_str().unwrap().starts_with("sha256:"));
}
#[test]
fn dry_run_against_registry_does_not_mutate() {
let dir = TempDir::new().unwrap();
let pkg_root = dir.path().join("pkg");
write_simple_package(&pkg_root);
let registry = dir.path().join("registry");
let output = cabin()
.args(["publish", "--dry-run", "--manifest-path"])
.arg(pkg_root.join("cabin.toml"))
.arg("--registry-dir")
.arg(®istry)
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains("dry-run") || stdout.contains("dry run"));
assert!(stdout.contains("No registry was modified"));
assert!(!registry.join("config.json").exists());
assert!(!registry.join("packages").exists());
assert!(!registry.join("artifacts").exists());
}
#[test]
fn dry_run_against_registry_json_reports_no_mutation() {
let dir = TempDir::new().unwrap();
let pkg_root = dir.path().join("pkg");
write_simple_package(&pkg_root);
let registry = dir.path().join("registry");
let value = run_json(
cabin()
.args(["publish", "--dry-run", "--manifest-path"])
.arg(pkg_root.join("cabin.toml"))
.arg("--registry-dir")
.arg(®istry)
.args(["--format", "json"]),
);
assert_eq!(value["dry_run"], true);
assert_eq!(value["registry_modified"], false);
assert_eq!(value["published"], false);
}
#[test]
fn publish_without_dry_run_or_registry_dir_fails_clearly() {
let dir = TempDir::new().unwrap();
let pkg_root = dir.path().join("pkg");
write_simple_package(&pkg_root);
cabin()
.args(["publish", "--manifest-path"])
.arg(pkg_root.join("cabin.toml"))
.assert()
.failure()
.stderr(predicate::str::contains("--registry-dir"))
.stderr(predicate::str::contains("--dry-run"));
}
#[test]
fn publish_rejects_output_dir_with_registry_dir() {
let dir = TempDir::new().unwrap();
let pkg_root = dir.path().join("pkg");
write_simple_package(&pkg_root);
let registry = dir.path().join("registry");
cabin()
.args(["publish", "--manifest-path"])
.arg(pkg_root.join("cabin.toml"))
.arg("--registry-dir")
.arg(®istry)
.arg("--output-dir")
.arg(dir.path().join("dist"))
.assert()
.failure()
.stderr(predicate::str::contains("--output-dir"))
.stderr(predicate::str::contains("--registry-dir"));
}
#[test]
fn path_dependency_publish_fails_clearly() {
let dir = TempDir::new().unwrap();
let pkg_root = dir.path().join("pkg");
assert_fs::fixture::ChildPath::new(pkg_root.join("cabin.toml"))
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
local = { path = "../local" }
"#,
)
.unwrap();
let registry = dir.path().join("registry");
cabin()
.args(["publish", "--manifest-path"])
.arg(pkg_root.join("cabin.toml"))
.arg("--registry-dir")
.arg(®istry)
.assert()
.failure()
.stderr(predicate::str::contains("path dependencies"));
}
#[test]
fn workspace_root_publish_fails_clearly() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/*"]
"#,
)
.unwrap();
dir.child("packages/a/cabin.toml")
.write_str(
r#"[package]
name = "a"
version = "0.1.0"
"#,
)
.unwrap();
let registry = dir.path().join("registry");
cabin()
.args(["publish", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--registry-dir")
.arg(®istry)
.assert()
.failure()
.stderr(predicate::str::contains("--package <name>"));
}
fn publish_simple_package(dir: &Path) -> std::path::PathBuf {
let pkg_root = dir.join("pkg");
write_simple_package(&pkg_root);
let registry = dir.join("registry");
cabin()
.args(["publish", "--manifest-path"])
.arg(pkg_root.join("cabin.toml"))
.arg("--registry-dir")
.arg(®istry)
.assert()
.success();
registry
}
fn write_app_using_fmt(dir: &Path, app_main: Option<&str>) {
let manifest = if app_main.is_some() {
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=10.0.0 <11.0.0"
[target.app]
type = "executable"
sources = ["src/main.cc"]
deps = ["fmt"]
"#
} else {
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=10.0.0 <11.0.0"
"#
};
assert_fs::fixture::ChildPath::new(dir.join("app/cabin.toml"))
.write_str(manifest)
.unwrap();
if let Some(body) = app_main {
assert_fs::fixture::ChildPath::new(dir.join("app/src/main.cc"))
.write_str(body)
.unwrap();
}
}
#[test]
fn published_registry_can_be_resolved() {
let dir = TempDir::new().unwrap();
let registry = publish_simple_package(dir.path());
write_app_using_fmt(dir.path(), None);
let value = run_json(
cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(®istry)
.args(["--format", "json"]),
);
let names: Vec<&str> = value["packages"]
.as_array()
.unwrap()
.iter()
.map(|p| p["name"].as_str().unwrap())
.collect();
assert!(
names.contains(&"fmt"),
"fmt missing from resolve: {names:?}"
);
}
#[test]
fn published_registry_can_be_fetched() {
let dir = TempDir::new().unwrap();
let registry = publish_simple_package(dir.path());
write_app_using_fmt(dir.path(), None);
let cache = dir.path().join("cache");
cabin()
.args(["fetch", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(®istry)
.arg("--cache-dir")
.arg(&cache)
.assert()
.success();
let sources = cache.join("sources/sha256");
let mut found_cabin_toml = false;
for entry in fs::read_dir(&sources).unwrap() {
let entry = entry.unwrap();
if entry.path().join("cabin.toml").is_file() {
found_cabin_toml = true;
break;
}
}
assert!(
found_cabin_toml,
"expected an extracted cabin.toml in cache"
);
}
#[test]
fn published_registry_can_be_built() {
skip_if!(
!build_tools_available(),
"published_registry_can_be_built",
"ninja or a C++ compiler is not available"
);
let dir = TempDir::new().unwrap();
let registry = publish_simple_package(dir.path());
let app_main = "#include \"fmt.h\"\nint main() { say_hello(); return 0; }\n";
write_app_using_fmt(dir.path(), Some(app_main));
let cache = dir.path().join("cache");
let build_dir = dir.path().join("build");
cabin()
.args(["build", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(®istry)
.arg("--cache-dir")
.arg(&cache)
.arg("--build-dir")
.arg(&build_dir)
.assert()
.success();
let exe = build_dir.join("dev/packages/app/app");
assert!(exe.is_file());
let output = std::process::Command::new(&exe).output().unwrap();
assert!(String::from_utf8_lossy(&output.stdout).contains("hello from fmt"));
}
}
mod sparse_http {
use super::*;
use std::path::PathBuf;
use std::sync::Arc;
use std::thread::JoinHandle;
struct TestServer {
server: Arc<tiny_http::Server>,
thread: Option<JoinHandle<()>>,
url: String,
}
impl TestServer {
fn serve(root: PathBuf) -> Self {
let server = Arc::new(
tiny_http::Server::http("127.0.0.1:0").expect("bind tiny_http on loopback"),
);
let addr = server.server_addr().to_ip().expect("loopback addr");
let url = format!("http://{addr}");
let server_for_thread = Arc::clone(&server);
let thread = std::thread::spawn(move || {
loop {
let Ok(req) = server_for_thread.recv() else {
break;
};
let raw_url = req.url().to_string();
let path = raw_url
.split('?')
.next()
.unwrap_or("")
.trim_start_matches('/')
.to_owned();
if path.contains("..") {
let _ = req.respond(tiny_http::Response::empty(400));
continue;
}
let file_path = root.join(&path);
if file_path.is_file() {
match fs::read(&file_path) {
Ok(bytes) => {
let _ = req.respond(tiny_http::Response::from_data(bytes));
}
Err(_) => {
let _ = req.respond(tiny_http::Response::empty(500));
}
}
} else {
let _ = req.respond(tiny_http::Response::empty(404));
}
}
});
Self {
server,
thread: Some(thread),
url,
}
}
fn url(&self) -> &str {
&self.url
}
}
impl Drop for TestServer {
fn drop(&mut self) {
self.server.unblock();
if let Some(handle) = self.thread.take() {
let _ = handle.join();
}
}
}
fn publish_fmt_to_registry(dir: &Path) -> PathBuf {
let pkg_root = dir.join("pkg");
assert_fs::fixture::ChildPath::new(pkg_root.join("cabin.toml"))
.write_str(
r#"[package]
name = "fmt"
version = "10.2.1"
[target.fmt]
type = "library"
sources = ["src/fmt.cc"]
include_dirs = ["include"]
"#,
)
.unwrap();
assert_fs::fixture::ChildPath::new(pkg_root.join("include/fmt.h"))
.write_str("#pragma once\nvoid say_hello();\n")
.unwrap();
assert_fs::fixture::ChildPath::new(pkg_root.join("src/fmt.cc"))
.write_str("#include <iostream>\n#include \"fmt.h\"\nvoid say_hello() { std::cout << \"hello from fmt\\n\"; }\n")
.unwrap();
let registry = dir.join("registry");
cabin()
.args(["publish", "--manifest-path"])
.arg(pkg_root.join("cabin.toml"))
.arg("--registry-dir")
.arg(®istry)
.assert()
.success();
registry
}
fn write_app_using_fmt(dir: &Path, app_main: Option<&str>) {
let manifest = if app_main.is_some() {
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=10.0.0 <11.0.0"
[target.app]
type = "executable"
sources = ["src/main.cc"]
deps = ["fmt"]
"#
} else {
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=10.0.0 <11.0.0"
"#
};
assert_fs::fixture::ChildPath::new(dir.join("app/cabin.toml"))
.write_str(manifest)
.unwrap();
if let Some(body) = app_main {
assert_fs::fixture::ChildPath::new(dir.join("app/src/main.cc"))
.write_str(body)
.unwrap();
}
}
#[test]
fn resolve_via_index_url_finds_published_package() {
let dir = TempDir::new().unwrap();
let registry = publish_fmt_to_registry(dir.path());
write_app_using_fmt(dir.path(), None);
let server = TestServer::serve(registry);
let value = run_json(
cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-url")
.arg(server.url())
.args(["--format", "json"]),
);
let names: Vec<&str> = value["packages"]
.as_array()
.unwrap()
.iter()
.map(|p| p["name"].as_str().unwrap())
.collect();
assert!(
names.contains(&"fmt"),
"fmt missing from resolve: {names:?}"
);
}
#[test]
fn fetch_via_index_url_extracts_archive_into_cache() {
let dir = TempDir::new().unwrap();
let registry = publish_fmt_to_registry(dir.path());
write_app_using_fmt(dir.path(), None);
let server = TestServer::serve(registry);
let cache = dir.path().join("cache");
cabin()
.args(["fetch", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-url")
.arg(server.url())
.arg("--cache-dir")
.arg(&cache)
.assert()
.success();
let sources = cache.join("sources/sha256");
assert!(sources.is_dir());
let mut found_cabin_toml = false;
for entry in fs::read_dir(&sources).unwrap() {
let entry = entry.unwrap();
if entry.path().join("cabin.toml").is_file() {
found_cabin_toml = true;
break;
}
}
assert!(
found_cabin_toml,
"expected an extracted cabin.toml in cache"
);
}
#[test]
fn build_via_index_url_builds_executable() {
skip_if!(
!build_tools_available(),
"build_via_index_url_builds_executable",
"ninja or a C++ compiler is not available"
);
let dir = TempDir::new().unwrap();
let registry = publish_fmt_to_registry(dir.path());
let app_main = "#include \"fmt.h\"\nint main() { say_hello(); return 0; }\n";
write_app_using_fmt(dir.path(), Some(app_main));
let server = TestServer::serve(registry);
let cache = dir.path().join("cache");
let build_dir = dir.path().join("build");
cabin()
.args(["build", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-url")
.arg(server.url())
.arg("--cache-dir")
.arg(&cache)
.arg("--build-dir")
.arg(&build_dir)
.assert()
.success();
let exe = build_dir.join("dev/packages/app/app");
assert!(exe.is_file());
let output = std::process::Command::new(&exe).output().unwrap();
assert!(String::from_utf8_lossy(&output.stdout).contains("hello from fmt"));
}
#[test]
fn index_path_and_index_url_together_fail() {
let dir = TempDir::new().unwrap();
let registry = publish_fmt_to_registry(dir.path());
write_app_using_fmt(dir.path(), None);
let server = TestServer::serve(registry.clone());
cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-path")
.arg(®istry)
.arg("--index-url")
.arg(server.url())
.assert()
.failure()
.stderr(predicate::str::contains("--index-path"))
.stderr(predicate::str::contains("--index-url"));
}
#[test]
fn http_package_not_found_surfaces_clear_error() {
let dir = TempDir::new().unwrap();
let empty_registry = dir.path().join("registry");
assert_fs::fixture::ChildPath::new(empty_registry.join("packages"))
.create_dir_all()
.unwrap();
assert_fs::fixture::ChildPath::new(empty_registry.join("artifacts"))
.create_dir_all()
.unwrap();
assert_fs::fixture::ChildPath::new(empty_registry.join("config.json"))
.write_str(
r#"{"schema":1,"kind":"file-registry","packages":"packages","artifacts":"artifacts"}"#,
)
.unwrap();
write_app_using_fmt(dir.path(), None);
let server = TestServer::serve(empty_registry);
cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-url")
.arg(server.url())
.assert()
.failure()
.stderr(predicate::str::contains("not found in HTTP index"));
}
#[test]
fn http_invalid_metadata_surfaces_clear_error() {
let dir = TempDir::new().unwrap();
let registry = dir.path().join("registry");
assert_fs::fixture::ChildPath::new(registry.join("packages"))
.create_dir_all()
.unwrap();
assert_fs::fixture::ChildPath::new(registry.join("artifacts"))
.create_dir_all()
.unwrap();
assert_fs::fixture::ChildPath::new(registry.join("config.json"))
.write_str(
r#"{"schema":1,"kind":"file-registry","packages":"packages","artifacts":"artifacts"}"#,
)
.unwrap();
assert_fs::fixture::ChildPath::new(registry.join("packages/fmt.json"))
.write_binary(b"{ not really json")
.unwrap();
write_app_using_fmt(dir.path(), None);
let server = TestServer::serve(registry);
cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-url")
.arg(server.url())
.assert()
.failure()
.stderr(predicate::str::contains("invalid package metadata"));
}
#[test]
fn cross_origin_http_artifact_url_is_rejected() {
let dir = TempDir::new().unwrap();
let registry = publish_fmt_to_registry(dir.path());
let pkg_index = registry.join("packages/fmt.json");
let mut value: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&pkg_index).unwrap()).unwrap();
value["versions"]["10.2.1"]["source"]["path"] =
serde_json::Value::String("http://127.0.0.1/artifacts/fmt.tar.gz".into());
assert_fs::fixture::ChildPath::new(&pkg_index)
.write_str(&(serde_json::to_string_pretty(&value).unwrap() + "\n"))
.unwrap();
write_app_using_fmt(dir.path(), None);
let server = TestServer::serve(registry);
cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-url")
.arg(server.url())
.assert()
.failure()
.stderr(predicate::str::contains("same origin"));
}
#[test]
fn http_artifact_checksum_mismatch_fails() {
let dir = TempDir::new().unwrap();
let registry = publish_fmt_to_registry(dir.path());
let pkg_index = registry.join("packages/fmt.json");
let mut value: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&pkg_index).unwrap()).unwrap();
value["versions"]["10.2.1"]["checksum"] =
serde_json::Value::String(format!("sha256:{}", "0".repeat(64)));
assert_fs::fixture::ChildPath::new(&pkg_index)
.write_str(&(serde_json::to_string_pretty(&value).unwrap() + "\n"))
.unwrap();
write_app_using_fmt(dir.path(), None);
let server = TestServer::serve(registry);
let cache = dir.path().join("cache");
cabin()
.args(["fetch", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-url")
.arg(server.url())
.arg("--cache-dir")
.arg(&cache)
.assert()
.failure()
.stderr(predicate::str::contains("checksum mismatch"));
}
#[test]
fn relative_artifact_path_resolves_correctly() {
let dir = TempDir::new().unwrap();
let registry = publish_fmt_to_registry(dir.path());
write_app_using_fmt(dir.path(), None);
let server = TestServer::serve(registry);
cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-url")
.arg(server.url())
.assert()
.success();
}
#[test]
fn frozen_with_index_url_fails_clearly() {
let dir = TempDir::new().unwrap();
let registry = publish_fmt_to_registry(dir.path());
write_app_using_fmt(dir.path(), None);
let server = TestServer::serve(registry);
cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-url")
.arg(server.url())
.assert()
.success();
let cache = dir.path().join("cache");
cabin()
.args(["fetch", "--frozen", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.arg("--index-url")
.arg(server.url())
.arg("--cache-dir")
.arg(&cache)
.assert()
.failure()
.stderr(predicate::str::contains("--index-url"))
.stderr(predicate::str::contains("--frozen"));
}
#[test]
fn resolve_frozen_rejects_config_index_url() {
let dir = TempDir::new().unwrap();
let registry = publish_fmt_to_registry(dir.path());
write_app_using_fmt(dir.path(), None);
let server = TestServer::serve(registry);
assert_fs::fixture::ChildPath::new(dir.path().join("app/.cabin/config.toml"))
.write_str(&format!("[registry]\nindex-url = \"{}\"\n", server.url()))
.unwrap();
let mut cmd = cabin();
super::pin_test_user_config_home_to_empty(&mut cmd);
cmd.args(["resolve", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.env_remove("CABIN_NO_CONFIG")
.env_remove("CABIN_CONFIG")
.env_remove("CABIN_CONFIG_HOME")
.assert()
.success();
let mut cmd = cabin();
super::pin_test_user_config_home_to_empty(&mut cmd);
cmd.args(["resolve", "--frozen", "--manifest-path"])
.arg(dir.path().join("app/cabin.toml"))
.env_remove("CABIN_NO_CONFIG")
.env_remove("CABIN_CONFIG")
.env_remove("CABIN_CONFIG_HOME")
.assert()
.failure()
.stderr(predicate::str::contains("--index-url"))
.stderr(predicate::str::contains("--frozen"));
}
}
mod features {
use super::*;
fn write_demo_with_features(root: &Path) {
assert_fs::fixture::ChildPath::new(root.join("cabin.toml"))
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
[features]
default = ["simd"]
simd = []
ssl = []
[target.demo]
type = "executable"
sources = ["src/main.cc"]
"#,
)
.unwrap();
assert_fs::fixture::ChildPath::new(root.join("src/main.cc"))
.write_str(HELLO_MAIN_CC)
.unwrap();
}
#[test]
fn unknown_feature_fails_clearly() {
let dir = TempDir::new().unwrap();
write_demo_with_features(dir.path());
cabin()
.current_dir(dir.path())
.args(["build", "--features", "missing", "--build-dir"])
.arg(dir.path().join("build"))
.assert()
.failure()
.stderr(predicate::str::contains("unknown feature"));
}
#[test]
fn cabin_metadata_reports_declarations_and_selections() {
let dir = TempDir::new().unwrap();
write_demo_with_features(dir.path());
let json = run_json(
cabin()
.current_dir(dir.path())
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml")),
);
let pkg = &json["packages"][0];
assert_eq!(pkg["features"]["default"][0], "simd");
let cfg = &pkg["configuration"];
assert_eq!(cfg["features"][0], "simd");
assert_eq!(cfg["fingerprint"].as_str().unwrap().len(), 64);
}
#[test]
fn cabin_metadata_all_features_applies_to_configuration_block() {
let dir = TempDir::new().unwrap();
write_demo_with_features(dir.path());
let json = run_json(
cabin()
.current_dir(dir.path())
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--all-features"]),
);
let cfg = &json["packages"][0]["configuration"];
assert_eq!(cfg["features"], serde_json::json!(["simd", "ssl"]));
}
#[test]
fn cabin_package_metadata_includes_declarations() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
[features]
default = ["simd"]
simd = []
[target.demo]
type = "executable"
sources = ["src/main.cc"]
"#,
)
.unwrap();
dir.child("src/main.cc").write_str(HELLO_MAIN_CC).unwrap();
let dist = dir.path().join("dist");
cabin()
.current_dir(dir.path())
.args(["package", "--output-dir"])
.arg(&dist)
.assert()
.success();
let meta_path = dist.join("demo-0.1.0.json");
let body = fs::read_to_string(&meta_path).unwrap();
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(json["features"]["default"][0], "simd");
}
#[test]
fn cabin_publish_registry_dir_preserves_declarations() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
[features]
default = []
simd = []
[target.demo]
type = "executable"
sources = ["src/main.cc"]
"#,
)
.unwrap();
dir.child("src/main.cc").write_str(HELLO_MAIN_CC).unwrap();
let registry = dir.path().join("registry");
cabin()
.current_dir(dir.path())
.args(["publish", "--registry-dir"])
.arg(®istry)
.assert()
.success();
let entry_path = registry.join("packages/demo.json");
let body = fs::read_to_string(&entry_path).unwrap();
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
let v = &json["versions"]["0.1.0"];
assert_eq!(v["features"]["features"]["simd"], serde_json::json!([]));
}
}
mod workspace_semantics {
use super::*;
fn write_three_member_workspace(
root: &Path,
default_members: Option<&[&str]>,
exclude: Option<&[&str]>,
) {
use std::fmt::Write as _;
let mut manifest = String::from("[workspace]\nmembers = [\"packages/*\"]\n");
if let Some(dm) = default_members {
let entries: Vec<String> = dm.iter().map(|n| format!("\"packages/{n}\"")).collect();
writeln!(manifest, "default-members = [{}]", entries.join(", ")).unwrap();
}
if let Some(ex) = exclude {
let entries: Vec<String> = ex.iter().map(|n| format!("\"packages/{n}\"")).collect();
writeln!(manifest, "exclude = [{}]", entries.join(", ")).unwrap();
}
assert_fs::fixture::ChildPath::new(root.join("cabin.toml"))
.write_str(&manifest)
.unwrap();
for name in ["alpha", "beta", "gamma"] {
assert_fs::fixture::ChildPath::new(root.join(format!("packages/{name}/cabin.toml")))
.write_str(&format!(
"[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n\n[target.{name}]\ntype = \"executable\"\nsources = [\"src/main.cc\"]\n"
))
.unwrap();
assert_fs::fixture::ChildPath::new(root.join(format!("packages/{name}/src/main.cc")))
.write_str(HELLO_MAIN_CC)
.unwrap();
}
}
#[test]
fn metadata_reports_workspace_members_default_excluded_selected() {
let dir = TempDir::new().unwrap();
write_three_member_workspace(dir.path(), Some(&["alpha"]), Some(&["gamma"]));
let value = run_metadata(&dir.path().join("cabin.toml"));
let ws = &value["workspace"];
assert!(!ws.is_null());
let members: Vec<&str> = ws["members"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(members, vec!["alpha", "beta"]);
let default_members: Vec<&str> = ws["default_members"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(default_members, vec!["alpha"]);
let excluded: Vec<&str> = ws["excluded_members"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(excluded, vec!["packages/gamma"]);
let selected: Vec<&str> = ws["selected_packages"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(selected, vec!["alpha"]);
}
#[test]
fn metadata_inside_member_directory_finds_root() {
let dir = TempDir::new().unwrap();
write_three_member_workspace(dir.path(), None, None);
let json = run_json(
cabin()
.current_dir(dir.path().join("packages/beta"))
.args(["metadata"]),
);
let ws = &json["workspace"];
assert!(
!ws.is_null(),
"workspace section missing — root discovery failed"
);
let root = ws["root"].as_str().unwrap();
assert!(
root.ends_with(dir.path().file_name().unwrap().to_str().unwrap()),
"root mismatch: {root}"
);
}
#[test]
fn metadata_workspace_flag_selects_all_members_minus_exclude() {
let dir = TempDir::new().unwrap();
write_three_member_workspace(dir.path(), None, None);
let out = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--workspace", "--exclude", "beta"])
.assert()
.success()
.get_output()
.clone();
let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let selected: Vec<&str> = json["workspace"]["selected_packages"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(selected, vec!["alpha", "gamma"]);
}
#[test]
fn metadata_explicit_packages_selects_named_members() {
let dir = TempDir::new().unwrap();
write_three_member_workspace(dir.path(), None, None);
let out = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["-p", "alpha", "-p", "gamma"])
.assert()
.success()
.get_output()
.clone();
let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let selected: Vec<&str> = json["workspace"]["selected_packages"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(selected, vec!["alpha", "gamma"]);
}
#[test]
fn metadata_unknown_package_fails_clearly() {
let dir = TempDir::new().unwrap();
write_three_member_workspace(dir.path(), None, None);
cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["-p", "nope"])
.assert()
.failure()
.stderr(predicate::str::contains("not a member"));
}
#[test]
fn metadata_default_members_mode_errors_when_undeclared() {
let dir = TempDir::new().unwrap();
write_three_member_workspace(dir.path(), None, None);
cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--default-members"])
.assert()
.failure()
.stderr(
predicate::str::contains("default member")
.or(predicate::str::contains("default-members")),
);
}
#[test]
fn metadata_exclude_with_explicit_package_errors() {
let dir = TempDir::new().unwrap();
write_three_member_workspace(dir.path(), None, None);
cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["-p", "alpha", "--exclude", "beta"])
.assert()
.failure()
.stderr(predicate::str::contains("--exclude"));
}
#[test]
fn workspace_default_member_missing_member_errors() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/*"]
default-members = ["packages/missing"]
"#,
)
.unwrap();
dir.child("packages/keep/cabin.toml")
.write_str("[package]\nname = \"keep\"\nversion = \"0.1.0\"\n")
.unwrap();
cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.failure()
.stderr(predicate::str::contains("default member"));
}
#[test]
fn unused_exclude_pattern_errors() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/keep"]
exclude = ["packages/missing"]
"#,
)
.unwrap();
dir.child("packages/keep/cabin.toml")
.write_str("[package]\nname = \"keep\"\nversion = \"0.1.0\"\n")
.unwrap();
cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.failure()
.stderr(predicate::str::contains("packages/missing"));
}
#[test]
fn nested_workspace_rejected() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["nested"]
"#,
)
.unwrap();
dir.child("nested/cabin.toml")
.write_str("[workspace]\nmembers = []\n")
.unwrap();
cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.failure()
.stderr(predicate::str::contains("nested workspace"));
}
#[test]
fn workspace_dependency_inheritance_resolves_in_metadata() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/app"]
[workspace.dependencies]
fmt = ">=10 <11"
"#,
)
.unwrap();
dir.child("packages/app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = { workspace = true }
"#,
)
.unwrap();
let json = run_metadata(&dir.path().join("cabin.toml"));
let app = json["packages"]
.as_array()
.unwrap()
.iter()
.find(|p| p["name"] == "app")
.unwrap();
let deps = app["dependencies"].as_array().unwrap();
assert_eq!(deps.len(), 1);
assert_eq!(deps[0]["kind"], "version");
}
#[test]
fn workspace_dependency_unresolved_errors() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/app"]
"#,
)
.unwrap();
dir.child("packages/app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = { workspace = true }
"#,
)
.unwrap();
cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.failure()
.stderr(predicate::str::contains("workspace = true"));
}
#[test]
fn build_workspace_flag_builds_every_member() {
skip_if!(
!build_tools_available(),
"workspace_semantics build --workspace",
"ninja or C++ compiler missing"
);
let dir = TempDir::new().unwrap();
write_three_member_workspace(dir.path(), None, None);
let build_dir = dir.path().join("build");
cabin()
.args(["build", "--workspace", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--build-dir")
.arg(&build_dir)
.assert()
.success();
for name in ["alpha", "beta", "gamma"] {
let exe = build_dir.join("dev").join("packages").join(name).join(name);
assert!(exe.is_file(), "missing built binary {}", exe.display());
}
}
#[test]
fn build_with_explicit_packages_builds_only_those() {
skip_if!(
!build_tools_available(),
"workspace_semantics build -p",
"ninja or C++ compiler missing"
);
let dir = TempDir::new().unwrap();
write_three_member_workspace(dir.path(), None, None);
let build_dir = dir.path().join("build");
cabin()
.args(["build", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["-p", "beta"])
.arg("--build-dir")
.arg(&build_dir)
.assert()
.success();
assert!(build_dir.join("dev/packages/beta/beta").is_file());
assert!(!build_dir.join("dev/packages/alpha/alpha").exists());
assert!(!build_dir.join("dev/packages/gamma/gamma").exists());
}
#[test]
fn build_workspace_with_exclude_skips_member() {
skip_if!(
!build_tools_available(),
"workspace_semantics build --workspace --exclude",
"ninja or C++ compiler missing"
);
let dir = TempDir::new().unwrap();
write_three_member_workspace(dir.path(), None, None);
let build_dir = dir.path().join("build");
cabin()
.args([
"build",
"--workspace",
"--exclude",
"gamma",
"--manifest-path",
])
.arg(dir.path().join("cabin.toml"))
.arg("--build-dir")
.arg(&build_dir)
.assert()
.success();
assert!(build_dir.join("dev/packages/alpha/alpha").is_file());
assert!(build_dir.join("dev/packages/beta/beta").is_file());
assert!(!build_dir.join("dev/packages/gamma/gamma").exists());
}
#[test]
fn build_unknown_package_fails_clearly() {
let dir = TempDir::new().unwrap();
write_three_member_workspace(dir.path(), None, None);
cabin()
.args(["build", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["-p", "nope", "--build-dir"])
.arg(dir.path().join("build"))
.assert()
.failure()
.stderr(predicate::str::contains("not a member"));
}
#[test]
fn package_in_workspace_requires_explicit_selection() {
let dir = TempDir::new().unwrap();
write_three_member_workspace(dir.path(), None, None);
cabin()
.args(["package", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.failure()
.stderr(predicate::str::contains("--package <name>"));
let dist = dir.path().join("dist");
cabin()
.args(["package", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["-p", "beta", "--output-dir"])
.arg(&dist)
.assert()
.success();
assert!(dist.join("beta-0.1.0.tar.gz").is_file());
}
#[test]
fn publish_in_workspace_requires_explicit_selection() {
let dir = TempDir::new().unwrap();
write_three_member_workspace(dir.path(), None, None);
let registry = dir.path().join("registry");
cabin()
.args(["publish", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--registry-dir")
.arg(®istry)
.assert()
.failure()
.stderr(predicate::str::contains("--package <name>"));
cabin()
.args(["publish", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["-p", "alpha", "--registry-dir"])
.arg(®istry)
.assert()
.success();
assert!(registry.join("packages/alpha.json").is_file());
}
}
mod workspace_review {
use super::*;
fn write_three_member_workspace_no_default(root: &Path) {
assert_fs::fixture::ChildPath::new(root.join("cabin.toml"))
.write_str(
r#"[workspace]
members = ["packages/*"]
"#,
)
.unwrap();
for name in ["alpha", "beta", "gamma"] {
assert_fs::fixture::ChildPath::new(root.join(format!("packages/{name}/cabin.toml")))
.write_str(&format!(
"[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n\n[target.{name}]\ntype = \"executable\"\nsources = [\"src/main.cc\"]\n"
))
.unwrap();
assert_fs::fixture::ChildPath::new(root.join(format!("packages/{name}/src/main.cc")))
.write_str(HELLO_MAIN_CC)
.unwrap();
}
}
#[test]
fn member_with_parent_dir_rejected_at_cli() {
let dir = TempDir::new().unwrap();
let workspace_dir = dir.path().join("ws");
let outside_dir = dir.path().join("outside");
assert_fs::fixture::ChildPath::new(&workspace_dir)
.create_dir_all()
.unwrap();
assert_fs::fixture::ChildPath::new(&outside_dir)
.create_dir_all()
.unwrap();
assert_fs::fixture::ChildPath::new(workspace_dir.join("cabin.toml"))
.write_str(
r#"[workspace]
members = ["../outside"]
"#,
)
.unwrap();
assert_fs::fixture::ChildPath::new(outside_dir.join("cabin.toml"))
.write_str("[package]\nname = \"sneaky\"\nversion = \"0.1.0\"\n")
.unwrap();
cabin()
.args(["metadata", "--manifest-path"])
.arg(workspace_dir.join("cabin.toml"))
.assert()
.failure()
.stderr(predicate::str::contains("workspace.members"))
.stderr(predicate::str::contains("../outside"));
}
#[test]
fn exclude_absolute_path_rejected_at_cli() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/keep"]
exclude = ["/tmp/outside"]
"#,
)
.unwrap();
dir.child("packages/keep/cabin.toml")
.write_str("[package]\nname = \"keep\"\nversion = \"0.1.0\"\n")
.unwrap();
cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.failure()
.stderr(predicate::str::contains("workspace.exclude"));
}
#[test]
fn select_package_without_cpp_target_errors_clearly() {
skip_if!(
!build_tools_available(),
"workspace_semantics review empty selection",
"ninja or C++ compiler missing"
);
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/*"]
"#,
)
.unwrap();
dir.child("packages/empty/cabin.toml")
.write_str("[package]\nname = \"empty\"\nversion = \"0.1.0\"\n")
.unwrap();
dir.child("packages/peer/cabin.toml")
.write_str(
r#"[package]
name = "peer"
version = "0.1.0"
[target.peer]
type = "executable"
sources = ["src/main.cc"]
"#,
)
.unwrap();
dir.child("packages/peer/src/main.cc")
.write_str(HELLO_MAIN_CC)
.unwrap();
let build_dir = dir.path().join("build");
cabin()
.args(["build", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["-p", "empty", "--build-dir"])
.arg(&build_dir)
.assert()
.failure()
.stderr(predicate::str::contains("no C/C++ targets"));
assert!(
!build_dir.join("dev/packages/peer/peer").exists(),
"selecting `empty` must not have built `peer`"
);
}
#[test]
fn fetch_unknown_package_errors_without_versioned_deps() {
let dir = TempDir::new().unwrap();
write_three_member_workspace_no_default(dir.path());
cabin()
.args(["fetch", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["-p", "missing"])
.assert()
.failure()
.stderr(predicate::str::contains("not a member"));
}
#[test]
fn resolve_pure_workspace_root_with_member_versioned_deps() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/app"]
"#,
)
.unwrap();
dir.child("packages/app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=10.0.0 <11.0.0"
"#,
)
.unwrap();
dir.child("index/fmt.json")
.write_str(r#"{
"schema": 1,
"name": "fmt",
"versions": {
"10.2.1": {
"dependencies": {},
"yanked": false,
"checksum": "sha256:0000000000000000000000000000000000000000000000000000000000000000"
}
}
}"#)
.unwrap();
cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--index-path")
.arg(dir.path().join("index"))
.assert()
.success();
let lockfile = fs::read_to_string(dir.path().join("cabin.lock")).unwrap();
assert!(
lockfile.contains(r#"name = "fmt""#),
"lockfile missing fmt: {lockfile}"
);
}
#[test]
fn resolve_explicit_package_selection() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/app", "packages/sibling"]
"#,
)
.unwrap();
dir.child("packages/app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=10.0.0 <11.0.0"
"#,
)
.unwrap();
dir.child("packages/sibling/cabin.toml")
.write_str(
r#"[package]
name = "sibling"
version = "0.1.0"
[dependencies]
unknown = ">=1"
"#,
)
.unwrap();
dir.child("index/fmt.json")
.write_str(r#"{
"schema": 1,
"name": "fmt",
"versions": {
"10.2.1": {
"dependencies": {},
"yanked": false,
"checksum": "sha256:0000000000000000000000000000000000000000000000000000000000000000"
}
}
}"#)
.unwrap();
cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["-p", "app", "--index-path"])
.arg(dir.path().join("index"))
.assert()
.success();
let lockfile = fs::read_to_string(dir.path().join("cabin.lock")).unwrap();
assert!(lockfile.contains(r#"name = "fmt""#));
assert!(!lockfile.contains("unknown"));
}
#[test]
fn update_split_flag_names() {
let dir = TempDir::new().unwrap();
write_three_member_workspace_no_default(dir.path());
cabin()
.args(["update", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--package", "anything"])
.assert()
.failure()
.stderr(predicate::str::contains(
"not a direct versioned dependency",
));
}
#[test]
fn explicit_manifest_path_overrides_root_discovery() {
let dir = TempDir::new().unwrap();
write_three_member_workspace_no_default(dir.path());
let json = run_json(cabin().current_dir(dir.path().join("packages/beta")).args([
"metadata",
"--manifest-path",
"cabin.toml",
]));
assert!(
json["workspace"].is_null(),
"expected member-scoped metadata, got: {json}"
);
let pkgs = json["packages"].as_array().unwrap();
assert_eq!(pkgs.len(), 1);
assert_eq!(pkgs[0]["name"], "beta");
}
#[test]
fn default_manifest_path_walks_up_to_workspace_root() {
let dir = TempDir::new().unwrap();
write_three_member_workspace_no_default(dir.path());
let json = run_json(
cabin()
.current_dir(dir.path().join("packages/beta"))
.args(["metadata"]),
);
assert!(
!json["workspace"].is_null(),
"expected workspace section, got: {json}"
);
}
}
mod workspace_selection_hardening {
use super::*;
fn write_workspace_with_app_and_versioned_unrelated(root: &Path) {
assert_fs::fixture::ChildPath::new(root.join("cabin.toml"))
.write_str(
r#"[workspace]
members = ["packages/*"]
"#,
)
.unwrap();
assert_fs::fixture::ChildPath::new(root.join("packages/app/cabin.toml"))
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[target.app]
type = "executable"
sources = ["src/main.cc"]
"#,
)
.unwrap();
assert_fs::fixture::ChildPath::new(root.join("packages/app/src/main.cc"))
.write_str(HELLO_MAIN_CC)
.unwrap();
assert_fs::fixture::ChildPath::new(root.join("packages/b/cabin.toml"))
.write_str(
r#"[package]
name = "b"
version = "0.1.0"
[dependencies]
fmt = ">=10.0.0 <11.0.0"
"#,
)
.unwrap();
}
#[test]
fn build_p_app_does_not_require_index_when_unrelated_member_has_versioned_dep() {
skip_if!(
!build_tools_available(),
"workspace_semantics.5 build -p app no index",
"ninja or C++ compiler missing"
);
let dir = TempDir::new().unwrap();
write_workspace_with_app_and_versioned_unrelated(dir.path());
let build_dir = dir.path().join("build");
cabin()
.args(["build", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["-p", "app", "--build-dir"])
.arg(&build_dir)
.assert()
.success();
assert!(build_dir.join("dev/packages/app/app").is_file());
}
#[test]
fn fetch_p_app_does_not_require_index_when_unrelated_member_has_versioned_dep() {
let dir = TempDir::new().unwrap();
write_workspace_with_app_and_versioned_unrelated(dir.path());
cabin()
.args(["fetch", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["-p", "app"])
.assert()
.success();
}
#[test]
fn resolve_p_app_includes_registry_deps_from_path_dep_lib() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/*"]
"#,
)
.unwrap();
dir.child("packages/app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
lib = { path = "../lib" }
"#,
)
.unwrap();
dir.child("packages/lib/cabin.toml")
.write_str(
r#"[package]
name = "lib"
version = "0.1.0"
[dependencies]
fmt = ">=10.0.0 <11.0.0"
"#,
)
.unwrap();
dir.child("index/fmt.json")
.write_str(r#"{
"schema": 1,
"name": "fmt",
"versions": {
"10.2.1": { "dependencies": {}, "yanked": false, "checksum": "sha256:0000000000000000000000000000000000000000000000000000000000000000" }
}
}"#)
.unwrap();
cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["-p", "app", "--index-path"])
.arg(dir.path().join("index"))
.assert()
.success();
let lock = fs::read_to_string(dir.path().join("cabin.lock")).unwrap();
assert!(lock.contains(r#"name = "fmt""#), "lockfile: {lock}");
}
#[test]
fn features_apply_only_to_selected_packages() {
skip_if!(
!build_tools_available(),
"workspace_semantics.5 features scoped",
"ninja or C++ compiler missing"
);
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/*"]
"#,
)
.unwrap();
dir.child("packages/a/cabin.toml")
.write_str(
r#"[package]
name = "a"
version = "0.1.0"
[features]
ssl = []
[target.a]
type = "executable"
sources = ["src/main.cc"]
"#,
)
.unwrap();
dir.child("packages/a/src/main.cc")
.write_str(HELLO_MAIN_CC)
.unwrap();
dir.child("packages/b/cabin.toml")
.write_str(
r#"[package]
name = "b"
version = "0.1.0"
[target.b]
type = "executable"
sources = ["src/main.cc"]
"#,
)
.unwrap();
dir.child("packages/b/src/main.cc")
.write_str(HELLO_MAIN_CC)
.unwrap();
cabin()
.args(["build", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["-p", "a", "--features", "ssl", "--build-dir"])
.arg(dir.path().join("build"))
.assert()
.success();
}
#[test]
fn package_resolves_workspace_dep_inheritance() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/app"]
[workspace.dependencies]
fmt = ">=10 <11"
"#,
)
.unwrap();
dir.child("packages/app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = { workspace = true }
"#,
)
.unwrap();
let dist = dir.path().join("dist");
cabin()
.args(["package", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["-p", "app", "--output-dir"])
.arg(&dist)
.assert()
.success();
let json: serde_json::Value =
serde_json::from_str(&fs::read_to_string(dist.join("app-0.1.0.json")).unwrap())
.unwrap();
assert!(
json["dependencies"]["fmt"].is_string(),
"fmt missing from package metadata: {json}"
);
}
#[test]
fn package_standalone_workspace_dep_errors() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = { workspace = true }
"#,
)
.unwrap();
let dist = dir.path().join("dist");
cabin()
.args(["package", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--output-dir")
.arg(&dist)
.assert()
.failure()
.stderr(predicate::str::contains("workspace = true"));
}
#[test]
fn publish_unsafe_package_name_rejected() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "../evil"
version = "0.1.0"
"#,
)
.unwrap();
let registry = dir.path().join("registry");
cabin()
.args(["publish", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--registry-dir")
.arg(®istry)
.assert()
.failure();
}
#[test]
fn exclude_without_workspace_errors() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/*"]
"#,
)
.unwrap();
dir.child("packages/a/cabin.toml")
.write_str("[package]\nname = \"a\"\nversion = \"0.1.0\"\n")
.unwrap();
dir.child("packages/b/cabin.toml")
.write_str("[package]\nname = \"b\"\nversion = \"0.1.0\"\n")
.unwrap();
cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--exclude", "b"])
.assert()
.failure()
.stderr(
predicate::str::contains("--workspace")
.or(predicate::str::contains("--default-members")),
);
}
#[test]
fn nested_workspace_from_inside_rejected() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["nested"]
"#,
)
.unwrap();
dir.child("nested/cabin.toml")
.write_str(
r#"[workspace]
members = []
"#,
)
.unwrap();
cabin()
.current_dir(dir.path().join("nested"))
.args(["metadata"])
.assert()
.failure()
.stderr(predicate::str::contains("nested workspace"));
}
#[test]
fn update_package_back_compat() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
[dependencies]
fmt = ">=10.0.0 <11.0.0"
"#,
)
.unwrap();
dir.child("index/fmt.json")
.write_str(r#"{
"schema": 1,
"name": "fmt",
"versions": {
"10.2.1": { "dependencies": {}, "yanked": false, "checksum": "sha256:0000000000000000000000000000000000000000000000000000000000000000" }
}
}"#)
.unwrap();
cabin()
.args(["update", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--package", "missing", "--index-path"])
.arg(dir.path().join("index"))
.assert()
.failure()
.stderr(predicate::str::contains(
"not a direct versioned dependency",
));
}
}
mod strict_nested_workspace_discovery {
use super::*;
#[test]
fn metadata_inside_nested_workspace_with_unrelated_outer_errors() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = []
"#,
)
.unwrap();
dir.child("nested/cabin.toml")
.write_str(
r#"[workspace]
members = []
"#,
)
.unwrap();
cabin()
.current_dir(dir.path().join("nested"))
.args(["metadata"])
.assert()
.failure()
.stderr(predicate::str::contains("nested workspace detected"));
}
#[test]
fn resolve_p_app_does_not_require_unrelated_dep_in_registry() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/*"]
"#,
)
.unwrap();
dir.child("packages/app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=10.0.0 <11.0.0"
"#,
)
.unwrap();
dir.child("packages/b/cabin.toml")
.write_str(
r#"[package]
name = "b"
version = "0.1.0"
[dependencies]
spdlog = "^1"
"#,
)
.unwrap();
dir.child("index/fmt.json")
.write_str(r#"{
"schema": 1,
"name": "fmt",
"versions": {
"10.2.1": { "dependencies": {}, "yanked": false, "checksum": "sha256:0000000000000000000000000000000000000000000000000000000000000000" }
}
}"#)
.unwrap();
cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--package", "app", "--index-path"])
.arg(dir.path().join("index"))
.assert()
.success();
}
#[test]
fn update_package_rejects_transitive() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
[dependencies]
fmt = ">=10.0.0 <11.0.0"
"#,
)
.unwrap();
dir.child("index/fmt.json")
.write_str(r#"{
"schema": 1,
"name": "fmt",
"versions": {
"10.2.1": { "dependencies": {}, "yanked": false, "checksum": "sha256:0000000000000000000000000000000000000000000000000000000000000000" }
}
}"#)
.unwrap();
cabin()
.args(["update", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--package", "spdlog", "--index-path"])
.arg(dir.path().join("index"))
.assert()
.failure()
.stderr(predicate::str::contains(
"only refreshes direct dependencies",
));
}
}
mod workspace_selection_followups {
use super::*;
const FMT_PKG_MANIFEST: &str = r#"[package]
name = "fmt"
version = "10.2.1"
[target.fmt]
type = "library"
sources = ["src/fmt.cc"]
include_dirs = ["include"]
"#;
const FMT_HEADER: &str = "#pragma once\nvoid say_hello();\n";
const FMT_SRC: &str = "#include <iostream>\n#include \"fmt.h\"\nvoid say_hello() { std::cout << \"hello\\n\"; }\n";
const APP_MAIN_USING_FMT: &str = "#include \"fmt.h\"\nint main() { say_hello(); return 0; }\n";
fn write_workspace_with_real_fmt_archive(root: &Path) -> String {
assert_fs::fixture::ChildPath::new(root.join("cabin.toml"))
.write_str(
r#"[workspace]
members = ["packages/*"]
"#,
)
.unwrap();
assert_fs::fixture::ChildPath::new(root.join("packages/app/cabin.toml"))
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[target.app]
type = "executable"
sources = ["src/main.cc"]
deps = ["fmt"]
[dependencies]
fmt = ">=10.0.0 <11.0.0"
"#,
)
.unwrap();
assert_fs::fixture::ChildPath::new(root.join("packages/app/src/main.cc"))
.write_str(APP_MAIN_USING_FMT)
.unwrap();
assert_fs::fixture::ChildPath::new(root.join("packages/b/cabin.toml"))
.write_str(
r#"[package]
name = "b"
version = "0.1.0"
[dependencies]
spdlog = "^1"
"#,
)
.unwrap();
let archive_path = root.join("artifacts/fmt-10.2.1.tar.gz");
let hex = make_archive(
&archive_path,
&[
("cabin.toml", FMT_PKG_MANIFEST),
("include/fmt.h", FMT_HEADER),
("src/fmt.cc", FMT_SRC),
],
);
let index_body = format!(
r#"{{
"schema": 1,
"name": "fmt",
"versions": {{
"10.2.1": {{
"dependencies": {{}},
"yanked": false,
"checksum": "sha256:{hex}",
"source": {{ "type": "archive", "path": "../artifacts/fmt-10.2.1.tar.gz", "format": "tar.gz" }}
}}
}}
}}"#
);
assert_fs::fixture::ChildPath::new(root.join("index/fmt.json"))
.write_str(&index_body)
.unwrap();
hex
}
#[test]
fn resolve_p_app_succeeds_when_unrelated_dep_missing_from_index() {
let dir = TempDir::new().unwrap();
write_workspace_with_real_fmt_archive(dir.path());
let assertion = cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--package", "app", "--index-path"])
.arg(dir.path().join("index"))
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
assert!(
stdout.contains("fmt"),
"resolve -p app should report `fmt` in its output: {stdout}"
);
}
#[test]
fn fetch_p_app_extracts_fmt_and_skips_unrelated_dep() {
let dir = TempDir::new().unwrap();
let hex = write_workspace_with_real_fmt_archive(dir.path());
let cache = dir.path().join("cache");
cabin()
.args(["fetch", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--package", "app", "--index-path"])
.arg(dir.path().join("index"))
.args(["--cache-dir"])
.arg(&cache)
.assert()
.success();
let archive_in_cache = cache.join("archives/sha256").join(format!("{hex}.tar.gz"));
assert!(
archive_in_cache.is_file(),
"fmt archive must be cached at {archive_in_cache:?}"
);
let source_in_cache = cache.join("sources/sha256").join(&hex);
assert!(
source_in_cache.join("cabin.toml").is_file(),
"fmt source must be extracted with cabin.toml at root"
);
let lock_path = dir.path().join("cabin.lock");
assert!(lock_path.is_file(), "workspace lockfile should be written");
let lock_body = fs::read_to_string(&lock_path).unwrap();
assert!(
lock_body.contains(r#"name = "fmt""#),
"lockfile must pin fmt: {lock_body}"
);
assert!(
lock_body.contains(&format!("checksum = \"sha256:{hex}\"")),
"lockfile must record fmt's archive checksum: {lock_body}"
);
assert!(
!lock_body.contains("spdlog"),
"selection-aware fetch must not pin spdlog: {lock_body}"
);
}
#[test]
fn build_p_app_links_against_real_fmt_archive() {
skip_if!(
!build_tools_available(),
"workspace_semantics.7 build -p app selection-aware",
"ninja or C++ compiler missing"
);
let dir = TempDir::new().unwrap();
write_workspace_with_real_fmt_archive(dir.path());
let build_dir = dir.path().join("build");
let cache = dir.path().join("cache");
cabin()
.args(["build", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--package", "app", "--build-dir"])
.arg(&build_dir)
.args(["--cache-dir"])
.arg(&cache)
.args(["--index-path"])
.arg(dir.path().join("index"))
.assert()
.success();
let app_exe = build_dir.join("dev/packages/app/app");
assert!(
app_exe.is_file(),
"app executable must be produced at {app_exe:?}"
);
}
#[test]
fn unsafe_package_name_in_manifest_rejected_before_http_url() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "foo?bar"
version = "0.1.0"
"#,
)
.unwrap();
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("\"foo?bar\""),
"error must echo the offending name: {stderr}"
);
assert!(
stderr.contains("ASCII letters") && stderr.contains("ASCII digits"),
"error must describe the allowed alphabet: {stderr}"
);
}
#[test]
fn unsafe_dep_name_in_manifest_rejected_before_http_url() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str("[package]\nname = \"demo\"\nversion = \"0.1.0\"\n\n[dependencies]\n\"foo#bar\" = \"1.0.0\"\n")
.unwrap();
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("foo#bar"),
"expected error mentioning unsafe dep name foo#bar; stderr was: {stderr}"
);
}
}
mod dependency_kinds {
use super::*;
const MIXED_KINDS_MANIFEST: &str = r#"[package]
name = "demo"
version = "0.1.0"
[dependencies]
fmt = ">=10"
zlib = { version = ">=1.2", system = true }
openssl = { version = ">=3", system = true }
[dev-dependencies]
gtest = "^1.14"
"#;
fn dep_entry<'a>(
package: &'a serde_json::Value,
name: &str,
kind: &str,
) -> &'a serde_json::Value {
package["dependencies"]
.as_array()
.expect("dependencies array")
.iter()
.find(|d| d["name"] == name && d["dependency_kind"] == kind)
.unwrap_or_else(|| panic!("dep {name:?} of kind {kind:?} not found in {package}"))
}
#[test]
fn metadata_lists_every_dependency_kind_with_explicit_kind_field() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(MIXED_KINDS_MANIFEST)
.unwrap();
let value = run_metadata(&dir.path().join("cabin.toml"));
let demo = package_in(&value, "demo");
for (name, kind) in [("fmt", "normal"), ("gtest", "dev")] {
let dep = dep_entry(demo, name, kind);
assert_eq!(dep["kind"], "version", "{name} should be a version source");
}
let system = demo["system_dependencies"]
.as_array()
.expect("system_dependencies array");
assert_eq!(system.len(), 2);
let by_name: std::collections::BTreeMap<&str, &serde_json::Value> = system
.iter()
.map(|sd| (sd["name"].as_str().expect("system dep name"), sd))
.collect();
assert_eq!(by_name["zlib"]["version"], ">=1.2");
assert!(
by_name["zlib"].get("required").is_none(),
"system dep metadata must not expose a `required` field: {:?}",
by_name["zlib"],
);
assert_eq!(by_name["zlib"]["dependency_kind"], "normal");
assert_eq!(by_name["openssl"]["version"], ">=3");
assert!(by_name["openssl"].get("required").is_none());
assert_eq!(by_name["openssl"]["dependency_kind"], "normal");
}
#[test]
fn metadata_dependency_listing_is_sorted_by_kind_then_name() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(MIXED_KINDS_MANIFEST)
.unwrap();
let value = run_metadata(&dir.path().join("cabin.toml"));
let demo = package_in(&value, "demo");
let listed: Vec<(String, String)> = demo["dependencies"]
.as_array()
.unwrap()
.iter()
.map(|d| {
(
d["dependency_kind"].as_str().unwrap().to_owned(),
d["name"].as_str().unwrap().to_owned(),
)
})
.collect();
assert_eq!(
listed,
vec![
("normal".into(), "fmt".into()),
("dev".into(), "gtest".into()),
]
);
}
#[test]
fn metadata_keeps_existing_shape_for_dependencies_only_manifest() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=10"
"#,
)
.unwrap();
let value = run_metadata(&dir.path().join("cabin.toml"));
let app = package_in(&value, "app");
assert!(
app["system_dependencies"].is_null(),
"system_dependencies must be omitted when empty: got {app}"
);
let deps = app["dependencies"].as_array().unwrap();
assert_eq!(deps.len(), 1);
assert_eq!(deps[0]["name"], "fmt");
assert_eq!(deps[0]["dependency_kind"], "normal");
assert_eq!(deps[0]["kind"], "version");
}
#[test]
fn resolve_excludes_dev_dependencies() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=10 <11"
[dev-dependencies]
gtest = "^1.14"
"#,
)
.unwrap();
write_index_entry_no_source(&dir.path().join("index"), "fmt", "10.2.1", &"0".repeat(64));
let assertion = cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--index-path"])
.arg(dir.path().join("index"))
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
assert!(stdout.contains("fmt"), "fmt should appear: {stdout}");
assert!(
!stdout.contains("gtest"),
"dev dep gtest must not enter ordinary resolution: {stdout}"
);
}
#[test]
fn resolve_does_not_send_system_dependencies_to_resolver() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=10"
zlib = { version = ">=1.2", system = true }
"#,
)
.unwrap();
write_index_entry_no_source(&dir.path().join("index"), "fmt", "10.2.1", &"0".repeat(64));
let assertion = cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--index-path"])
.arg(dir.path().join("index"))
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
assert!(stdout.contains("fmt"));
assert!(
!stdout.contains("zlib"),
"system dep zlib must not appear in resolver output: {stdout}"
);
}
#[test]
fn optional_dependency_in_system_section_is_rejected_at_cli() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
zlib = { version = ">=1.2", system = true, optional = true }
"#,
)
.unwrap();
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("optional"),
"expected an error mentioning the unsupported optional system dep, got: {stderr}"
);
}
#[test]
fn workspace_inheritance_per_kind_is_validated_kind_specifically() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/app"]
[workspace.dependencies]
fmt = ">=10"
"#,
)
.unwrap();
dir.child("packages/app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dev-dependencies]
fmt = { workspace = true }
"#,
)
.unwrap();
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("[workspace.dev-dependencies]"),
"error should name the missing workspace table: {stderr}"
);
assert!(
stderr.contains("[dev-dependencies]"),
"error should name the declaring section: {stderr}"
);
}
#[test]
fn package_metadata_round_trips_every_dependency_kind() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(MIXED_KINDS_MANIFEST)
.unwrap();
let out = dir.path().join("dist");
cabin()
.args(["package", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--output-dir"])
.arg(&out)
.assert()
.success();
let metadata_path = out.join("demo-0.1.0.json");
let metadata: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&metadata_path).unwrap()).unwrap();
assert_eq!(metadata["dependencies"]["fmt"].as_str().unwrap(), ">=10");
assert_eq!(
metadata["dev-dependencies"]["gtest"].as_str().unwrap(),
"^1.14"
);
let zlib = &metadata["system-dependencies"]["zlib"];
assert_eq!(zlib["version"].as_str().unwrap(), ">=1.2");
assert!(
zlib.get("required").is_none(),
"canonical metadata must not carry `required`: {zlib:?}",
);
assert_eq!(zlib["dependency_kind"].as_str().unwrap(), "normal");
let openssl = &metadata["system-dependencies"]["openssl"];
assert_eq!(openssl["version"].as_str().unwrap(), ">=3");
assert!(openssl.get("required").is_none());
assert_eq!(openssl["dependency_kind"].as_str().unwrap(), "normal");
}
}
mod optional_dependencies_and_features {
use super::*;
fn write_app_with_optional_openssl(root: &Path) {
assert_fs::fixture::ChildPath::new(root.join("cabin.toml"))
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[features]
default = []
ssl = ["dep:openssl"]
[dependencies]
fmt = ">=10 <11"
openssl = { version = "^3", optional = true }
"#,
)
.unwrap();
write_index_entry_no_source(&root.join("index"), "fmt", "10.2.1", &"0".repeat(64));
write_index_entry_no_source(&root.join("index"), "openssl", "3.2.0", &"0".repeat(64));
}
#[test]
fn resolve_without_feature_skips_optional_dep() {
let dir = TempDir::new().unwrap();
write_app_with_optional_openssl(dir.path());
let assertion = cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--index-path"])
.arg(dir.path().join("index"))
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
assert!(stdout.contains("fmt"), "fmt should appear: {stdout}");
assert!(
!stdout.contains("openssl"),
"disabled optional dep openssl must not appear: {stdout}"
);
}
#[test]
fn resolve_with_feature_includes_optional_dep() {
let dir = TempDir::new().unwrap();
write_app_with_optional_openssl(dir.path());
let assertion = cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--index-path"])
.arg(dir.path().join("index"))
.args(["--features", "ssl"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
assert!(
stdout.contains("openssl"),
"feature ssl should pull openssl in: {stdout}"
);
}
#[test]
fn resolve_no_default_features_disables_root_default_chain() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[features]
default = ["ssl"]
ssl = ["dep:openssl"]
[dependencies]
openssl = { version = "^3", optional = true }
"#,
)
.unwrap();
write_index_entry_no_source(
&dir.path().join("index"),
"openssl",
"3.2.0",
&"0".repeat(64),
);
let with_default = cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--index-path"])
.arg(dir.path().join("index"))
.assert()
.success();
assert!(String::from_utf8_lossy(&with_default.get_output().stdout).contains("openssl"));
let no_default = cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--index-path"])
.arg(dir.path().join("index"))
.args(["--no-default-features"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&no_default.get_output().stdout);
assert!(
!stdout.contains("openssl"),
"no-default-features must drop openssl: {stdout}"
);
}
#[test]
fn fetch_does_not_pull_disabled_optional_into_lockfile() {
let dir = TempDir::new().unwrap();
write_app_with_optional_openssl(dir.path());
cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--index-path"])
.arg(dir.path().join("index"))
.assert()
.success();
let lock_body = fs::read_to_string(dir.path().join("cabin.lock")).unwrap();
assert!(
lock_body.contains("fmt"),
"fmt should be locked: {lock_body}"
);
assert!(
!lock_body.contains("openssl"),
"disabled optional openssl must not be locked: {lock_body}"
);
}
#[test]
fn metadata_round_trips_optional_and_features_in_dependency_view() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
[dependencies]
fmt = { version = ">=10", features = ["compile"], default-features = false }
openssl = { version = "^3", optional = true }
"#,
)
.unwrap();
let value = run_metadata(&dir.path().join("cabin.toml"));
let demo = package_in(&value, "demo");
let deps = demo["dependencies"].as_array().unwrap();
let openssl = deps
.iter()
.find(|d| d["name"] == "openssl")
.expect("openssl listed");
assert_eq!(openssl["optional"].as_bool(), Some(true));
let fmt = deps.iter().find(|d| d["name"] == "fmt").unwrap();
assert_eq!(fmt["default_features"].as_bool(), Some(false));
let features: Vec<&str> = fmt["features"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(features, vec!["compile"]);
}
#[test]
fn package_metadata_round_trips_optional_and_features() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
[dependencies]
fmt = ">=10"
openssl = { version = "^3", optional = true }
"#,
)
.unwrap();
let out = dir.path().join("dist");
cabin()
.args(["package", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--output-dir"])
.arg(&out)
.assert()
.success();
let metadata: serde_json::Value =
serde_json::from_str(&fs::read_to_string(out.join("demo-0.1.0.json")).unwrap())
.unwrap();
assert!(metadata["dependencies"]["fmt"].is_string());
let openssl = &metadata["dependencies"]["openssl"];
assert_eq!(openssl["version"].as_str().unwrap(), "^3");
assert!(openssl["optional"].as_bool().unwrap());
}
#[test]
fn unknown_root_feature_errors_clearly_at_cli() {
let dir = TempDir::new().unwrap();
write_app_with_optional_openssl(dir.path());
let assertion = cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--index-path"])
.arg(dir.path().join("index"))
.args(["--features", "no-such-feature"])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("unknown feature") && stderr.contains("no-such-feature"),
"expected unknown-feature error, got: {stderr}"
);
}
#[test]
fn dep_colon_on_non_optional_dep_is_rejected_at_cli() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
[features]
ssl = ["dep:fmt"]
[dependencies]
fmt = ">=10"
"#,
)
.unwrap();
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--features", "ssl"])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("not optional") || stderr.contains("DepIsNotOptional"),
"expected non-optional dep error, got: {stderr}"
);
}
}
mod target_dependencies {
use super::*;
fn host_os_value() -> &'static str {
std::env::consts::OS
}
fn other_os_value() -> &'static str {
if std::env::consts::OS == "linux" {
"macos"
} else {
"linux"
}
}
#[test]
fn metadata_reports_target_platform_and_active_flag() {
let dir = TempDir::new().unwrap();
let manifest = format!(
r#"[package]
name = "app"
version = "0.1.0"
[target.'cfg(os = "{host}")'.dependencies]
fmt = ">=10"
[target.'cfg(os = "{other}")'.dependencies]
spdlog = "^1"
"#,
host = host_os_value(),
other = other_os_value(),
);
dir.child("cabin.toml").write_str(&manifest).unwrap();
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let target_platform = &value["target_platform"];
assert_eq!(target_platform["os"].as_str().unwrap(), host_os_value());
let deps = value["packages"][0]["dependencies"].as_array().unwrap();
let fmt = deps.iter().find(|d| d["name"] == "fmt").unwrap();
assert_eq!(fmt["active"].as_bool(), Some(true));
assert!(fmt["target"].as_str().unwrap().contains("os ="));
let spdlog = deps.iter().find(|d| d["name"] == "spdlog").unwrap();
assert_eq!(spdlog["active"].as_bool(), Some(false));
}
#[test]
fn resolve_filters_inactive_target_dependency() {
let dir = TempDir::new().unwrap();
let manifest = format!(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=10"
[target.'cfg(os = "{other}")'.dependencies]
spdlog = "^1"
"#,
other = other_os_value(),
);
dir.child("cabin.toml").write_str(&manifest).unwrap();
write_index_entry_no_source(&dir.path().join("index"), "fmt", "10.2.1", &"0".repeat(64));
let assertion = cabin()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--index-path"])
.arg(dir.path().join("index"))
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
assert!(stdout.contains("fmt"), "fmt should resolve: {stdout}");
assert!(
!stdout.contains("spdlog"),
"inactive spdlog must not enter resolution: {stdout}",
);
}
#[test]
fn package_metadata_round_trips_target_field() {
let dir = TempDir::new().unwrap();
let manifest = format!(
r#"[package]
name = "demo"
version = "0.1.0"
[target.'cfg(os = "{host}")'.dependencies]
fmt = ">=10"
"#,
host = host_os_value(),
);
dir.child("cabin.toml").write_str(&manifest).unwrap();
let out = dir.path().join("dist");
cabin()
.args(["package", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--output-dir"])
.arg(&out)
.assert()
.success();
let metadata: serde_json::Value =
serde_json::from_str(&fs::read_to_string(out.join("demo-0.1.0.json")).unwrap())
.unwrap();
let fmt = &metadata["dependencies"]["fmt"];
assert!(fmt.is_object(), "expected rich table: {fmt}");
assert_eq!(fmt["version"].as_str().unwrap(), ">=10");
assert!(fmt["target"].as_str().unwrap().contains("os ="));
}
#[test]
fn workspace_inheritance_inside_target_cfg_is_rejected() {
let dir = TempDir::new().unwrap();
let manifest = format!(
r#"[package]
name = "app"
version = "0.1.0"
[target.'cfg(os = "{host}")'.dependencies]
fmt = {{ workspace = true }}
"#,
host = host_os_value(),
);
dir.child("cabin.toml").write_str(&manifest).unwrap();
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("workspace") && stderr.contains("cfg"),
"expected workspace-inside-cfg rejection, got: {stderr}",
);
}
#[test]
fn invalid_cfg_predicate_is_rejected_with_clear_error() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[target.'cfg(host_endian = "little")'.dependencies]
fmt = ">=10"
"#,
)
.unwrap();
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("host_endian") || stderr.contains("cfg"),
"expected cfg parse error, got: {stderr}",
);
}
}
mod profiles {
use super::*;
#[test]
fn metadata_reports_default_dev_profile_when_unselected() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
"#,
)
.unwrap();
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let selected = &value["profiles"]["selected"];
assert_eq!(selected["name"].as_str(), Some("dev"));
assert_eq!(selected["debug"].as_bool(), Some(true));
assert_eq!(selected["opt_level"].as_str(), Some("0"));
assert_eq!(selected["assertions"].as_bool(), Some(true));
assert_eq!(selected["source"].as_str(), Some("builtin"));
let available: Vec<&str> = value["profiles"]["available"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert_eq!(available, vec!["dev", "release"]);
}
#[test]
fn metadata_reports_release_when_selected() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
"#,
)
.unwrap();
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--profile", "release"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let selected = &value["profiles"]["selected"];
assert_eq!(selected["name"].as_str(), Some("release"));
assert_eq!(selected["opt_level"].as_str(), Some("3"));
assert_eq!(selected["debug"].as_bool(), Some(false));
assert_eq!(selected["assertions"].as_bool(), Some(false));
}
#[test]
fn metadata_reports_custom_profile_definitions_and_resolved_fields() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
[profile.relwithdebinfo]
inherits = "release"
debug = true
"#,
)
.unwrap();
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--profile", "relwithdebinfo"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let selected = &value["profiles"]["selected"];
assert_eq!(selected["name"].as_str(), Some("relwithdebinfo"));
assert_eq!(selected["opt_level"].as_str(), Some("3"));
assert_eq!(selected["debug"].as_bool(), Some(true));
assert_eq!(selected["source"].as_str(), Some("custom"));
let chain: Vec<&str> = selected["inherits_chain"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert_eq!(chain, vec!["release", "relwithdebinfo"]);
let available: Vec<&str> = value["profiles"]["available"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert_eq!(available, vec!["dev", "release", "relwithdebinfo"]);
assert!(
value["profiles"]["definitions"]["relwithdebinfo"].is_object(),
"manifest definition preserved",
);
}
#[test]
fn unknown_profile_errors_clearly_at_cli() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
"#,
)
.unwrap();
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--profile", "fastdebug"])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("unknown profile") && stderr.contains("fastdebug"),
"expected unknown-profile error, got: {stderr}"
);
}
#[test]
fn invalid_profile_name_errors_clearly_at_cli() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
"#,
)
.unwrap();
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--profile", ".release"])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("invalid profile name"),
"expected invalid-profile-name error, got: {stderr}"
);
}
#[test]
fn release_flag_and_profile_flag_conflict() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
[target.demo]
type = "executable"
sources = ["src/main.cc"]
"#,
)
.unwrap();
dir.child("src/main.cc").write_str(HELLO_MAIN_CC).unwrap();
let assertion = cabin()
.args(["build", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--release", "--profile", "release"])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("cannot be used with") || stderr.contains("conflicts"),
"expected clap conflict error, got: {stderr}"
);
}
#[test]
fn dev_and_release_use_distinct_output_directories() {
skip_if!(
!build_tools_available(),
"dev_and_release_use_distinct_output_directories",
"ninja or a C++ compiler is not available"
);
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "hello"
version = "0.1.0"
[target.hello]
type = "executable"
sources = ["src/main.cc"]
"#,
)
.unwrap();
dir.child("src/main.cc").write_str(HELLO_MAIN_CC).unwrap();
let build_dir = dir.path().join("build");
cabin()
.current_dir(dir.path())
.args(["build", "--build-dir"])
.arg(&build_dir)
.assert()
.success();
cabin()
.current_dir(dir.path())
.args(["build", "--release", "--build-dir"])
.arg(&build_dir)
.assert()
.success();
assert!(build_dir.join("dev").join("build.ninja").is_file());
assert!(build_dir.join("release").join("build.ninja").is_file());
assert!(
build_dir
.join("dev")
.join("packages")
.join("hello")
.join("hello")
.is_file()
);
assert!(
build_dir
.join("release")
.join("packages")
.join("hello")
.join("hello")
.is_file()
);
let dev_cc =
std::fs::read_to_string(build_dir.join("dev").join("compile_commands.json")).unwrap();
let release_cc =
std::fs::read_to_string(build_dir.join("release").join("compile_commands.json"))
.unwrap();
assert!(dev_cc.contains("-O0") && dev_cc.contains("-g"));
assert!(release_cc.contains("-O3") && release_cc.contains("-DNDEBUG"));
}
#[test]
fn custom_profile_uses_its_own_output_directory() {
skip_if!(
!build_tools_available(),
"custom_profile_uses_its_own_output_directory",
"ninja or a C++ compiler is not available"
);
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "hello"
version = "0.1.0"
[target.hello]
type = "executable"
sources = ["src/main.cc"]
[profile.relwithdebinfo]
inherits = "release"
debug = true
"#,
)
.unwrap();
dir.child("src/main.cc").write_str(HELLO_MAIN_CC).unwrap();
let build_dir = dir.path().join("build");
cabin()
.current_dir(dir.path())
.args(["build", "--profile", "relwithdebinfo", "--build-dir"])
.arg(&build_dir)
.assert()
.success();
let cc = std::fs::read_to_string(
build_dir
.join("relwithdebinfo")
.join("compile_commands.json"),
)
.unwrap();
assert!(cc.contains("-O3"), "{cc}");
assert!(cc.contains("-DNDEBUG"), "{cc}");
assert!(cc.contains("-g"), "{cc}");
}
#[test]
fn metadata_build_config_appends_inherited_profile_flags() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
[profile]
cxxflags = ["-Wall"]
[profile.release]
cxxflags = ["-O3"]
[profile.bench]
inherits = "release"
cxxflags = ["-pg"]
"#,
)
.unwrap();
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--profile", "bench"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let per_package = value["toolchain"]["build_flags_per_package"]
.as_object()
.expect("toolchain.build_flags_per_package object");
let pkg = per_package
.values()
.next()
.expect("at least one package with build flags");
let cxx: Vec<String> = pkg["cxxflags"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_owned())
.collect();
assert_eq!(
cxx,
vec!["-Wall".to_owned(), "-O3".to_owned(), "-pg".to_owned(),],
"[profile] → inherited parent → selected must append in that order",
);
}
#[test]
fn metadata_build_config_orders_all_four_layers() {
let host_os = std::env::consts::OS;
let dir = TempDir::new().unwrap();
let manifest = format!(
r#"[package]
name = "demo"
version = "0.1.0"
[profile]
cxxflags = ["-Wall"]
[target.'cfg(os = "{host_os}")'.profile]
cxxflags = ["-DCFG"]
[profile.release]
cxxflags = ["-O3"]
[profile.bench]
inherits = "release"
cxxflags = ["-pg"]
"#
);
dir.child("cabin.toml").write_str(&manifest).unwrap();
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--profile", "bench"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let pkg = value["toolchain"]["build_flags_per_package"]
.as_object()
.expect("toolchain.build_flags_per_package object")
.values()
.next()
.expect("at least one package with build flags");
let cxx: Vec<String> = pkg["cxxflags"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_owned())
.collect();
assert_eq!(
cxx,
vec![
"-Wall".to_owned(),
"-DCFG".to_owned(),
"-O3".to_owned(),
"-pg".to_owned(),
],
"documented order: [profile] → cfg → inherited → selected",
);
}
#[test]
fn old_manifest_without_profile_tables_still_metadata_works() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "old"
version = "0.1.0"
[dependencies]
fmt = ">=10"
"#,
)
.unwrap();
cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.success();
}
}
mod toolchain {
use super::*;
use std::path::PathBuf;
#[cfg(unix)]
fn fake_tool(dir: &Path, name: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let path = dir.join(name);
assert_fs::fixture::ChildPath::new(&path)
.write_str("#!/bin/sh\nexit 0\n")
.unwrap();
let mut perms = fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&path, perms).unwrap();
path
}
#[cfg(unix)]
#[test]
fn metadata_reports_default_toolchain_source() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
"#,
)
.unwrap();
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let cxx = &value["toolchain"]["tools"]["cxx"];
assert_eq!(cxx["kind"].as_str(), Some("cxx"));
assert_eq!(cxx["source"].as_str(), Some("default"));
}
#[test]
fn metadata_requires_resolvable_cxx_before_detection() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
"#,
)
.unwrap();
let empty_path = TempDir::new().unwrap();
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.env("PATH", empty_path.path())
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("no usable C++ compiler found on PATH"),
"expected missing-CXX diagnostic, got: {stderr}"
);
}
#[cfg(unix)]
#[test]
fn cli_cxx_flag_overrides_default() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
"#,
)
.unwrap();
let bin = TempDir::new().unwrap();
let cxx = fake_tool(bin.path(), "my-cxx");
let _ar = fake_tool(bin.path(), "ar");
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--cxx"])
.arg(&cxx)
.env("PATH", bin.path())
.env_remove("CXX")
.env_remove("CC")
.env_remove("AR")
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let entry = &value["toolchain"]["tools"]["cxx"];
assert_eq!(entry["source"].as_str(), Some("cli"));
assert_eq!(entry["spec"].as_str().unwrap(), cxx.to_str().unwrap());
}
#[cfg(unix)]
#[test]
fn cxx_env_var_is_respected_when_no_cli_flag() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
"#,
)
.unwrap();
let bin = TempDir::new().unwrap();
let cxx = fake_tool(bin.path(), "env-cxx");
let _ar = fake_tool(bin.path(), "ar");
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.env("PATH", bin.path())
.env("CXX", &cxx)
.env_remove("CC")
.env_remove("AR")
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let entry = &value["toolchain"]["tools"]["cxx"];
assert_eq!(entry["source"].as_str(), Some("env"));
}
#[cfg(unix)]
#[test]
fn missing_explicit_cxx_errors_clearly() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
"#,
)
.unwrap();
let bin = TempDir::new().unwrap();
let _ar = fake_tool(bin.path(), "ar");
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--cxx", "definitely-not-a-real-compiler-99"])
.env("PATH", bin.path())
.env_remove("CXX")
.env_remove("CC")
.env_remove("AR")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("definitely-not-a-real-compiler-99"),
"expected toolchain error mentioning the spec, got: {stderr}",
);
assert!(
stderr.contains("could not be found"),
"expected `could not be found` wording, got: {stderr}",
);
}
#[cfg(unix)]
#[test]
fn manifest_toolchain_table_is_honored_when_no_cli_or_env() {
let dir = TempDir::new().unwrap();
let bin = TempDir::new().unwrap();
let _g = fake_tool(bin.path(), "g++");
let _c = fake_tool(bin.path(), "clang++");
let _ar = fake_tool(bin.path(), "ar");
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
[toolchain]
cxx = "clang++"
"#,
)
.unwrap();
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.env("PATH", bin.path())
.env_remove("CXX")
.env_remove("CC")
.env_remove("AR")
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let entry = &value["toolchain"]["tools"]["cxx"];
assert_eq!(entry["source"].as_str(), Some("manifest"));
assert_eq!(entry["spec"].as_str(), Some("clang++"));
}
#[test]
fn unsupported_toolchain_field_is_rejected() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
[toolchain]
compiler-family = "clang"
"#,
)
.unwrap();
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("compiler-family"),
"expected unknown-field error mentioning compiler-family, got: {stderr}",
);
}
#[test]
fn invalid_include_path_with_parent_traversal_is_rejected() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
[profile]
include-dirs = ["../sneaky"]
"#,
)
.unwrap();
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("..") || stderr.contains("sneaky"),
"expected include-dir traversal rejection, got: {stderr}",
);
}
#[cfg(unix)]
#[test]
fn target_conditioned_build_flags_apply_to_compile_commands() {
skip_if!(
!build_tools_available(),
"target_conditioned_build_flags_apply_to_compile_commands",
"ninja or a C++ compiler is not available"
);
let host_os = std::env::consts::OS;
let other_os = if host_os == "linux" { "macos" } else { "linux" };
let dir = TempDir::new().unwrap();
let manifest = format!(
r#"[package]
name = "hello"
version = "0.1.0"
[target.hello]
type = "executable"
sources = ["src/main.cc"]
[target.'cfg(os = "{host_os}")'.profile]
defines = ["CABIN_HOST_MATCHED"]
[target.'cfg(os = "{other_os}")'.profile]
defines = ["CABIN_HOST_NOT_MATCHED"]
"#
);
dir.child("cabin.toml").write_str(&manifest).unwrap();
dir.child("src/main.cc").write_str(HELLO_MAIN_CC).unwrap();
let build_dir = dir.path().join("build");
cabin()
.current_dir(dir.path())
.args(["build", "--build-dir"])
.arg(&build_dir)
.assert()
.success();
let cc =
std::fs::read_to_string(build_dir.join("dev").join("compile_commands.json")).unwrap();
assert!(
cc.contains("-DCABIN_HOST_MATCHED"),
"expected matching cfg define present: {cc}"
);
assert!(
!cc.contains("CABIN_HOST_NOT_MATCHED"),
"expected non-matching cfg define absent: {cc}"
);
}
#[cfg(unix)]
#[test]
fn build_includes_dirs_from_build_table() {
skip_if!(
!build_tools_available(),
"build_includes_dirs_from_build_table",
"ninja or a C++ compiler is not available"
);
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "hello"
version = "0.1.0"
[target.hello]
type = "executable"
sources = ["src/main.cc"]
[profile]
defines = ["CABIN_BUILD_DEFINE"]
include-dirs = ["include"]
"#,
)
.unwrap();
dir.child("src/main.cc").write_str(HELLO_MAIN_CC).unwrap();
dir.child("include/.gitkeep").write_str("").unwrap();
let build_dir = dir.path().join("build");
cabin()
.current_dir(dir.path())
.args(["build", "--build-dir"])
.arg(&build_dir)
.assert()
.success();
let cc =
std::fs::read_to_string(build_dir.join("dev").join("compile_commands.json")).unwrap();
assert!(
cc.contains("-DCABIN_BUILD_DEFINE"),
"expected build define in compile_commands: {cc}"
);
assert!(
cc.contains("/include"),
"expected include dir in compile_commands: {cc}"
);
}
#[test]
fn member_manifest_with_toolchain_table_is_rejected() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/app"]
"#,
)
.unwrap();
dir.child("packages/app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[toolchain]
cxx = "clang++"
"#,
)
.unwrap();
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("toolchain"),
"expected member-toolchain rejection, got: {stderr}",
);
}
}
mod compiler_detection {
use super::*;
use std::path::PathBuf;
#[cfg(unix)]
fn fake_tool_with_output(
dir: &Path,
name: &str,
stdout: &str,
stderr: &str,
status: i32,
) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let path = dir.join(name);
let escaped_stdout = stdout.replace('\'', "'\\''");
let escaped_stderr = stderr.replace('\'', "'\\''");
let script = format!(
"#!/bin/sh\nprintf '%s' '{escaped_stdout}'\nprintf '%s' '{escaped_stderr}' >&2\nexit {status}\n"
);
assert_fs::fixture::ChildPath::new(&path)
.write_str(&script)
.unwrap();
let mut perms = fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&path, perms).unwrap();
path
}
#[cfg(unix)]
#[test]
fn metadata_reports_detected_clang_identity() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
"#,
)
.unwrap();
let bin = TempDir::new().unwrap();
let cxx = fake_tool_with_output(
bin.path(),
"fake-clang++",
"clang version 17.0.6\nTarget: x86_64-unknown-linux-gnu\nThread model: posix\n",
"",
0,
);
let _ar = fake_tool_with_output(bin.path(), "ar", "GNU ar (GNU Binutils) 2.40\n", "", 0);
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--cxx"])
.arg(&cxx)
.env("PATH", bin.path())
.env_remove("CXX")
.env_remove("CC")
.env_remove("AR")
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let detected = &value["toolchain"]["detected"];
assert_eq!(detected["cxx"]["identity"]["kind"].as_str(), Some("clang"));
assert_eq!(
detected["cxx"]["identity"]["version"].as_str(),
Some("17.0.6")
);
assert!(
detected["cxx"]["capabilities"]["gcc_style_flags"]["supported"]
.as_bool()
.unwrap()
);
assert_eq!(detected["ar"]["identity"]["kind"].as_str(), Some("ar"));
}
#[cfg(unix)]
#[test]
fn build_with_msvc_compiler_errors_clearly() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
[target.demo]
type = "executable"
sources = ["src/main.cc"]
"#,
)
.unwrap();
dir.child("src/main.cc").write_str(HELLO_MAIN_CC).unwrap();
let bin = TempDir::new().unwrap();
let cxx = fake_tool_with_output(
bin.path(),
"fake-cl",
"Microsoft (R) C/C++ Optimizing Compiler Version 19.39.0 for x64\n",
"",
0,
);
let _ar = fake_tool_with_output(bin.path(), "ar", "GNU ar (GNU Binutils) 2.40\n", "", 0);
let assertion = cabin()
.current_dir(dir.path())
.args(["build", "--cxx"])
.arg(&cxx)
.env("PATH", bin.path())
.env_remove("CXX")
.env_remove("CC")
.env_remove("AR")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("MSVC") || stderr.contains("GCC- or Clang-like"),
"expected MSVC unsupported error, got: {stderr}"
);
}
#[cfg(unix)]
#[test]
fn build_with_unknown_compiler_errors_clearly() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
[target.demo]
type = "executable"
sources = ["src/main.cc"]
"#,
)
.unwrap();
dir.child("src/main.cc").write_str(HELLO_MAIN_CC).unwrap();
let bin = TempDir::new().unwrap();
let cxx = fake_tool_with_output(
bin.path(),
"fake-funky-cxx",
"my funky compiler 0.1\n",
"",
0,
);
let _ar = fake_tool_with_output(bin.path(), "ar", "GNU ar (GNU Binutils) 2.40\n", "", 0);
let assertion = cabin()
.current_dir(dir.path())
.args(["build", "--cxx"])
.arg(&cxx)
.env("PATH", bin.path())
.env_remove("CXX")
.env_remove("CC")
.env_remove("AR")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("could not be identified"),
"expected unknown-compiler error, got: {stderr}"
);
}
#[cfg(unix)]
#[test]
fn package_metadata_does_not_serialize_local_detection() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
[target.demo]
type = "library"
sources = ["src/lib.cc"]
"#,
)
.unwrap();
dir.child("src/lib.cc")
.write_str("int demo() { return 0; }\n")
.unwrap();
let out = dir.path().join("dist");
cabin()
.args(["package", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--output-dir"])
.arg(&out)
.assert()
.success();
let body = fs::read_to_string(out.join("demo-0.1.0.json")).unwrap();
assert!(!body.contains("CABIN_CXX_COMPILER_KIND"), "{body}");
assert!(!body.contains("\"detected\""), "{body}");
assert!(!body.contains("clang version"), "{body}");
assert!(!body.contains("Apple clang"), "{body}");
}
#[cfg(unix)]
#[test]
fn metadata_toolchain_block_is_a_stable_golden_for_a_fixed_toolchain() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
"#,
)
.unwrap();
let bin = TempDir::new().unwrap();
let cxx = fake_tool_with_output(
bin.path(),
"fake-clang++",
"clang version 17.0.6\nTarget: x86_64-unknown-linux-gnu\nThread model: posix\n",
"",
0,
);
let ar =
fake_tool_with_output(bin.path(), "fake-ar", "GNU ar (GNU Binutils) 2.40\n", "", 0);
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--cxx"])
.arg(&cxx)
.args(["--ar"])
.arg(&ar)
.env("PATH", bin.path())
.env_remove("CXX")
.env_remove("CC")
.env_remove("AR")
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).into_owned();
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let cxx_str = cxx.to_string_lossy().into_owned();
let ar_str = ar.to_string_lossy().into_owned();
let detected = serde_json::to_string_pretty(&value["toolchain"]["detected"]).unwrap();
let detected = detected.replace(&cxx_str, "<CXX>").replace(&ar_str, "<AR>");
let expected = r#"{
"cxx": {
"path": "<CXX>",
"identity": {
"kind": "clang",
"version": "17.0.6",
"target": "x86_64-unknown-linux-gnu",
"raw_version_line": "clang version 17.0.6"
},
"capabilities": {
"color_diagnostics_flag": {
"supported": true,
"source": "version"
},
"cxx_standard_17": {
"supported": true,
"source": "version"
},
"depfile_mmd_mf": {
"supported": true,
"source": "version"
},
"gcc_style_flags": {
"supported": true,
"source": "version"
},
"json_diagnostics": {
"supported": true,
"source": "version"
},
"msvc_style_flags": {
"supported": false,
"source": "assumed-default"
},
"response_files": {
"supported": true,
"source": "version"
},
"sarif_diagnostics": {
"supported": false,
"source": "assumed-default"
},
"std_flag": {
"supported": true,
"source": "version"
}
}
},
"ar": {
"path": "<AR>",
"identity": {
"kind": "ar",
"version": "2.40",
"raw_version_line": "GNU ar (GNU Binutils) 2.40"
},
"capabilities": {
"ar_crs": {
"supported": true,
"source": "version"
},
"static_library_output": {
"supported": true,
"source": "version"
}
}
}
}"#;
assert_eq!(detected, expected);
}
}
mod compiler_cache {
use super::*;
use std::path::PathBuf;
#[cfg(unix)]
fn fake_tool_with_output(
dir: &Path,
name: &str,
stdout: &str,
stderr: &str,
status: i32,
) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let path = dir.join(name);
let escaped_stdout = stdout.replace('\'', "'\\''");
let escaped_stderr = stderr.replace('\'', "'\\''");
let script = format!(
"#!/bin/sh\nprintf '%s' '{escaped_stdout}'\nprintf '%s' '{escaped_stderr}' >&2\nexit {status}\n"
);
assert_fs::fixture::ChildPath::new(&path)
.write_str(&script)
.unwrap();
let mut perms = fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&path, perms).unwrap();
path
}
#[cfg(unix)]
#[test]
fn metadata_reports_no_wrapper_by_default() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
"#,
)
.unwrap();
let bin = TempDir::new().unwrap();
let _cxx = fake_tool_with_output(bin.path(), "c++", "clang version 17.0.6\n", "", 0);
let _ar = fake_tool_with_output(bin.path(), "ar", "GNU ar 2.40\n", "", 0);
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.env("PATH", bin.path())
.env_remove("CXX")
.env_remove("CC")
.env_remove("AR")
.env_remove("CABIN_COMPILER_WRAPPER")
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert!(
value["toolchain"]["compiler_wrapper"].is_null(),
"expected null compiler_wrapper, got: {}",
value["toolchain"]["compiler_wrapper"],
);
}
#[cfg(unix)]
#[test]
fn metadata_reports_cli_selected_ccache() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
"#,
)
.unwrap();
let bin = TempDir::new().unwrap();
let _cxx = fake_tool_with_output(bin.path(), "c++", "clang version 17.0.6\n", "", 0);
let _ar = fake_tool_with_output(bin.path(), "ar", "GNU ar 2.40\n", "", 0);
let _ccache = fake_tool_with_output(
bin.path(),
"ccache",
"ccache version 4.10.2\nFeatures: file-storage http-storage\n",
"",
0,
);
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--compiler-wrapper", "ccache"])
.env("PATH", bin.path())
.env_remove("CXX")
.env_remove("CC")
.env_remove("AR")
.env_remove("CABIN_COMPILER_WRAPPER")
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let wrapper = &value["toolchain"]["compiler_wrapper"];
assert_eq!(wrapper["kind"].as_str(), Some("ccache"));
assert_eq!(wrapper["source"].as_str(), Some("cli"));
assert_eq!(wrapper["version"].as_str(), Some("4.10.2"));
}
#[cfg(unix)]
#[test]
fn no_compiler_wrapper_overrides_manifest_selection() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
[profile.cache]
compiler-wrapper = "ccache"
"#,
)
.unwrap();
let bin = TempDir::new().unwrap();
let _cxx = fake_tool_with_output(bin.path(), "c++", "clang version 17.0.6\n", "", 0);
let _ar = fake_tool_with_output(bin.path(), "ar", "GNU ar 2.40\n", "", 0);
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--no-compiler-wrapper"])
.env("PATH", bin.path())
.env_remove("CXX")
.env_remove("CC")
.env_remove("AR")
.env_remove("CABIN_COMPILER_WRAPPER")
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert!(value["toolchain"]["compiler_wrapper"].is_null());
}
#[cfg(unix)]
#[test]
fn manifest_build_cache_selects_wrapper_when_no_override() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
[profile.cache]
compiler-wrapper = "sccache"
"#,
)
.unwrap();
let bin = TempDir::new().unwrap();
let _cxx = fake_tool_with_output(bin.path(), "c++", "clang version 17.0.6\n", "", 0);
let _ar = fake_tool_with_output(bin.path(), "ar", "GNU ar 2.40\n", "", 0);
let _sccache = fake_tool_with_output(bin.path(), "sccache", "sccache 0.7.7\n", "", 0);
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.env("PATH", bin.path())
.env_remove("CXX")
.env_remove("CC")
.env_remove("AR")
.env_remove("CABIN_COMPILER_WRAPPER")
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let wrapper = &value["toolchain"]["compiler_wrapper"];
assert_eq!(wrapper["kind"].as_str(), Some("sccache"));
assert_eq!(wrapper["source"].as_str(), Some("manifest"));
assert_eq!(wrapper["version"].as_str(), Some("0.7.7"));
}
#[cfg(unix)]
#[test]
fn env_overrides_manifest_compiler_wrapper() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
[profile.cache]
compiler-wrapper = "sccache"
"#,
)
.unwrap();
let bin = TempDir::new().unwrap();
let _cxx = fake_tool_with_output(bin.path(), "c++", "clang version 17.0.6\n", "", 0);
let _ar = fake_tool_with_output(bin.path(), "ar", "GNU ar 2.40\n", "", 0);
let _ccache = fake_tool_with_output(bin.path(), "ccache", "ccache version 4.10.2\n", "", 0);
let _sccache = fake_tool_with_output(bin.path(), "sccache", "sccache 0.7.7\n", "", 0);
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.env("PATH", bin.path())
.env("CABIN_COMPILER_WRAPPER", "ccache")
.env_remove("CXX")
.env_remove("CC")
.env_remove("AR")
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let wrapper = &value["toolchain"]["compiler_wrapper"];
assert_eq!(wrapper["kind"].as_str(), Some("ccache"));
assert_eq!(wrapper["source"].as_str(), Some("env"));
}
#[cfg(unix)]
#[test]
fn missing_wrapper_executable_yields_clear_build_error() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
[target.demo]
type = "library"
sources = ["src/lib.cc"]
"#,
)
.unwrap();
dir.child("src/lib.cc")
.write_str("int demo() { return 0; }\n")
.unwrap();
let bin = TempDir::new().unwrap();
let _cxx = fake_tool_with_output(bin.path(), "c++", "clang version 17.0.6\n", "", 0);
let _ar = fake_tool_with_output(bin.path(), "ar", "GNU ar 2.40\n", "", 0);
let _ninja = fake_tool_with_output(bin.path(), "ninja", "1.11.1\n", "", 0);
let assertion = cabin()
.args(["build", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--compiler-wrapper", "ccache"])
.env("PATH", bin.path())
.env_remove("CXX")
.env_remove("CC")
.env_remove("AR")
.env_remove("CABIN_COMPILER_WRAPPER")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("ccache") && stderr.contains("could not be found"),
"expected NotFound message naming ccache, got: {stderr}"
);
}
#[cfg(unix)]
#[test]
fn unsupported_cli_value_is_rejected() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
"#,
)
.unwrap();
let bin = TempDir::new().unwrap();
let _cxx = fake_tool_with_output(bin.path(), "c++", "clang version 17.0.6\n", "", 0);
let _ar = fake_tool_with_output(bin.path(), "ar", "GNU ar 2.40\n", "", 0);
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--compiler-wrapper", "fastcache"])
.env("PATH", bin.path())
.env_remove("CXX")
.env_remove("CC")
.env_remove("AR")
.env_remove("CABIN_COMPILER_WRAPPER")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("fastcache") && stderr.contains("not supported"),
"expected unsupported-wrapper error, got: {stderr}"
);
}
#[cfg(unix)]
#[test]
fn cli_flags_are_mutually_exclusive() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
"#,
)
.unwrap();
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--compiler-wrapper", "ccache", "--no-compiler-wrapper"])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("--no-compiler-wrapper")
|| stderr.contains("--compiler-wrapper")
|| stderr.contains("cannot be used"),
"expected mutually-exclusive error, got: {stderr}"
);
}
#[cfg(unix)]
#[test]
fn build_fingerprint_changes_when_wrapper_changes() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
[features]
default = []
fast = []
"#,
)
.unwrap();
let bin = TempDir::new().unwrap();
let _cxx = fake_tool_with_output(bin.path(), "c++", "clang version 17.0.6\n", "", 0);
let _ar = fake_tool_with_output(bin.path(), "ar", "GNU ar 2.40\n", "", 0);
let _ccache = fake_tool_with_output(bin.path(), "ccache", "ccache version 4.10.2\n", "", 0);
let common = |extra: &[&str]| {
let mut cmd = cabin();
cmd.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(extra)
.env("PATH", bin.path())
.env_remove("CXX")
.env_remove("CC")
.env_remove("AR")
.env_remove("CABIN_COMPILER_WRAPPER");
cmd
};
let baseline = common(&[]).assert().success();
let baseline_value: serde_json::Value =
serde_json::from_str(&String::from_utf8_lossy(&baseline.get_output().stdout)).unwrap();
let baseline_fp = baseline_value["packages"][0]["configuration"]["fingerprint"]
.as_str()
.expect("baseline fingerprint")
.to_owned();
let with_wrapper = common(&["--compiler-wrapper", "ccache"]).assert().success();
let with_wrapper_value: serde_json::Value =
serde_json::from_str(&String::from_utf8_lossy(&with_wrapper.get_output().stdout))
.unwrap();
let with_wrapper_fp = with_wrapper_value["packages"][0]["configuration"]["fingerprint"]
.as_str()
.expect("wrapper fingerprint");
assert_ne!(
baseline_fp, with_wrapper_fp,
"fingerprint must differ when a wrapper is selected"
);
}
#[cfg(unix)]
#[test]
fn member_manifest_with_build_cache_is_rejected() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["member"]
[package]
name = "root"
version = "0.1.0"
"#,
)
.unwrap();
dir.child("member/cabin.toml")
.write_str(
r#"[package]
name = "member"
version = "0.1.0"
[profile.cache]
compiler-wrapper = "ccache"
"#,
)
.unwrap();
let bin = TempDir::new().unwrap();
let _cxx = fake_tool_with_output(bin.path(), "c++", "clang version 17.0.6\n", "", 0);
let _ar = fake_tool_with_output(bin.path(), "ar", "GNU ar 2.40\n", "", 0);
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.env("PATH", bin.path())
.env_remove("CABIN_COMPILER_WRAPPER")
.env_remove("CXX")
.env_remove("CC")
.env_remove("AR")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("compiler-cache wrapper")
|| stderr.contains("[profile.cache]")
|| stderr.contains("workspace root"),
"expected member-rejection error, got: {stderr}"
);
}
}
mod config {
use super::*;
use std::path::PathBuf;
fn cabin_with_config() -> Command {
let mut cmd =
Command::cargo_bin("cabin").expect("the `cabin` binary should be built by cargo");
cmd.env_remove("CABIN_NO_CONFIG")
.env_remove("CABIN_CONFIG")
.env_remove("CABIN_CONFIG_HOME");
super::pin_test_user_config_home_to_empty(&mut cmd);
super::pin_test_cache_home(&mut cmd);
cmd
}
fn write_workspace_config(workspace_root: &Path, body: &str) -> PathBuf {
let dir = workspace_root.join(".cabin");
assert_fs::fixture::ChildPath::new(&dir)
.create_dir_all()
.unwrap();
let path = dir.join("config.toml");
assert_fs::fixture::ChildPath::new(&path)
.write_str(body)
.unwrap();
path
}
fn write_user_config(home: &Path, body: &str) -> PathBuf {
assert_fs::fixture::ChildPath::new(home)
.create_dir_all()
.unwrap();
let path = home.join("config.toml");
assert_fs::fixture::ChildPath::new(&path)
.write_str(body)
.unwrap();
path
}
fn project_dir(template: &str) -> TempDir {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml").write_str(template).unwrap();
dir
}
const MINIMAL_PROJECT: &str = r#"[package]
name = "demo"
version = "0.1.0"
"#;
#[test]
fn metadata_without_config_emits_empty_loaded_files_block() {
let dir = project_dir(MINIMAL_PROJECT);
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let config = &value["config"];
assert_eq!(config["loaded_files"], serde_json::json!([]));
assert_eq!(config["registry"], serde_json::Value::Null);
assert_eq!(config["build"]["profile"], serde_json::Value::Null);
assert_eq!(config["compiler_wrapper"], serde_json::Value::Null);
assert_eq!(config["paths"]["cache_dir"], serde_json::Value::Null);
}
#[test]
fn metadata_reports_loaded_workspace_config_file() {
let dir = project_dir(MINIMAL_PROJECT);
write_workspace_config(
dir.path(),
r#"[build]
profile = "release"
"#,
);
let user_home = TempDir::new().unwrap();
let assertion = cabin_with_config()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.env("CABIN_CONFIG_HOME", user_home.path())
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let loaded = value["config"]["loaded_files"]
.as_array()
.expect("loaded_files array");
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0]["source"].as_str(), Some("package"));
let profile = &value["config"]["build"]["profile"];
assert_eq!(profile["name"].as_str(), Some("release"));
assert_eq!(profile["value_source"].as_str(), Some("package-config"));
}
#[test]
fn metadata_workspace_root_label_is_workspace_when_root_declares_workspace() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["pkg"]
"#,
)
.unwrap();
dir.child("pkg/cabin.toml")
.write_str(
r#"[package]
name = "pkg"
version = "0.1.0"
"#,
)
.unwrap();
write_workspace_config(
dir.path(),
r#"[build]
profile = "release"
"#,
);
let user_home = TempDir::new().unwrap();
let assertion = cabin_with_config()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.env("CABIN_CONFIG_HOME", user_home.path())
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let loaded = value["config"]["loaded_files"]
.as_array()
.expect("loaded_files array");
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0]["source"].as_str(), Some("workspace"));
}
#[test]
fn workspace_config_overrides_user_config_for_overlapping_profile_setting() {
let dir = project_dir(MINIMAL_PROJECT);
write_workspace_config(
dir.path(),
r#"[build]
profile = "release"
"#,
);
let user_home = TempDir::new().unwrap();
write_user_config(
user_home.path(),
r#"[build]
profile = "dev"
"#,
);
let assertion = cabin_with_config()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.env("CABIN_CONFIG_HOME", user_home.path())
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let profile = &value["config"]["build"]["profile"];
assert_eq!(profile["name"].as_str(), Some("release"));
assert_eq!(profile["value_source"].as_str(), Some("package-config"));
assert_eq!(
value["profiles"]["selected"]["name"].as_str(),
Some("release")
);
}
#[test]
fn cli_profile_overrides_config_default() {
let dir = project_dir(MINIMAL_PROJECT);
write_workspace_config(
dir.path(),
r#"[build]
profile = "release"
"#,
);
let user_home = TempDir::new().unwrap();
let assertion = cabin_with_config()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--profile", "dev"])
.env("CABIN_CONFIG_HOME", user_home.path())
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(value["profiles"]["selected"]["name"].as_str(), Some("dev"));
assert_eq!(
value["config"]["build"]["profile"]["name"].as_str(),
Some("release")
);
}
#[test]
fn cabin_no_config_disables_discovery() {
let dir = project_dir(MINIMAL_PROJECT);
write_workspace_config(
dir.path(),
r#"[build]
profile = "release"
"#,
);
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let loaded = value["config"]["loaded_files"]
.as_array()
.expect("loaded_files array");
assert!(loaded.is_empty());
assert_eq!(value["config"]["build"]["profile"], serde_json::Value::Null);
}
#[test]
fn explicit_config_path_loads_a_specific_file() {
let dir = project_dir(MINIMAL_PROJECT);
let explicit = TempDir::new().unwrap();
let explicit_path = explicit.path().join("explicit.toml");
assert_fs::fixture::ChildPath::new(&explicit_path)
.write_str(
r#"[build]
profile = "release"
"#,
)
.unwrap();
let assertion = cabin_with_config()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.env("CABIN_CONFIG", &explicit_path)
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let loaded = value["config"]["loaded_files"]
.as_array()
.expect("loaded_files array");
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0]["source"].as_str(), Some("explicit"));
assert_eq!(loaded[0]["path"].as_str(), explicit_path.to_str());
assert_eq!(
value["profiles"]["selected"]["name"].as_str(),
Some("release")
);
}
#[test]
fn explicit_config_path_missing_yields_clear_error() {
let dir = project_dir(MINIMAL_PROJECT);
let assertion = cabin_with_config()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.env(
"CABIN_CONFIG",
"/definitely/not/a/real/path/cabin/config.toml",
)
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("requested explicitly"),
"expected explicit-config rejection, got: {stderr}"
);
}
#[test]
fn invalid_top_level_table_in_config_yields_clear_error() {
let dir = project_dir(MINIMAL_PROJECT);
write_workspace_config(
dir.path(),
r#"[networking]
mode = "offline"
"#,
);
let user_home = TempDir::new().unwrap();
let assertion = cabin_with_config()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.env("CABIN_CONFIG_HOME", user_home.path())
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("unknown top-level config table"),
"expected unknown-table error, got: {stderr}"
);
}
#[test]
fn auth_token_keys_in_config_are_rejected() {
let dir = project_dir(MINIMAL_PROJECT);
write_workspace_config(
dir.path(),
r#"[auth]
token = "secret"
"#,
);
let user_home = TempDir::new().unwrap();
let assertion = cabin_with_config()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.env("CABIN_CONFIG_HOME", user_home.path())
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("does not handle credentials"),
"expected auth rejection, got: {stderr}"
);
}
#[test]
fn target_conditioned_config_table_yields_clear_error() {
let dir = project_dir(MINIMAL_PROJECT);
write_workspace_config(
dir.path(),
r#"[target.'cfg(os = "linux")'.toolchain]
cxx = "clang++"
"#,
);
let user_home = TempDir::new().unwrap();
let assertion = cabin_with_config()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.env("CABIN_CONFIG_HOME", user_home.path())
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("target-conditioned config tables are not supported"),
"expected target-conditioned rejection, got: {stderr}"
);
}
#[test]
fn registry_path_url_conflict_yields_clear_error() {
let dir = project_dir(MINIMAL_PROJECT);
write_workspace_config(
dir.path(),
r#"[registry]
index-path = "registry"
index-url = "https://example.com/index"
"#,
);
let user_home = TempDir::new().unwrap();
let assertion = cabin_with_config()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.env("CABIN_CONFIG_HOME", user_home.path())
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("conflicts with"),
"expected registry conflict error, got: {stderr}"
);
}
#[test]
fn invalid_compiler_wrapper_in_config_yields_clear_error() {
let dir = project_dir(MINIMAL_PROJECT);
write_workspace_config(
dir.path(),
r#"[build.cache]
compiler-wrapper = "fastcache"
"#,
);
let user_home = TempDir::new().unwrap();
let assertion = cabin_with_config()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.env("CABIN_CONFIG_HOME", user_home.path())
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("not supported") && stderr.contains("none, ccache, sccache"),
"expected wrapper-value error, got: {stderr}"
);
}
#[test]
fn registry_index_path_default_resolves_relative_to_workspace_config() {
let dir = project_dir(MINIMAL_PROJECT);
write_workspace_config(
dir.path(),
r#"[registry]
index-path = "registry"
"#,
);
let user_home = TempDir::new().unwrap();
let assertion = cabin_with_config()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.env("CABIN_CONFIG_HOME", user_home.path())
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let registry = &value["config"]["registry"];
assert_eq!(registry["kind"].as_str(), Some("path"));
let resolved = registry["value"]
.as_str()
.expect("registry path is reported as a string");
assert!(
resolved.ends_with("/.cabin/registry"),
"expected the relative `registry` path to resolve against the config directory, got: {resolved}",
);
assert_eq!(registry["value_source"].as_str(), Some("package-config"));
}
#[test]
fn config_does_not_appear_in_published_package_metadata() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
[target.demo]
type = "library"
sources = ["src/lib.cc"]
"#,
)
.unwrap();
dir.child("src/lib.cc")
.write_str("int demo() { return 0; }\n")
.unwrap();
write_workspace_config(
dir.path(),
r#"[build]
profile = "release"
[build.cache]
compiler-wrapper = "ccache"
"#,
);
let out = dir.path().join("dist");
cabin()
.args(["package", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--output-dir"])
.arg(&out)
.assert()
.success();
let body = fs::read_to_string(out.join("demo-0.1.0.json")).unwrap();
assert!(!body.contains("compiler-wrapper"), "{body}");
assert!(!body.contains("\"build\""), "{body}");
assert!(!body.contains("\"config\""), "{body}");
let archive = out.join("demo-0.1.0.tar.gz");
let archive_bytes = fs::read(&archive).unwrap();
let decoder = flate2::read::GzDecoder::new(archive_bytes.as_slice());
let mut tar = tar::Archive::new(decoder);
for entry in tar.entries().unwrap() {
let entry = entry.unwrap();
let path = entry.path().unwrap().display().to_string();
assert!(
!path.contains(".cabin"),
"archive must not contain .cabin entries, found: {path}",
);
}
}
#[test]
fn cli_index_path_overrides_config_registry() {
let dir = project_dir(MINIMAL_PROJECT);
write_workspace_config(
dir.path(),
r#"[registry]
index-path = "/definitely/not/a/real/path"
"#,
);
let user_home = TempDir::new().unwrap();
let cli_index = TempDir::new().unwrap();
cabin_with_config()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--index-path"])
.arg(cli_index.path())
.env("CABIN_CONFIG_HOME", user_home.path())
.assert()
.success();
}
#[test]
fn no_index_anywhere_for_a_versioned_dep_mentions_config() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
[dependencies]
fmt = ">=10.0.0 <11.0.0"
"#,
)
.unwrap();
let user_home = TempDir::new().unwrap();
let assertion = cabin_with_config()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.env("CABIN_CONFIG_HOME", user_home.path())
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("--index-path") && stderr.contains("[registry]"),
"expected index-source error to mention CLI flag and config, got: {stderr}"
);
}
#[cfg(unix)]
fn fake_tool_with_output(
dir: &Path,
name: &str,
stdout: &str,
stderr: &str,
status: i32,
) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let path = dir.join(name);
let escaped_stdout = stdout.replace('\'', "'\\''");
let escaped_stderr = stderr.replace('\'', "'\\''");
let script = format!(
"#!/bin/sh\nprintf '%s' '{escaped_stdout}'\nprintf '%s' '{escaped_stderr}' >&2\nexit {status}\n"
);
assert_fs::fixture::ChildPath::new(&path)
.write_str(&script)
.unwrap();
let mut perms = fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&path, perms).unwrap();
path
}
#[cfg(unix)]
#[test]
fn metadata_reports_config_supplied_toolchain_cxx() {
let dir = project_dir(MINIMAL_PROJECT);
let bin = TempDir::new().unwrap();
let cxx =
fake_tool_with_output(bin.path(), "fake-clang++", "clang version 17.0.6\n", "", 0);
let _ar = fake_tool_with_output(bin.path(), "ar", "GNU ar 2.40\n", "", 0);
write_workspace_config(
dir.path(),
&format!(
r#"[toolchain]
cxx = "{cxx}"
"#,
cxx = cxx.display()
),
);
let user_home = TempDir::new().unwrap();
let assertion = cabin_with_config()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.env("CABIN_CONFIG_HOME", user_home.path())
.env("PATH", bin.path())
.env_remove("CXX")
.env_remove("CC")
.env_remove("AR")
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let cxx_view = &value["toolchain"]["tools"]["cxx"];
assert_eq!(cxx_view["spec"].as_str(), Some(cxx.to_str().unwrap()));
assert_eq!(cxx_view["source"].as_str(), Some("package-config"));
assert_eq!(
value["config"]["toolchain"]["cxx"]["value_source"].as_str(),
Some("package-config")
);
}
#[cfg(unix)]
#[test]
fn cxx_env_overrides_config_toolchain_cxx() {
let dir = project_dir(MINIMAL_PROJECT);
let bin = TempDir::new().unwrap();
let env_cxx =
fake_tool_with_output(bin.path(), "env-clang++", "clang version 17.0.6\n", "", 0);
let config_cxx = fake_tool_with_output(
bin.path(),
"config-clang++",
"clang version 17.0.6\n",
"",
0,
);
let _ar = fake_tool_with_output(bin.path(), "ar", "GNU ar 2.40\n", "", 0);
write_workspace_config(
dir.path(),
&format!(
r#"[toolchain]
cxx = "{cxx}"
"#,
cxx = config_cxx.display()
),
);
let user_home = TempDir::new().unwrap();
let assertion = cabin_with_config()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.env("CABIN_CONFIG_HOME", user_home.path())
.env("PATH", bin.path())
.env("CXX", &env_cxx)
.env_remove("CC")
.env_remove("AR")
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let cxx_view = &value["toolchain"]["tools"]["cxx"];
assert_eq!(cxx_view["spec"].as_str(), Some(env_cxx.to_str().unwrap()));
assert_eq!(cxx_view["source"].as_str(), Some("env"));
}
#[cfg(unix)]
#[test]
fn config_supplies_compiler_wrapper_default() {
let dir = project_dir(MINIMAL_PROJECT);
let bin = TempDir::new().unwrap();
let _cxx = fake_tool_with_output(bin.path(), "c++", "clang version 17.0.6\n", "", 0);
let _ar = fake_tool_with_output(bin.path(), "ar", "GNU ar 2.40\n", "", 0);
let _ccache = fake_tool_with_output(bin.path(), "ccache", "ccache version 4.10.2\n", "", 0);
write_workspace_config(
dir.path(),
r#"[build.cache]
compiler-wrapper = "ccache"
"#,
);
let user_home = TempDir::new().unwrap();
let assertion = cabin_with_config()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.env("CABIN_CONFIG_HOME", user_home.path())
.env("PATH", bin.path())
.env_remove("CXX")
.env_remove("CC")
.env_remove("AR")
.env_remove("CABIN_COMPILER_WRAPPER")
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let wrapper = &value["toolchain"]["compiler_wrapper"];
assert_eq!(wrapper["kind"].as_str(), Some("ccache"));
assert_eq!(wrapper["source"].as_str(), Some("package-config"));
assert_eq!(
value["config"]["compiler_wrapper"]["request"].as_str(),
Some("ccache")
);
}
#[cfg(unix)]
#[test]
fn no_compiler_wrapper_flag_overrides_config_default() {
let dir = project_dir(MINIMAL_PROJECT);
let bin = TempDir::new().unwrap();
let _cxx = fake_tool_with_output(bin.path(), "c++", "clang version 17.0.6\n", "", 0);
let _ar = fake_tool_with_output(bin.path(), "ar", "GNU ar 2.40\n", "", 0);
write_workspace_config(
dir.path(),
r#"[build.cache]
compiler-wrapper = "ccache"
"#,
);
let user_home = TempDir::new().unwrap();
let assertion = cabin_with_config()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--no-compiler-wrapper"])
.env("CABIN_CONFIG_HOME", user_home.path())
.env("PATH", bin.path())
.env_remove("CXX")
.env_remove("CC")
.env_remove("AR")
.env_remove("CABIN_COMPILER_WRAPPER")
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert!(value["toolchain"]["compiler_wrapper"].is_null());
assert_eq!(
value["config"]["compiler_wrapper"]["request"].as_str(),
Some("ccache")
);
}
#[test]
fn config_does_not_change_lockfile_layout() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
"#,
)
.unwrap();
write_workspace_config(
dir.path(),
r#"[registry]
index-path = "registry"
"#,
);
let user_home = TempDir::new().unwrap();
cabin_with_config()
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.env("CABIN_CONFIG_HOME", user_home.path())
.assert()
.success();
assert!(!dir.path().join("cabin.lock").exists());
}
}
mod patches {
use super::*;
use std::path::PathBuf;
fn cabin_with_config() -> Command {
let mut cmd =
Command::cargo_bin("cabin").expect("the `cabin` binary should be built by cargo");
cmd.env_remove("CABIN_NO_CONFIG")
.env_remove("CABIN_CONFIG")
.env_remove("CABIN_CONFIG_HOME");
super::pin_test_user_config_home_to_empty(&mut cmd);
super::pin_test_cache_home(&mut cmd);
cmd
}
fn write_workspace_config(workspace_root: &Path, body: &str) -> PathBuf {
let dir = workspace_root.join(".cabin");
assert_fs::fixture::ChildPath::new(&dir)
.create_dir_all()
.unwrap();
let path = dir.join("config.toml");
assert_fs::fixture::ChildPath::new(&path)
.write_str(body)
.unwrap();
path
}
fn write_root_manifest(root: &Path, body: &str) {
assert_fs::fixture::ChildPath::new(root.join("cabin.toml"))
.write_str(body)
.unwrap();
}
fn write_patched_fork(parent: &Path, dir_name: &str, body: &str) -> PathBuf {
let path = parent.join(dir_name);
assert_fs::fixture::ChildPath::new(path.join("cabin.toml"))
.write_str(body)
.unwrap();
path
}
#[test]
fn metadata_reports_active_manifest_patch() {
let parent = TempDir::new().unwrap();
let root = parent.path().join("app");
write_root_manifest(
&root,
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=10.0.0 <11.0.0"
[patch]
fmt = { path = "../fmt" }
"#,
);
write_patched_fork(
parent.path(),
"fmt",
r#"[package]
name = "fmt"
version = "10.2.1"
"#,
);
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(root.join("cabin.toml"))
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let patches = value["patches"].as_array().expect("patches array");
assert_eq!(patches.len(), 1);
assert_eq!(patches[0]["package"].as_str(), Some("fmt"));
assert_eq!(patches[0]["version"].as_str(), Some("10.2.1"));
assert_eq!(patches[0]["kind"].as_str(), Some("path"));
assert_eq!(patches[0]["provenance"].as_str(), Some("manifest"));
let pkg_names: Vec<&str> = value["packages"]
.as_array()
.unwrap()
.iter()
.map(|p| p["name"].as_str().unwrap())
.collect();
assert!(pkg_names.contains(&"fmt"));
}
#[test]
fn metadata_reports_config_supplied_patch_overriding_manifest() {
let parent = TempDir::new().unwrap();
let root = parent.path().join("app");
write_root_manifest(
&root,
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=10.0.0 <11.0.0"
[patch]
fmt = { path = "../fmt-manifest" }
"#,
);
write_patched_fork(
parent.path(),
"fmt-manifest",
r#"[package]
name = "fmt"
version = "10.0.0"
"#,
);
write_patched_fork(
&root,
"fmt-config",
r#"[package]
name = "fmt"
version = "10.5.0"
"#,
);
write_workspace_config(
&root,
r#"[patch]
fmt = { path = "../fmt-config" }
"#,
);
let user_home = TempDir::new().unwrap();
let assertion = cabin_with_config()
.args(["metadata", "--manifest-path"])
.arg(root.join("cabin.toml"))
.env("CABIN_CONFIG_HOME", user_home.path())
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let patches = value["patches"].as_array().expect("patches array");
assert_eq!(patches.len(), 1);
assert_eq!(patches[0]["version"].as_str(), Some("10.5.0"));
assert_eq!(patches[0]["provenance"].as_str(), Some("package-config"));
}
#[test]
fn no_patches_flag_disables_active_patches() {
let parent = TempDir::new().unwrap();
let root = parent.path().join("app");
write_root_manifest(
&root,
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=10.0.0 <11.0.0"
[patch]
fmt = { path = "../fmt" }
"#,
);
write_patched_fork(
parent.path(),
"fmt",
r#"[package]
name = "fmt"
version = "10.2.1"
"#,
);
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(root.join("cabin.toml"))
.arg("--no-patches")
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert!(value["patches"].as_array().unwrap().is_empty());
}
#[test]
fn missing_patch_path_yields_clear_error() {
let parent = TempDir::new().unwrap();
let root = parent.path().join("app");
write_root_manifest(
&root,
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=10.0.0 <11.0.0"
[patch]
fmt = { path = "../fmt" }
"#,
);
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(root.join("cabin.toml"))
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("does not contain a cabin.toml"),
"expected missing-manifest error, got: {stderr}"
);
}
#[test]
fn patch_package_name_mismatch_yields_clear_error() {
let parent = TempDir::new().unwrap();
let root = parent.path().join("app");
write_root_manifest(
&root,
r#"[package]
name = "app"
version = "0.1.0"
[patch]
fmt = { path = "../fmt-fork" }
"#,
);
write_patched_fork(
parent.path(),
"fmt-fork",
r#"[package]
name = "wrong-name"
version = "10.2.1"
"#,
);
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(root.join("cabin.toml"))
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("patch package name must match `fmt`"),
"expected name mismatch, got: {stderr}"
);
}
#[test]
fn patch_version_mismatch_yields_clear_error() {
let parent = TempDir::new().unwrap();
let root = parent.path().join("app");
write_root_manifest(
&root,
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=11.0.0 <12.0.0"
[patch]
fmt = { path = "../fmt" }
"#,
);
write_patched_fork(
parent.path(),
"fmt",
r#"[package]
name = "fmt"
version = "10.0.0"
"#,
);
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(root.join("cabin.toml"))
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("does not satisfy dependency requirement"),
"expected version mismatch, got: {stderr}"
);
}
#[test]
fn package_rejects_manifest_with_patch_table() {
let parent = TempDir::new().unwrap();
let dir = parent.path().join("app");
write_root_manifest(
&dir,
r#"[package]
name = "app"
version = "0.1.0"
[target.app]
type = "library"
sources = ["src/lib.cc"]
[patch]
fmt = { path = "../fmt" }
"#,
);
assert_fs::fixture::ChildPath::new(dir.join("src/lib.cc"))
.write_str("int app() { return 0; }\n")
.unwrap();
let assertion = cabin()
.args(["package", "--manifest-path"])
.arg(dir.join("cabin.toml"))
.args(["--output-dir"])
.arg(dir.join("dist"))
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("declares a `[patch]` table"),
"expected patch-rejection error, got: {stderr}"
);
}
#[test]
fn member_manifest_with_patch_table_is_rejected() {
let dir = TempDir::new().unwrap();
write_root_manifest(
dir.path(),
r#"[workspace]
members = ["member"]
"#,
);
dir.child("member/cabin.toml")
.write_str(
r#"[package]
name = "member"
version = "0.1.0"
[patch]
fmt = { path = "../fmt" }
"#,
)
.unwrap();
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("only appear in the workspace root"),
"expected member-rejection, got: {stderr}"
);
}
#[test]
fn metadata_reports_active_source_replacement() {
let parent = TempDir::new().unwrap();
let root = parent.path().join("app");
write_root_manifest(
&root,
r#"[package]
name = "app"
version = "0.1.0"
"#,
);
write_workspace_config(
&root,
r#"[source-replacement]
"https://example.com/index" = { index-path = "../mirror" }
"#,
);
let user_home = TempDir::new().unwrap();
let assertion = cabin_with_config()
.args(["metadata", "--manifest-path"])
.arg(root.join("cabin.toml"))
.env("CABIN_CONFIG_HOME", user_home.path())
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let entries = value["source_replacements"]
.as_array()
.expect("source_replacements array");
assert_eq!(entries.len(), 1);
assert_eq!(
entries[0]["original"].as_str(),
Some("https://example.com/index")
);
assert_eq!(entries[0]["replacement_kind"].as_str(), Some("index-path"));
}
#[test]
fn explain_source_no_patches_still_reports_configured_source_replacements() {
let parent = TempDir::new().unwrap();
let root = parent.path().join("app");
write_root_manifest(
&root,
r#"[package]
name = "app"
version = "0.1.0"
"#,
);
write_workspace_config(
&root,
r#"[source-replacement]
"https://example.com/index" = { index-path = "../mirror" }
"#,
);
let user_home = TempDir::new().unwrap();
let assertion = cabin_with_config()
.args(["explain", "--manifest-path"])
.arg(root.join("cabin.toml"))
.args(["--format", "json", "--no-patches", "source", "app"])
.env("CABIN_CONFIG_HOME", user_home.path())
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let entries = value["source_replacements"]
.as_array()
.expect("source_replacements array");
assert_eq!(entries.len(), 1);
assert_eq!(
entries[0].as_str(),
Some("https://example.com/index -> ../mirror (package-config)")
);
}
#[test]
fn source_replacement_credentials_in_url_yield_clear_error() {
let parent = TempDir::new().unwrap();
let root = parent.path().join("app");
write_root_manifest(
&root,
r#"[package]
name = "app"
version = "0.1.0"
"#,
);
write_workspace_config(
&root,
r#"[source-replacement]
"https://user:pw@example.com/index" = { index-path = "../mirror" }
"#,
);
let user_home = TempDir::new().unwrap();
let assertion = cabin_with_config()
.args(["metadata", "--manifest-path"])
.arg(root.join("cabin.toml"))
.env("CABIN_CONFIG_HOME", user_home.path())
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("must not contain credentials"),
"expected credential rejection, got: {stderr}"
);
}
#[test]
fn locked_fails_when_patch_policy_changed_after_lockfile() {
let parent = TempDir::new().unwrap();
let root = parent.path().join("app");
write_root_manifest(
&root,
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=10.0.0 <11.0.0"
"#,
);
let index = parent.path().join("index");
assert_fs::fixture::ChildPath::new(index.join("fmt.json"))
.write_str(FMT_INDEX_TWO_VERSIONS)
.unwrap();
cabin()
.args(["resolve", "--manifest-path"])
.arg(root.join("cabin.toml"))
.arg("--index-path")
.arg(&index)
.assert()
.success();
write_root_manifest(
&root,
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=10.0.0 <11.0.0"
[patch]
fmt = { path = "../fmt" }
"#,
);
write_patched_fork(
parent.path(),
"fmt",
r#"[package]
name = "fmt"
version = "10.2.0"
"#,
);
let assertion = cabin()
.args(["resolve", "--locked", "--manifest-path"])
.arg(root.join("cabin.toml"))
.arg("--index-path")
.arg(&index)
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("patch / source-replacement policy differs"),
"expected patch staleness error, got: {stderr}"
);
}
#[test]
fn source_replacement_self_loop_yields_clear_cycle_error() {
let parent = TempDir::new().unwrap();
let root = parent.path().join("app");
write_root_manifest(
&root,
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=10.0.0"
"#,
);
write_workspace_config(
&root,
r#"[registry]
index-url = "https://example.com/index"
[source-replacement]
"https://example.com/index" = { index-url = "https://example.com/index" }
"#,
);
let user_home = TempDir::new().unwrap();
let assertion = cabin_with_config()
.args(["resolve", "--manifest-path"])
.arg(root.join("cabin.toml"))
.env("CABIN_CONFIG_HOME", user_home.path())
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("source replacement cycle detected"),
"expected source-replacement cycle error, got: {stderr}"
);
}
#[test]
fn offline_rejects_index_path_redirected_to_url_via_source_replacement() {
let parent = TempDir::new().unwrap();
let root = parent.path().join("app");
write_root_manifest(
&root,
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = "10.2.1"
"#,
);
write_workspace_config(
&root,
r#"[source-replacement]
"./mirror" = { index-url = "https://example.com/index" }
"#,
);
let user_home = TempDir::new().unwrap();
let assertion = cabin_with_config()
.args(["build", "--offline", "--manifest-path"])
.arg(root.join("cabin.toml"))
.args(["--index-path", "./mirror"])
.arg("--build-dir")
.arg(root.join("build"))
.env("CABIN_CONFIG_HOME", user_home.path())
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("source-replacement"),
"expected source-replacement blame, got: {stderr}"
);
assert!(
stderr.contains("https://example.com/index"),
"diagnostic should name the offending URL, got: {stderr}"
);
}
#[test]
fn vendor_rejects_index_path_redirected_to_url_via_source_replacement() {
let parent = TempDir::new().unwrap();
let root = parent.path().join("app");
write_root_manifest(
&root,
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = "10.2.1"
"#,
);
write_workspace_config(
&root,
r#"[source-replacement]
"./mirror" = { index-url = "https://example.com/index" }
"#,
);
let user_home = TempDir::new().unwrap();
let assertion = cabin_with_config()
.args(["vendor", "--manifest-path"])
.arg(root.join("cabin.toml"))
.args(["--index-path", "./mirror"])
.arg("--vendor-dir")
.arg(root.join("vendor"))
.env("CABIN_CONFIG_HOME", user_home.path())
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("source-replacement"),
"expected source-replacement blame, got: {stderr}"
);
assert!(
stderr.contains("cabin vendor"),
"diagnostic should mention `cabin vendor`, got: {stderr}"
);
}
#[test]
fn metadata_succeeds_when_only_inactive_dep_mismatches_patch_version() {
let parent = TempDir::new().unwrap();
let root = parent.path().join("app");
write_root_manifest(
&root,
r#"[package]
name = "app"
version = "0.1.0"
[dev-dependencies]
fmt = ">=99"
[patch]
fmt = { path = "../fmt" }
"#,
);
write_patched_fork(
parent.path(),
"fmt",
r#"[package]
name = "fmt"
version = "0.1.0"
"#,
);
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(root.join("cabin.toml"))
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let patches = value["patches"].as_array().expect("patches array");
assert_eq!(patches.len(), 1);
assert_eq!(patches[0]["package"].as_str(), Some("fmt"));
assert_eq!(patches[0]["version"].as_str(), Some("0.1.0"));
}
#[test]
fn resolve_includes_versioned_deps_introduced_by_patched_manifest() {
let parent = TempDir::new().unwrap();
let root = parent.path().join("app");
write_root_manifest(
&root,
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=10.0.0 <11.0.0"
[patch]
fmt = { path = "../fmt-fork" }
"#,
);
write_patched_fork(
parent.path(),
"fmt-fork",
r#"[package]
name = "fmt"
version = "10.2.1"
[dependencies]
spdlog = ">=1.13.0 <2.0.0"
"#,
);
parent
.child("index/spdlog.json")
.write_str(SPDLOG_INDEX)
.unwrap();
parent.child("index/fmt.json").write_str(FMT_INDEX).unwrap();
let value = run_json(
cabin()
.args(["resolve", "--manifest-path"])
.arg(root.join("cabin.toml"))
.arg("--index-path")
.arg(parent.path().join("index"))
.args(["--format", "json"]),
);
let names: Vec<&str> = value["packages"]
.as_array()
.unwrap()
.iter()
.map(|p| p["name"].as_str().unwrap())
.collect();
assert!(
names.contains(&"spdlog"),
"spdlog must enter resolution through the patched fmt manifest, got: {names:?}"
);
}
#[test]
fn vendor_requires_index_for_versioned_deps_introduced_by_patched_manifest() {
let parent = TempDir::new().unwrap();
let root = parent.path().join("app");
write_root_manifest(
&root,
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=10.0.0 <11.0.0"
[patch]
fmt = { path = "../fmt-fork" }
"#,
);
write_patched_fork(
parent.path(),
"fmt-fork",
r#"[package]
name = "fmt"
version = "10.2.1"
[dependencies]
spdlog = ">=1.13.0 <2.0.0"
"#,
);
let assertion = cabin()
.args(["vendor", "--manifest-path"])
.arg(root.join("cabin.toml"))
.arg("--vendor-dir")
.arg(parent.path().join("vendor"))
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("versioned dependencies require --index-path"),
"patched manifest's registry deps should require an index: {stderr}"
);
}
#[test]
fn source_replacement_does_not_leak_into_package_metadata() {
let dir = TempDir::new().unwrap();
write_root_manifest(
dir.path(),
r#"[package]
name = "demo"
version = "0.1.0"
[target.demo]
type = "library"
sources = ["src/lib.cc"]
"#,
);
dir.child("src/lib.cc")
.write_str("int demo() { return 0; }\n")
.unwrap();
write_workspace_config(
dir.path(),
r#"[source-replacement]
"https://example.com/index" = { index-path = "../mirror" }
"#,
);
let out = dir.path().join("dist");
cabin()
.args(["package", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--output-dir"])
.arg(&out)
.assert()
.success();
let body = fs::read_to_string(out.join("demo-0.1.0.json")).unwrap();
assert!(!body.contains("source-replacement"), "{body}");
assert!(!body.contains("https://example.com/index"), "{body}");
}
}
mod test_targets {
use super::*;
fn passing_test_project() -> TempDir {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
[target.demo]
type = "library"
sources = ["src/lib.cc"]
[target.demo_test]
type = "test"
sources = ["tests/lib_test.cc"]
deps = ["demo"]
"#,
)
.unwrap();
dir.child("src/lib.cc")
.write_str("int demo() { return 42; }\n")
.unwrap();
dir.child("tests/lib_test.cc")
.write_str("int main() { return 0; }\n")
.unwrap();
dir
}
fn project_with_dev_kinds() -> TempDir {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
[target.demo]
type = "library"
sources = ["src/lib.cc"]
[target.demo_test]
type = "test"
sources = ["tests/lib_test.cc"]
deps = ["demo"]
[target.hello_example]
type = "example"
sources = ["examples/hello.cc"]
deps = ["demo"]
"#,
)
.unwrap();
dir.child("src/lib.cc")
.write_str("int demo() { return 1; }\n")
.unwrap();
dir.child("tests/lib_test.cc")
.write_str("int main() { return 0; }\n")
.unwrap();
dir.child("examples/hello.cc")
.write_str("int main() { return 0; }\n")
.unwrap();
dir
}
#[test]
fn metadata_lists_test_and_example_target_kinds() {
let dir = project_with_dev_kinds();
let value = run_metadata(&dir.path().join("cabin.toml"));
let demo = package_in(&value, "demo");
let kinds: std::collections::BTreeMap<String, String> = demo["targets"]
.as_array()
.unwrap()
.iter()
.map(|t| {
(
t["name"].as_str().unwrap().to_owned(),
t["kind"].as_str().unwrap().to_owned(),
)
})
.collect();
assert_eq!(kinds.get("demo").map(String::as_str), Some("library"));
assert_eq!(kinds.get("demo_test").map(String::as_str), Some("test"));
assert_eq!(
kinds.get("hello_example").map(String::as_str),
Some("example")
);
}
#[test]
fn invalid_target_kind_is_rejected_with_helpful_message() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
[target.broken]
type = "cpp_tests"
sources = ["src/x.cc"]
"#,
)
.unwrap();
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("\"test\"")
&& stderr.contains("\"library\"")
&& stderr.contains("\"executable\"")
&& stderr.contains("\"header_only\"")
&& stderr.contains("\"example\""),
"expected target-type error mentioning the supported kinds, got: {stderr}"
);
}
#[test]
fn build_default_does_not_build_dev_only_targets() {
skip_if!(
!build_tools_available(),
"build_default_does_not_build_dev_only_targets",
"ninja or a C++ compiler is unavailable on PATH"
);
let dir = project_with_dev_kinds();
let assertion = cabin()
.args(["build", "-v", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--build-dir")
.arg(dir.path().join("build"))
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
assert!(
stdout.contains("AR"),
"library archive should build: {stdout}"
);
for forbidden in ["demo_test", "hello_example"] {
assert!(
!stdout.contains(forbidden),
"default build must not produce {forbidden}: {stdout}"
);
}
}
#[test]
fn cabin_test_builds_and_runs_passing_test() {
skip_if!(
!build_tools_available(),
"cabin_test_builds_and_runs_passing_test",
"ninja or a C++ compiler is unavailable on PATH"
);
let dir = passing_test_project();
let assertion = cabin()
.args(["test", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--build-dir")
.arg(dir.path().join("build"))
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
assert!(
stdout.contains("test demo:demo_test ... ok"),
"expected per-test result line, got: {stdout}"
);
assert!(
stdout.contains("test result: ok. 1 passed; 0 failed"),
"expected passing summary, got: {stdout}"
);
}
#[test]
fn cabin_test_sets_per_test_cabin_env_overlay() {
skip_if!(
!build_tools_available(),
"cabin_test_sets_per_test_cabin_env_overlay",
"ninja or a C++ compiler is unavailable on PATH"
);
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "env_demo"
version = "0.1.0"
[target.env_test]
type = "test"
sources = ["tests/env_test.cc"]
"#,
)
.unwrap();
dir.child("tests/env_test.cc")
.write_str(
r#"#include <cstdio>
#include <cstdlib>
#include <cstring>
static int status = 0;
void keep(const char* name, const char* expected) {
const char* v = std::getenv(name);
if (v == nullptr) {
std::printf("MISSING %s\n", name);
status |= 1;
return;
}
std::printf("KEEP %s=%s\n", name, v);
if (expected != nullptr && std::strcmp(v, expected) != 0) {
status |= 2;
}
}
void keep_present(const char* name) {
const char* v = std::getenv(name);
if (v == nullptr || v[0] == '\0') {
std::printf("MISSING %s\n", name);
status |= 1;
return;
}
std::printf("KEEP %s\n", name);
}
void must_be_absent(const char* name) {
if (std::getenv(name) != nullptr) {
std::printf("LEAK %s\n", name);
status |= 4;
} else {
std::printf("ABSENT %s\n", name);
}
}
int main() {
keep("CABIN_PACKAGE_NAME", "env_demo");
keep("CABIN_PACKAGE_VERSION", "0.1.0");
keep("CABIN_PROFILE", "dev");
keep_present("CABIN_MANIFEST_DIR");
keep_present("CABIN_MANIFEST_PATH");
keep_present("CABIN_BUILD_DIR");
must_be_absent("CABIN");
must_be_absent("CABIN_PACKAGE_NAME_CANONICAL");
must_be_absent("CABIN_BIN_NAME");
must_be_absent("CABIN_BIN_NAME_CANONICAL");
must_be_absent("CABIN_TEST_NAME");
must_be_absent("CABIN_TEST_NAME_CANONICAL");
must_be_absent("CABIN_TARGET_KIND");
must_be_absent("CABIN_TARGET_TRIPLE");
must_be_absent("CABIN_HOST_TRIPLE");
must_be_absent("CABIN_BUILD_CONFIGURATION_FINGERPRINT");
return status;
}
"#,
)
.unwrap();
let assertion = cabin()
.args(["test", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--build-dir")
.arg(dir.path().join("build"))
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
for expected in [
"KEEP CABIN_PACKAGE_NAME=env_demo",
"KEEP CABIN_PACKAGE_VERSION=0.1.0",
"KEEP CABIN_PROFILE=dev",
"KEEP CABIN_MANIFEST_DIR",
"KEEP CABIN_MANIFEST_PATH",
"KEEP CABIN_BUILD_DIR",
"ABSENT CABIN",
"ABSENT CABIN_PACKAGE_NAME_CANONICAL",
"ABSENT CABIN_BIN_NAME",
"ABSENT CABIN_BIN_NAME_CANONICAL",
"ABSENT CABIN_TEST_NAME",
"ABSENT CABIN_TEST_NAME_CANONICAL",
"ABSENT CABIN_TARGET_KIND",
"ABSENT CABIN_TARGET_TRIPLE",
"ABSENT CABIN_HOST_TRIPLE",
"ABSENT CABIN_BUILD_CONFIGURATION_FINGERPRINT",
"test env_demo:env_test ... ok",
] {
assert!(
stdout.contains(expected),
"expected `{expected}` in test output, got: {stdout}"
);
}
assert!(
!stdout.contains("LEAK "),
"no removed CABIN_* variable may be injected, got: {stdout}"
);
}
#[test]
fn cabin_test_exits_non_zero_on_failure() {
skip_if!(
!build_tools_available(),
"cabin_test_exits_non_zero_on_failure",
"ninja or a C++ compiler is unavailable on PATH"
);
let dir = passing_test_project();
dir.child("tests/lib_test.cc")
.write_str("int main() { return 17; }\n")
.unwrap();
let assertion = cabin()
.args(["test", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--build-dir")
.arg(dir.path().join("build"))
.assert()
.failure();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stdout.contains("test demo:demo_test ... FAILED (exit 17)"),
"expected per-test failure line, got stdout: {stdout}"
);
assert!(
stderr.contains("test failures: 1 of 1"),
"expected failure summary in stderr, got: {stderr}"
);
}
#[test]
fn cabin_test_no_targets_errors_by_default() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "lib_only"
version = "0.1.0"
[target.lib_only]
type = "library"
sources = ["src/lib.cc"]
"#,
)
.unwrap();
dir.child("src/lib.cc")
.write_str("int x() { return 1; }\n")
.unwrap();
let assertion = cabin()
.args(["test", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--build-dir")
.arg(dir.path().join("build"))
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("no test targets found"),
"expected no-test-targets error, got: {stderr}"
);
}
#[test]
fn cabin_test_no_targets_succeeds_with_allow_no_tests() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "lib_only"
version = "0.1.0"
[target.lib_only]
type = "library"
sources = ["src/lib.cc"]
"#,
)
.unwrap();
dir.child("src/lib.cc")
.write_str("int x() { return 1; }\n")
.unwrap();
let assertion = cabin()
.args(["test", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--build-dir")
.arg(dir.path().join("build"))
.arg("--allow-no-tests")
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
assert!(
stdout.contains("no test targets found"),
"expected explanatory line, got: {stdout}"
);
}
#[test]
fn cabin_test_runs_in_deterministic_package_then_target_order() {
skip_if!(
!build_tools_available(),
"cabin_test_runs_in_deterministic_package_then_target_order",
"ninja or a C++ compiler is unavailable on PATH"
);
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/b", "packages/a"]
"#,
)
.unwrap();
for (member, deps_table) in [("a", "[target.a_z_test]"), ("b", "[target.b_a_test]")] {
assert_fs::fixture::ChildPath::new(
dir.path().join(format!("packages/{member}/cabin.toml")),
)
.write_str(&format!(
r#"[package]
name = "{member}"
version = "0.1.0"
[target.{member}]
type = "library"
sources = ["src/lib.cc"]
{deps_table}
type = "test"
sources = ["tests/lib_test.cc"]
deps = ["{member}"]
"#
))
.unwrap();
dir.child(format!("packages/{member}/src/lib.cc"))
.write_str("int x() { return 0; }\n")
.unwrap();
dir.child(format!("packages/{member}/tests/lib_test.cc"))
.write_str("int main() { return 0; }\n")
.unwrap();
}
let assertion = cabin()
.args(["test", "--workspace", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--build-dir")
.arg(dir.path().join("build"))
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let a_pos = stdout
.find("test a:a_z_test ... ok")
.unwrap_or_else(|| panic!("missing a:a_z_test in: {stdout}"));
let b_pos = stdout
.find("test b:b_a_test ... ok")
.unwrap_or_else(|| panic!("missing b:b_a_test in: {stdout}"));
assert!(
a_pos < b_pos,
"tests must run in (package, target) ascending order; got: {stdout}"
);
}
#[test]
fn cabin_test_rejects_target_flag_as_unknown_argument() {
cabin()
.args(["test", "--target", "foo"])
.assert()
.failure()
.code(2)
.stderr(predicate::str::contains(
"unexpected argument '--target' found",
));
}
#[test]
fn package_archive_includes_test_and_example_sources() {
let dir = project_with_dev_kinds();
let out = dir.path().join("dist");
cabin()
.args(["package", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--output-dir"])
.arg(&out)
.assert()
.success();
let archive = out.join("demo-0.1.0.tar.gz");
let bytes = fs::read(&archive).expect("archive readable");
let listing = list_tar_gz_paths(&bytes);
for expected in ["src/lib.cc", "tests/lib_test.cc", "examples/hello.cc"] {
assert!(
listing.iter().any(|p| p.ends_with(expected)),
"archive missing {expected}; got: {listing:?}"
);
}
}
fn list_tar_gz_paths(bytes: &[u8]) -> Vec<String> {
let decoder = flate2::read::GzDecoder::new(bytes);
let mut archive = tar::Archive::new(decoder);
archive
.entries()
.expect("entries iterator")
.map(|e| {
e.expect("entry")
.path()
.expect("path")
.to_string_lossy()
.into_owned()
})
.collect()
}
}
mod c_language {
use super::*;
fn write_c_only_library(dir: &Path) {
assert_fs::fixture::ChildPath::new(dir.join("cabin.toml"))
.write_str(
r#"[package]
name = "cdemo"
version = "0.1.0"
[target.cdemo]
type = "library"
sources = ["src/lib.c"]
include_dirs = ["include"]
[target.runner]
type = "executable"
sources = ["src/main.c"]
deps = ["cdemo"]
"#,
)
.unwrap();
assert_fs::fixture::ChildPath::new(dir.join("include/cdemo.h"))
.write_str("#pragma once\nint cdemo(void);\n")
.unwrap();
assert_fs::fixture::ChildPath::new(dir.join("src/lib.c"))
.write_str("#include \"cdemo.h\"\nint cdemo(void) { return 7; }\n")
.unwrap();
assert_fs::fixture::ChildPath::new(dir.join("src/main.c"))
.write_str("#include \"cdemo.h\"\nint main(void) { return cdemo() == 7 ? 0 : 1; }\n")
.unwrap();
}
fn write_mixed_library(dir: &Path) {
assert_fs::fixture::ChildPath::new(dir.join("cabin.toml"))
.write_str(
r#"[package]
name = "mixed"
version = "0.1.0"
[target.mixedlib]
type = "library"
sources = ["src/c_part.c", "src/cpp_part.cc"]
include_dirs = ["include"]
[target.app]
type = "executable"
sources = ["src/main.cc"]
deps = ["mixedlib"]
"#,
)
.unwrap();
assert_fs::fixture::ChildPath::new(dir.join("include/mixed.h"))
.write_str("#pragma once\n#ifdef __cplusplus\nextern \"C\" {\n#endif\nint c_value(void);\n#ifdef __cplusplus\n}\n#endif\nint cpp_value();\n")
.unwrap();
assert_fs::fixture::ChildPath::new(dir.join("src/c_part.c"))
.write_str("#include \"mixed.h\"\nint c_value(void) { return 21; }\n")
.unwrap();
assert_fs::fixture::ChildPath::new(dir.join("src/cpp_part.cc"))
.write_str("#include \"mixed.h\"\nint cpp_value() { return 21; }\n")
.unwrap();
assert_fs::fixture::ChildPath::new(dir.join("src/main.cc"))
.write_str("#include \"mixed.h\"\nint main() { return (c_value() + cpp_value()) == 42 ? 0 : 1; }\n")
.unwrap();
}
#[test]
fn metadata_reports_target_kinds_for_c_only_project() {
let dir = TempDir::new().unwrap();
write_c_only_library(dir.path());
let value = run_metadata(&dir.path().join("cabin.toml"));
let pkg = package_in(&value, "cdemo");
let target_kinds: std::collections::BTreeMap<String, String> = pkg["targets"]
.as_array()
.unwrap()
.iter()
.map(|t| {
(
t["name"].as_str().unwrap().to_owned(),
t["kind"].as_str().unwrap().to_owned(),
)
})
.collect();
assert_eq!(
target_kinds.get("cdemo").map(String::as_str),
Some("library")
);
assert_eq!(
target_kinds.get("runner").map(String::as_str),
Some("executable")
);
}
#[test]
fn build_c_only_project_emits_c_compile_rule_and_c_link_driver() {
skip_if!(
!c_and_cxx_build_tools_available(),
"build_c_only_project_emits_c_compile_rule_and_c_link_driver",
"ninja, a C compiler, or a C++ compiler is unavailable on PATH"
);
let dir = TempDir::new().unwrap();
write_c_only_library(dir.path());
cabin()
.args(["build", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--build-dir")
.arg(dir.path().join("build"))
.assert()
.success();
let ninja = fs::read_to_string(dir.path().join("build/dev/build.ninja")).unwrap();
assert!(
ninja.contains("c_compile"),
"expected c_compile rule to be referenced: {ninja}"
);
assert!(
!ninja
.lines()
.any(|l| l.contains("cxx_compile") && l.starts_with("build ")),
"no cxx_compile build edges expected for pure-C package: {ninja}"
);
let link_line = ninja
.lines()
.find(|l| l.contains("link_executable") && l.contains("/runner"))
.expect("link edge for runner");
let next = ninja
.lines()
.skip_while(|l| *l != link_line)
.nth(1)
.expect("link edge has a command line");
assert!(
!next.contains("c++") && !next.contains("g++") && !next.contains("clang++"),
"C-only link must not use a C++ driver, got: {next}"
);
}
#[test]
fn build_mixed_project_uses_cxx_link_driver_when_any_object_is_cxx() {
skip_if!(
!c_and_cxx_build_tools_available(),
"build_mixed_project_uses_cxx_link_driver_when_any_object_is_cxx",
"ninja, a C compiler, or a C++ compiler is unavailable on PATH"
);
let dir = TempDir::new().unwrap();
write_mixed_library(dir.path());
cabin()
.args(["build", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--build-dir")
.arg(dir.path().join("build"))
.assert()
.success();
let ninja = fs::read_to_string(dir.path().join("build/dev/build.ninja")).unwrap();
assert!(
ninja.contains("c_compile"),
"expected a C compile edge for mixed package: {ninja}"
);
assert!(
ninja.contains("cxx_compile"),
"expected a C++ compile edge for mixed package: {ninja}"
);
let link_line = ninja
.lines()
.find(|l| l.contains("link_executable") && l.ends_with("libmixedlib.a"))
.expect("link edge for app");
let cmd = ninja
.lines()
.skip_while(|l| *l != link_line)
.nth(1)
.expect("link edge has a command line");
assert!(
cmd.contains("c++") || cmd.contains("g++") || cmd.contains("clang++"),
"mixed link must use a C++ driver, got: {cmd}"
);
}
#[test]
fn link_driver_path_matches_resolved_cc_path_for_pure_c_target() {
skip_if!(
!c_and_cxx_build_tools_available(),
"link_driver_path_matches_resolved_cc_path_for_pure_c_target",
"ninja, a C compiler, or a C++ compiler is unavailable on PATH"
);
let dir = TempDir::new().unwrap();
write_c_only_library(dir.path());
let metadata = run_metadata(&dir.path().join("cabin.toml"));
let cc_path = metadata["toolchain"]["detected"]["cc"]["path"]
.as_str()
.expect("metadata must report a resolved cc path on this host");
cabin()
.args(["build", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--build-dir")
.arg(dir.path().join("build"))
.assert()
.success();
let ninja = fs::read_to_string(dir.path().join("build/dev/build.ninja")).unwrap();
let link_cmds = compile_command_lines_for_rule(&ninja, "link_executable");
assert_eq!(link_cmds.len(), 1, "expected one link edge");
let link_argv: Vec<&str> = link_cmds[0].split_whitespace().collect();
assert_eq!(
link_argv[0], cc_path,
"pure-C target must link with the resolved C compiler, got: {}",
link_cmds[0]
);
}
#[test]
fn cabin_test_runs_pure_c_test_executable() {
skip_if!(
!c_and_cxx_build_tools_available(),
"cabin_test_runs_pure_c_test_executable",
"ninja, a C compiler, or a C++ compiler is unavailable on PATH"
);
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "cdemo"
version = "0.1.0"
[target.cdemo]
type = "library"
sources = ["src/lib.c"]
include_dirs = ["include"]
[target.cdemo_test]
type = "test"
sources = ["tests/lib_test.c"]
deps = ["cdemo"]
"#,
)
.unwrap();
dir.child("include/cdemo.h")
.write_str("#pragma once\nint cdemo(void);\n")
.unwrap();
dir.child("src/lib.c")
.write_str("#include \"cdemo.h\"\nint cdemo(void) { return 9; }\n")
.unwrap();
dir.child("tests/lib_test.c")
.write_str("#include \"cdemo.h\"\nint main(void) { return cdemo() == 9 ? 0 : 1; }\n")
.unwrap();
let assertion = cabin()
.args(["test", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--build-dir")
.arg(dir.path().join("build"))
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
assert!(
stdout.contains("test cdemo:cdemo_test ... ok"),
"expected passing C test, got: {stdout}"
);
}
#[test]
fn unrecognized_source_extension_is_rejected() {
skip_if!(
!build_tools_available(),
"unrecognized_source_extension_is_rejected",
"ninja or a C++ compiler is unavailable on PATH"
);
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "broken"
version = "0.1.0"
[target.broken]
type = "library"
sources = ["src/file.txt"]
"#,
)
.unwrap();
dir.child("src/file.txt")
.write_str("not a source\n")
.unwrap();
let assertion = cabin()
.args(["build", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--build-dir")
.arg(dir.path().join("build"))
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("unrecognized extension"),
"expected explicit extension diagnostic, got: {stderr}"
);
assert!(
stderr.contains(".c") && stderr.contains(".cc"),
"diagnostic should list supported extensions, got: {stderr}"
);
}
#[test]
fn cflags_and_cxxflags_do_not_leak_across_languages() {
skip_if!(
!c_and_cxx_build_tools_available(),
"cflags_and_cxxflags_do_not_leak_across_languages",
"ninja, a C compiler, or a C++ compiler is unavailable on PATH"
);
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "splitflags"
version = "0.1.0"
[profile]
cflags = ["-DCABIN_TEST_C_FLAG=1"]
cxxflags = ["-DCABIN_TEST_CXX_FLAG=1"]
[target.splitflags]
type = "library"
sources = ["src/c_part.c", "src/cpp_part.cc"]
"#,
)
.unwrap();
dir.child("src/c_part.c")
.write_str("int c_part_value(void) { return 0; }\n")
.unwrap();
dir.child("src/cpp_part.cc")
.write_str("int cpp_part_value() { return 0; }\n")
.unwrap();
cabin()
.args(["build", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--build-dir")
.arg(dir.path().join("build"))
.assert()
.success();
let ninja = fs::read_to_string(dir.path().join("build/dev/build.ninja")).unwrap();
let c_compile_lines = compile_command_lines_for_rule(&ninja, "c_compile");
let cxx_compile_lines = compile_command_lines_for_rule(&ninja, "cxx_compile");
assert!(
!c_compile_lines.is_empty(),
"expected at least one c_compile edge: {ninja}"
);
assert!(
!cxx_compile_lines.is_empty(),
"expected at least one cxx_compile edge: {ninja}"
);
for line in &c_compile_lines {
assert!(
line.contains("-DCABIN_TEST_C_FLAG=1"),
"C compile must include the C-only define, got: {line}"
);
assert!(
!line.contains("-DCABIN_TEST_CXX_FLAG=1"),
"C-only define must NOT leak into the C++ compile, got: {line}"
);
}
for line in &cxx_compile_lines {
assert!(
line.contains("-DCABIN_TEST_CXX_FLAG=1"),
"C++ compile must include the C++-only define, got: {line}"
);
assert!(
!line.contains("-DCABIN_TEST_C_FLAG=1"),
"C++-only define must NOT leak into the C compile, got: {line}"
);
}
}
#[test]
fn cabin_test_runs_cpp_test_depending_on_c_library() {
skip_if!(
!c_and_cxx_build_tools_available(),
"cabin_test_runs_cpp_test_depending_on_c_library",
"ninja or a C++ compiler is unavailable on PATH"
);
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "interop"
version = "0.1.0"
[target.clib]
type = "library"
sources = ["src/clib.c"]
include_dirs = ["include"]
[target.cpp_test]
type = "test"
sources = ["tests/clib_test.cc"]
deps = ["clib"]
"#,
)
.unwrap();
dir.child("include/clib.h")
.write_str("#pragma once\n#ifdef __cplusplus\nextern \"C\" {\n#endif\nint c_value(void);\n#ifdef __cplusplus\n}\n#endif\n")
.unwrap();
dir.child("src/clib.c")
.write_str("#include \"clib.h\"\nint c_value(void) { return 99; }\n")
.unwrap();
dir.child("tests/clib_test.cc")
.write_str("#include \"clib.h\"\nint main() { return c_value() == 99 ? 0 : 1; }\n")
.unwrap();
let assertion = cabin()
.args(["test", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--build-dir")
.arg(dir.path().join("build"))
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
assert!(
stdout.contains("test interop:cpp_test ... ok"),
"expected passing C++ test that consumes a C library, got: {stdout}"
);
let ninja = fs::read_to_string(dir.path().join("build/dev/build.ninja")).unwrap();
let c_compile_lines = compile_command_lines_for_rule(&ninja, "c_compile");
let cxx_compile_lines = compile_command_lines_for_rule(&ninja, "cxx_compile");
assert!(!c_compile_lines.is_empty(), "expected C compile edge");
assert!(!cxx_compile_lines.is_empty(), "expected C++ compile edge");
let link_cmds = compile_command_lines_for_rule(&ninja, "link_executable");
assert_eq!(link_cmds.len(), 1, "expected one link edge");
let link = &link_cmds[0];
assert!(
link.contains("c++") || link.contains("g++") || link.contains("clang++"),
"C++ test target must link with a C++ driver, got: {link}"
);
}
#[test]
fn cabin_test_runs_mixed_c_and_cpp_tests_in_deterministic_order() {
skip_if!(
!c_and_cxx_build_tools_available(),
"cabin_test_runs_mixed_c_and_cpp_tests_in_deterministic_order",
"ninja, a C compiler, or a C++ compiler is unavailable on PATH"
);
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "mixedtests"
version = "0.1.0"
[target.zz_cpp_test]
type = "test"
sources = ["tests/zz_cpp.cc"]
[target.aa_c_test]
type = "test"
sources = ["tests/aa_c.c"]
"#,
)
.unwrap();
dir.child("tests/zz_cpp.cc")
.write_str("int main() { return 0; }\n")
.unwrap();
dir.child("tests/aa_c.c")
.write_str("int main(void) { return 0; }\n")
.unwrap();
let assertion = cabin()
.args(["test", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--build-dir")
.arg(dir.path().join("build"))
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout);
let aa_pos = stdout
.find("test mixedtests:aa_c_test ... ok")
.expect("aa_c_test result must be present");
let zz_pos = stdout
.find("test mixedtests:zz_cpp_test ... ok")
.expect("zz_cpp_test result must be present");
assert!(
aa_pos < zz_pos,
"tests must run in (package, target) ascending order regardless of language; got: {stdout}"
);
}
#[test]
fn missing_c_compiler_yields_actionable_diagnostic() {
skip_if!(
!build_tools_available(),
"missing_c_compiler_yields_actionable_diagnostic",
"ninja or a C++ compiler is unavailable on PATH"
);
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "needscc"
version = "0.1.0"
[target.needscc]
type = "library"
sources = ["src/lib.c"]
"#,
)
.unwrap();
dir.child("src/lib.c")
.write_str("int needscc_value(void) { return 0; }\n")
.unwrap();
let missing_cc = dir.path().join("missing-cc");
let assertion = cabin()
.args(["build", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--build-dir")
.arg(dir.path().join("build"))
.arg("--cc")
.arg(&missing_cc)
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("C compiler") || stderr.contains("`cc`"),
"expected error to mention the C compiler, got: {stderr}"
);
}
fn compile_command_lines_for_rule(ninja: &str, rule_name: &str) -> Vec<String> {
let needle = format!(": {rule_name} ");
let mut out: Vec<String> = Vec::new();
let mut lines = ninja.lines();
while let Some(line) = lines.next() {
if !line.starts_with("build ") || !line.contains(&needle) {
continue;
}
for inner in lines.by_ref() {
if inner.is_empty() {
break;
}
if let Some(rest) = inner.strip_prefix(" command = ") {
out.push(rest.to_owned());
break;
}
}
}
out
}
}
mod vendor_offline {
use super::*;
use std::path::PathBuf;
fn stage_fmt_index(root: &Path) -> PathBuf {
let index = root.join("index");
assert_fs::fixture::ChildPath::new(index.join("config.json"))
.write_str("{\"schema\":1,\"kind\":\"file-registry\",\"packages\":\"packages\",\"artifacts\":\"artifacts\"}\n")
.unwrap();
let archive = index.join("artifacts/fmt/fmt-10.2.1.tar.gz");
let manifest = "[package]\nname = \"fmt\"\nversion = \"10.2.1\"\n\n[target.fmt]\ntype = \"library\"\nsources = [\"src/fmt.cc\"]\ninclude_dirs = [\"include\"]\n";
let header = "#pragma once\nint fmt_value();\n";
let body = "#include \"fmt.h\"\nint fmt_value() { return 42; }\n";
let checksum = make_archive(
&archive,
&[
("cabin.toml", manifest),
("include/fmt.h", header),
("src/fmt.cc", body),
],
);
let entry = format!(
"{{\n \"schema\": 1,\n \"name\": \"fmt\",\n \"versions\": {{\n \"10.2.1\": {{\n \"dependencies\": {{}},\n \"yanked\": false,\n \"checksum\": \"sha256:{checksum}\",\n \"source\": {{\"type\": \"archive\", \"path\": \"../artifacts/fmt/fmt-10.2.1.tar.gz\", \"format\": \"tar.gz\"}}\n }}\n }}\n}}\n",
);
assert_fs::fixture::ChildPath::new(index.join("packages/fmt.json"))
.write_str(&entry)
.unwrap();
index
}
fn stage_consumer_project(root: &Path) {
assert_fs::fixture::ChildPath::new(root.join("cabin.toml"))
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = "10.2.1"
[target.app]
type = "executable"
sources = ["src/main.cc"]
deps = ["fmt"]
"#,
)
.unwrap();
assert_fs::fixture::ChildPath::new(root.join("src/main.cc"))
.write_str(
"extern int fmt_value();\nint main() { return fmt_value() == 42 ? 0 : 1; }\n",
)
.unwrap();
}
#[test]
fn vendor_writes_deterministic_file_registry() {
let dir = TempDir::new().unwrap();
let index = stage_fmt_index(dir.path());
stage_consumer_project(&dir.path().join("proj"));
cabin()
.args(["vendor", "--manifest-path"])
.arg(dir.path().join("proj/cabin.toml"))
.arg("--vendor-dir")
.arg(dir.path().join("proj/vendor"))
.arg("--index-path")
.arg(&index)
.arg("--cache-dir")
.arg(dir.path().join("cache"))
.assert()
.success();
let vendor = dir.path().join("proj/vendor");
assert!(vendor.join("config.json").is_file());
assert!(vendor.join("packages/fmt.json").is_file());
assert!(vendor.join("artifacts/fmt/fmt-10.2.1.tar.gz").is_file());
assert!(vendor.join("cabin-vendor.json").is_file());
let body = fs::read_to_string(vendor.join("packages/fmt.json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
let path = parsed["versions"]["10.2.1"]["source"]["path"]
.as_str()
.unwrap();
assert_eq!(path, "../artifacts/fmt/fmt-10.2.1.tar.gz");
let summary = fs::read(vendor.join("cabin-vendor.json")).unwrap();
cabin()
.args(["vendor", "--manifest-path"])
.arg(dir.path().join("proj/cabin.toml"))
.arg("--vendor-dir")
.arg(&vendor)
.arg("--index-path")
.arg(&index)
.arg("--cache-dir")
.arg(dir.path().join("cache"))
.assert()
.success();
let summary_again = fs::read(vendor.join("cabin-vendor.json")).unwrap();
assert_eq!(summary, summary_again);
}
#[test]
fn vendor_then_offline_build_links_against_the_vendored_dependency() {
skip_if!(
!build_tools_available(),
"vendor_then_offline_build_links_against_the_vendored_dependency",
"ninja or a C++ compiler is unavailable on PATH"
);
let dir = TempDir::new().unwrap();
let index = stage_fmt_index(dir.path());
stage_consumer_project(&dir.path().join("proj"));
cabin()
.args(["vendor", "--manifest-path"])
.arg(dir.path().join("proj/cabin.toml"))
.arg("--vendor-dir")
.arg(dir.path().join("proj/vendor"))
.arg("--index-path")
.arg(&index)
.arg("--cache-dir")
.arg(dir.path().join("cache"))
.assert()
.success();
fs::remove_dir_all(&index).unwrap();
cabin()
.args(["build", "--offline", "--manifest-path"])
.arg(dir.path().join("proj/cabin.toml"))
.arg("--build-dir")
.arg(dir.path().join("proj/build"))
.arg("--index-path")
.arg(dir.path().join("proj/vendor"))
.arg("--cache-dir")
.arg(dir.path().join("vendor-cache"))
.assert()
.success();
let exe = dir.path().join("proj/build/dev/packages/app/app");
assert!(exe.is_file(), "offline build must link the executable");
}
#[test]
fn offline_rejects_index_url() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "lone"
version = "0.1.0"
"#,
)
.unwrap();
let assertion = cabin()
.args(["build", "--offline", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--index-url")
.arg("https://example.com/index")
.arg("--build-dir")
.arg(dir.path().join("build"))
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("--offline forbids network access"),
"expected offline rejection, got: {stderr}"
);
assert!(
stderr.contains("https://example.com/index"),
"diagnostic should name the rejected URL, got: {stderr}"
);
}
#[test]
fn vendor_with_no_versioned_deps_writes_skeleton_only() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "lone"
version = "0.1.0"
[target.lone]
type = "library"
sources = ["src/lib.cc"]
"#,
)
.unwrap();
dir.child("src/lib.cc")
.write_str("int lone_value() { return 0; }\n")
.unwrap();
let vendor = dir.path().join("vendor");
cabin()
.args(["vendor", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--vendor-dir")
.arg(&vendor)
.assert()
.success();
assert!(vendor.join("config.json").is_file());
assert!(vendor.join("cabin-vendor.json").is_file());
assert!(vendor.join("packages").is_dir());
assert!(vendor.join("artifacts").is_dir());
}
#[test]
fn vendor_locked_succeeds_when_lockfile_is_current() {
let dir = TempDir::new().unwrap();
let index = stage_fmt_index(dir.path());
stage_consumer_project(&dir.path().join("proj"));
cabin()
.args(["vendor", "--manifest-path"])
.arg(dir.path().join("proj/cabin.toml"))
.arg("--vendor-dir")
.arg(dir.path().join("proj/vendor"))
.arg("--index-path")
.arg(&index)
.arg("--cache-dir")
.arg(dir.path().join("cache"))
.assert()
.success();
let lock_before = fs::read_to_string(dir.path().join("proj/cabin.lock")).unwrap();
cabin()
.args(["vendor", "--locked", "--manifest-path"])
.arg(dir.path().join("proj/cabin.toml"))
.arg("--vendor-dir")
.arg(dir.path().join("proj/vendor"))
.arg("--index-path")
.arg(&index)
.arg("--cache-dir")
.arg(dir.path().join("cache"))
.assert()
.success();
let lock_after = fs::read_to_string(dir.path().join("proj/cabin.lock")).unwrap();
assert_eq!(lock_before, lock_after);
}
}
mod metadata_tree_explain {
use super::*;
fn write_app_with_path_dep(dir: &Path) {
assert_fs::fixture::ChildPath::new(dir.join("cabin.toml"))
.write_str(
r#"[workspace]
members = ["app", "lib"]
"#,
)
.unwrap();
assert_fs::fixture::ChildPath::new(dir.join("app/cabin.toml"))
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
lib = { path = "../lib" }
[target.app]
type = "executable"
sources = ["src/main.cc"]
"#,
)
.unwrap();
assert_fs::fixture::ChildPath::new(dir.join("app/src/main.cc"))
.write_str(HELLO_MAIN_CC)
.unwrap();
assert_fs::fixture::ChildPath::new(dir.join("lib/cabin.toml"))
.write_str(
r#"[package]
name = "lib"
version = "0.1.0"
[target.lib]
type = "library"
sources = ["src/lib.cc"]
"#,
)
.unwrap();
assert_fs::fixture::ChildPath::new(dir.join("lib/src/lib.cc"))
.write_str("int lib_value() { return 1; }\n")
.unwrap();
}
#[test]
fn tree_human_format_default_emits_box_drawing() {
let dir = TempDir::new().unwrap();
write_app_with_path_dep(dir.path());
let output = cabin()
.args(["tree", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains("app v0.1.0"), "got: {stdout}");
assert!(stdout.contains("lib v0.1.0"), "got: {stdout}");
assert!(stdout.contains("[normal]"), "got: {stdout}");
assert!(
stdout.contains("└── lib") || stdout.contains("├── lib"),
"got: {stdout}"
);
}
#[test]
fn tree_json_format_is_valid_structured_document() {
let dir = TempDir::new().unwrap();
write_app_with_path_dep(dir.path());
let value = run_json(
cabin()
.args(["tree", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--format", "json"]),
);
let arr = value.as_array().expect("forest must be a JSON array");
let app = arr
.iter()
.find(|n| n["name"] == "app")
.expect("app must be a root in tree forest");
assert_eq!(app["version"], "0.1.0");
assert_eq!(app["source"]["kind"], "workspace-member");
let children = app["children"].as_array().expect("children must be array");
assert_eq!(children.len(), 1, "app should have exactly one child");
assert_eq!(children[0]["name"], "lib");
assert_eq!(children[0]["edge_kind"], "normal");
}
#[test]
fn tree_default_roots_honor_workspace_default_members() {
let dir = TempDir::new().unwrap();
write_app_with_path_dep(dir.path());
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["app", "lib"]
default-members = ["app"]
"#,
)
.unwrap();
let output = cabin()
.args(["tree", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--format", "json"])
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).unwrap();
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let roots = value.as_array().expect("forest must be a JSON array");
let root_names: Vec<&str> = roots
.iter()
.map(|node| node["name"].as_str().unwrap())
.collect();
assert_eq!(
root_names,
vec!["app"],
"implicit tree selection should use default-members, got: {stdout}"
);
assert_eq!(roots[0]["children"][0]["name"], "lib");
}
#[test]
fn tree_kind_filter_restricts_to_normal_edges() {
let dir = TempDir::new().unwrap();
write_app_with_path_dep(dir.path());
let value = run_json(
cabin()
.args(["tree", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--kind", "normal"])
.args(["--format", "json"]),
);
let app = value
.as_array()
.unwrap()
.iter()
.find(|n| n["name"] == "app")
.expect("app must be present");
let children = app["children"].as_array().unwrap();
assert!(
children.iter().all(|c| c["edge_kind"] == "normal"),
"--kind normal should restrict to normal edges, got: {children:?}"
);
}
#[test]
fn explain_package_marks_selected_root() {
let dir = TempDir::new().unwrap();
write_app_with_path_dep(dir.path());
let value = run_json(
cabin()
.args(["explain", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--format", "json", "package", "app"]),
);
assert_eq!(value["kind"], "package");
assert_eq!(value["name"], "app");
assert_eq!(value["is_selected_root"], true);
assert_eq!(value["source"]["kind"], "workspace-member");
}
#[test]
fn explain_package_traces_dep_path_from_root() {
let dir = TempDir::new().unwrap();
write_app_with_path_dep(dir.path());
let value = run_json(
cabin()
.args(["explain", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--package", "app"])
.args(["--format", "json", "package", "lib"]),
);
let paths = value["paths"].as_array().unwrap();
assert!(!paths.is_empty(), "lib must be reachable from a root");
let first = paths[0].as_array().unwrap();
assert_eq!(first[0]["name"], "app");
assert_eq!(first[1]["name"], "lib");
assert_eq!(first[1]["edge_kind"], "normal");
}
#[test]
fn explain_unknown_package_returns_diagnostic() {
let dir = TempDir::new().unwrap();
write_app_with_path_dep(dir.path());
let assertion = cabin()
.args(["explain", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["package", "missing"])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("`missing`") && stderr.contains("not found"),
"expected package-not-found diagnostic, got: {stderr}"
);
assert!(
stderr.contains("cabin::explain::error"),
"the typed `ExplainError` must reach the diagnostic dispatcher so the stable code is emitted, got: {stderr}",
);
}
#[test]
fn explain_target_reports_languages_and_kind() {
let dir = TempDir::new().unwrap();
write_app_with_path_dep(dir.path());
let value = run_json(
cabin()
.args(["explain", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--format", "json", "target", "lib"]),
);
assert_eq!(value["kind"], "target");
assert_eq!(value["package"], "lib");
assert_eq!(value["target"], "lib");
assert_eq!(value["target_kind"], "library");
assert!(value["is_buildable"].as_bool().unwrap());
let langs = value["languages"].as_array().unwrap();
assert!(
langs.iter().any(|v| v == "cxx"),
"expected cxx in languages, got: {langs:?}"
);
}
#[test]
fn explain_source_reports_workspace_member_provenance() {
let dir = TempDir::new().unwrap();
write_app_with_path_dep(dir.path());
let value = run_json(
cabin()
.args(["explain", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--format", "json", "source", "app"]),
);
assert_eq!(value["kind"], "source");
assert_eq!(value["name"], "app");
assert_eq!(value["source"]["kind"], "workspace-member");
}
#[test]
fn explain_feature_query_without_separator_errors() {
let dir = TempDir::new().unwrap();
write_app_with_path_dep(dir.path());
let assertion = cabin()
.args(["explain", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["feature", "no-separator"])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("`package/feature`"),
"expected feature query diagnostic, got: {stderr}"
);
}
#[test]
fn explain_build_config_emits_fingerprint_field() {
let dir = TempDir::new().unwrap();
write_app_with_path_dep(dir.path());
let value = run_json(
cabin()
.args(["explain", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.args(["--format", "json", "build-config", "app"]),
);
assert_eq!(value["kind"], "build-config");
assert_eq!(value["package"], "app");
let cfg = &value["configuration"];
assert!(
cfg["fingerprint"].is_string(),
"fingerprint must be present, got: {cfg}"
);
assert!(cfg["profile"].is_object());
}
#[test]
fn tree_renders_deterministically_across_runs() {
let dir = TempDir::new().unwrap();
write_app_with_path_dep(dir.path());
let manifest = dir.path().join("cabin.toml");
let first = cabin()
.args(["tree", "--manifest-path"])
.arg(&manifest)
.args(["--format", "json"])
.assert()
.success()
.get_output()
.clone();
let second = cabin()
.args(["tree", "--manifest-path"])
.arg(&manifest)
.args(["--format", "json"])
.assert()
.success()
.get_output()
.clone();
assert_eq!(
first.stdout, second.stdout,
"tree output must be byte-stable"
);
}
}
mod cargo_interface {
use super::*;
use std::path::PathBuf;
const ARGV_AND_ENV_MAIN_CC: &str = r#"
#include <cstdio>
#include <cstdlib>
int main(int argc, char** argv) {
for (int i = 1; i < argc; ++i) {
std::printf("ARG %d %s\n", i, argv[i]);
}
if (const char* p = std::getenv("CABIN_PACKAGE_NAME")) {
std::printf("PKG %s\n", p);
}
if (const char* p = std::getenv("CABIN_PROFILE")) {
std::printf("PROFILE %s\n", p);
}
return 0;
}
"#;
fn write_run_fixture(root: &Path, package: &str, target: &str) {
assert_fs::fixture::ChildPath::new(root.join("cabin.toml"))
.write_str(&format!(
r#"[package]
name = "{package}"
version = "0.1.0"
[target.{target}]
type = "executable"
sources = ["src/main.cc"]
"#
))
.unwrap();
assert_fs::fixture::ChildPath::new(root.join("src/main.cc"))
.write_str(ARGV_AND_ENV_MAIN_CC)
.unwrap();
}
#[test]
fn run_executes_default_binary_and_forwards_trailing_args() {
if !ninja_available() || !cxx_compiler_available() {
eprintln!("test skipped: requires ninja + a C++ compiler");
return;
}
let dir = TempDir::new().unwrap();
write_run_fixture(dir.path(), "demo", "demo_app");
let output = cabin()
.args(["run", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--build-dir")
.arg(dir.path().join("build"))
.args(["--", "first", "second"])
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains("ARG 1 first"), "got: {stdout}");
assert!(stdout.contains("ARG 2 second"), "got: {stdout}");
assert!(stdout.contains("PKG demo"), "got: {stdout}");
assert!(stdout.contains("PROFILE dev"), "got: {stdout}");
}
#[test]
fn run_does_not_materialize_dev_dependencies() {
if !build_tools_available() {
eprintln!("test skipped: requires ninja + a C++ compiler");
return;
}
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
[dev-dependencies]
test_only = "1.0.0"
[target.demo]
type = "executable"
sources = ["src/main.cc"]
"#,
)
.unwrap();
dir.child("src/main.cc")
.write_str(ARGV_AND_ENV_MAIN_CC)
.unwrap();
let output = cabin()
.args(["run", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--build-dir")
.arg(dir.path().join("build"))
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains("PKG demo"), "got: {stdout}");
}
#[test]
fn run_with_bin_flag_picks_named_target() {
if !ninja_available() || !cxx_compiler_available() {
eprintln!("test skipped: requires ninja + a C++ compiler");
return;
}
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "two-bins"
version = "0.1.0"
[target.alpha]
type = "executable"
sources = ["src/alpha.cc"]
[target.beta]
type = "executable"
sources = ["src/beta.cc"]
"#,
)
.unwrap();
dir.child("src/alpha.cc")
.write_str(
"#include <cstdio>\nint main() { std::printf(\"WHICH alpha\\n\"); return 0; }\n",
)
.unwrap();
dir.child("src/beta.cc")
.write_str(
"#include <cstdio>\nint main() { std::printf(\"WHICH beta\\n\"); return 0; }\n",
)
.unwrap();
let output = cabin()
.args(["run", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--build-dir")
.arg(dir.path().join("build"))
.args(["--bin", "beta"])
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains("WHICH beta"), "got: {stdout}");
}
#[test]
fn run_in_pure_workspace_omits_fingerprint_env() {
if !ninja_available() || !cxx_compiler_available() {
eprintln!("test skipped: requires ninja + a C++ compiler");
return;
}
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/app"]
"#,
)
.unwrap();
dir.child("packages/app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[features]
default = []
fast = []
[target.app]
type = "executable"
sources = ["src/main.cc"]
"#,
)
.unwrap();
dir.child("packages/app/src/main.cc")
.write_str(ARGV_AND_ENV_MAIN_CC)
.unwrap();
let output = cabin()
.args(["run", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--build-dir")
.arg(dir.path().join("build"))
.args(["--package", "app", "--features", "fast"])
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
stdout.contains("PKG app"),
"the selected package's binary should have run: {stdout}"
);
assert!(
!stdout.contains("FINGERPRINT"),
"CABIN_BUILD_CONFIGURATION_FINGERPRINT must not be injected: {stdout}"
);
}
#[test]
fn run_without_bin_when_multiple_executables_errors() {
if !ninja_available() || !cxx_compiler_available() {
eprintln!("test skipped: requires ninja + a C++ compiler");
return;
}
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "ambiguous"
version = "0.1.0"
[target.alpha]
type = "executable"
sources = ["src/main.cc"]
[target.beta]
type = "executable"
sources = ["src/main.cc"]
"#,
)
.unwrap();
dir.child("src/main.cc").write_str(HELLO_MAIN_CC).unwrap();
let assertion = cabin()
.args(["run", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--build-dir")
.arg(dir.path().join("build"))
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("multiple `executable` targets"),
"expected ambiguous-executable error, got: {stderr}"
);
}
#[test]
fn run_unknown_bin_returns_actionable_error() {
if !ninja_available() || !cxx_compiler_available() {
eprintln!("test skipped: requires ninja + a C++ compiler");
return;
}
let dir = TempDir::new().unwrap();
write_run_fixture(dir.path(), "demo", "demo_app");
let assertion = cabin()
.args(["run", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--build-dir")
.arg(dir.path().join("build"))
.args(["--bin", "missing"])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("`missing`") && stderr.contains("not found"),
"expected --bin missing error, got: {stderr}"
);
}
#[test]
fn run_for_c_executable_target() {
if !ninja_available() || !c_compiler_available() {
eprintln!("test skipped: requires ninja + a C compiler");
return;
}
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "c-app"
version = "0.1.0"
[target.c_app]
type = "executable"
sources = ["src/main.c"]
"#,
)
.unwrap();
dir.child("src/main.c")
.write_str("#include <stdio.h>\nint main(int argc, char** argv) { (void)argc; (void)argv; puts(\"c-ok\"); return 0; }\n")
.unwrap();
let output = cabin()
.args(["run", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--build-dir")
.arg(dir.path().join("build"))
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains("c-ok"), "got: {stdout}");
}
#[test]
fn run_for_mixed_c_and_cpp_executable_target() {
if !ninja_available() || !cxx_compiler_available() || !c_compiler_available() {
eprintln!("test skipped: requires ninja + C/C++ compilers");
return;
}
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "mixed-app"
version = "0.1.0"
[target.mixed_app]
type = "executable"
sources = ["src/main.cc", "src/util.c"]
"#,
)
.unwrap();
dir.child("src/util.c")
.write_str("int util_value(void) { return 42; }\n")
.unwrap();
dir.child("src/main.cc")
.write_str(
r#"#include <cstdio>
extern "C" int util_value();
int main() { std::printf("util=%d\n", util_value()); return 0; }
"#,
)
.unwrap();
let output = cabin()
.args(["run", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--build-dir")
.arg(dir.path().join("build"))
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains("util=42"), "got: {stdout}");
}
#[test]
fn cabin_build_dir_env_var_overrides_default_directory() {
if !ninja_available() || !cxx_compiler_available() {
eprintln!("test skipped: requires ninja + a C++ compiler");
return;
}
let dir = TempDir::new().unwrap();
write_run_fixture(dir.path(), "envbin", "envbin");
let custom_dir: PathBuf = dir.path().join("custom-build");
cabin()
.env("CABIN_BUILD_DIR", &custom_dir)
.args(["build", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.success();
assert!(
custom_dir.join("dev/build.ninja").is_file(),
"expected build.ninja under {}",
custom_dir.display()
);
assert!(
!dir.path().join("build/dev/build.ninja").is_file(),
"default `build/` should not have been created"
);
}
#[test]
fn cli_build_dir_flag_wins_over_cabin_build_dir_env() {
if !ninja_available() || !cxx_compiler_available() {
eprintln!("test skipped: requires ninja + a C++ compiler");
return;
}
let dir = TempDir::new().unwrap();
write_run_fixture(dir.path(), "winflag", "winflag");
let env_dir = dir.path().join("env-build");
let cli_dir = dir.path().join("cli-build");
cabin()
.env("CABIN_BUILD_DIR", &env_dir)
.args(["build", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--build-dir")
.arg(&cli_dir)
.assert()
.success();
assert!(
cli_dir.join("dev/build.ninja").is_file(),
"CLI flag must win — expected build.ninja under {}",
cli_dir.display()
);
assert!(
!env_dir.exists(),
"env-supplied build dir should not have been used"
);
}
#[test]
fn cli_build_dir_default_literal_wins_over_cabin_build_dir_env() {
if !ninja_available() || !cxx_compiler_available() {
eprintln!("test skipped: requires ninja + a C++ compiler");
return;
}
let dir = TempDir::new().unwrap();
write_run_fixture(dir.path(), "explicit_default", "explicit_default");
let env_dir = dir.path().join("env-build");
cabin()
.current_dir(dir.path())
.env("CABIN_BUILD_DIR", &env_dir)
.args(["build", "--build-dir", "build"])
.assert()
.success();
assert!(
dir.path().join("build/dev/build.ninja").is_file(),
"explicit `--build-dir build` must win — expected build.ninja under build/dev/"
);
assert!(
!env_dir.exists(),
"env-supplied build dir should not have been used"
);
}
#[test]
fn cabin_net_offline_env_var_blocks_url_index() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "needs-fmt"
version = "0.1.0"
[dependencies]
fmt = ">=10.0.0 <11.0.0"
"#,
)
.unwrap();
let assertion = cabin()
.env("CABIN_NET_OFFLINE", "1")
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--index-url")
.arg("https://example.com/index")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("--offline forbids network access"),
"expected offline diagnostic, got: {stderr}"
);
}
#[test]
fn invalid_cabin_net_offline_env_value_is_rejected() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "needs-fmt"
version = "0.1.0"
[dependencies]
fmt = ">=10.0.0 <11.0.0"
"#,
)
.unwrap();
dir.child("index/fmt.json").write_str(FMT_INDEX).unwrap();
let assertion = cabin()
.env("CABIN_NET_OFFLINE", "nope")
.args(["resolve", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--index-path")
.arg(dir.path().join("index"))
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("CABIN_NET_OFFLINE") && stderr.contains("nope"),
"invalid offline env diagnostic should name the variable and value: {stderr}"
);
}
#[test]
fn cabin_run_exits_with_program_status_code() {
if !ninja_available() || !cxx_compiler_available() {
eprintln!("test skipped: requires ninja + a C++ compiler");
return;
}
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "exit-code"
version = "0.1.0"
[target.exit_code]
type = "executable"
sources = ["src/main.cc"]
"#,
)
.unwrap();
dir.child("src/main.cc")
.write_str("int main() { return 42; }\n")
.unwrap();
let output = cabin()
.args(["run", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.arg("--build-dir")
.arg(dir.path().join("build"))
.assert()
.failure()
.get_output()
.clone();
assert_eq!(output.status.code(), Some(42));
}
}
mod cargo_interface_cleanup {
use super::*;
fn help_text(args: &[&str]) -> String {
let mut full: Vec<&str> = args.to_vec();
full.push("--help");
let output = cabin().args(&full).assert().success().get_output().clone();
String::from_utf8(output.stdout).expect("help output should be utf-8")
}
#[test]
fn top_level_help_lists_day_to_day_subcommands() {
let out = help_text(&[]);
for cmd in visible_subcommand_names() {
assert!(out.contains(&cmd), "top-level help missing `{cmd}`:\n{out}");
}
assert!(
out.contains("C/C++") || out.contains("C/C++"),
"top-level help should mention C/C++:\n{out}"
);
}
#[test]
fn cabin_run_help_mentions_documented_flags() {
let out = help_text(&["run"]);
for needle in [
"--bin",
"--manifest-path",
"--build-dir",
"--profile",
"--release",
"--features",
"--all-features",
"--no-default-features",
"--locked",
"--frozen",
"--offline",
"--no-patches",
] {
assert!(
out.contains(needle),
"`cabin run --help` missing `{needle}`:\n{out}"
);
}
}
#[test]
fn cabin_build_help_uses_build_dir_not_target_dir() {
let out = help_text(&["build"]);
assert!(out.contains("--build-dir"), "build help: {out}");
assert!(
!out.contains("--target-dir"),
"Cabin must not surface `--target-dir`:\n{out}"
);
}
#[test]
fn cabin_build_and_test_help_do_not_advertise_target_flag() {
for sub in ["build", "test"] {
let out = help_text(&[sub]);
assert!(
!out.contains("--target"),
"`cabin {sub} --help` must not advertise `--target`:\n{out}"
);
}
}
#[test]
fn cabin_metadata_help_lists_format_flag() {
let out = help_text(&["metadata"]);
assert!(out.contains("--format"), "metadata help: {out}");
}
#[test]
fn cabin_tree_help_lists_kind_filter() {
let out = help_text(&["tree"]);
assert!(out.contains("--kind"), "tree help: {out}");
assert!(out.contains("--format"), "tree help: {out}");
}
#[test]
fn cabin_explain_help_lists_subcommand_set() {
let out = help_text(&["explain"]);
for sub in ["package", "target", "source", "feature", "build-config"] {
assert!(out.contains(sub), "explain help missing `{sub}`:\n{out}");
}
}
#[test]
fn cabin_vendor_help_mentions_vendor_dir() {
let out = help_text(&["vendor"]);
assert!(
out.contains("external versioned dependencies"),
"vendor help should not claim to vendor local path deps: {out}"
);
assert!(out.contains("--vendor-dir"), "vendor help: {out}");
assert!(out.contains("--offline"), "vendor help: {out}");
}
#[test]
fn cabin_version_reports_workspace_release_version() {
let output = cabin()
.arg("--version")
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).expect("--version should be utf-8");
assert!(
stdout.contains(CABIN_VERSION),
"expected `cabin --version` to mention the {CABIN_VERSION} release, got: {stdout}"
);
}
#[test]
fn cabin_about_describes_c_and_cpp_not_just_cpp() {
let out = help_text(&[]);
assert!(
out.contains("for C/C++"),
"expected `--help` to describe Cabin for C/C++; got: {out}"
);
}
}
mod diagnostics {
use super::*;
#[test]
fn cli_sources_do_not_write_directly_to_stderr() {
fn collect_rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
for entry in fs::read_dir(dir).expect("read source directory") {
let entry = entry.expect("read source entry");
let path = entry.path();
if path.is_dir() {
collect_rs_files(&path, out);
} else if path.extension().and_then(|s| s.to_str()) == Some("rs") {
out.push(path);
}
}
}
let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = Vec::new();
collect_rs_files(&src_dir, &mut files);
let mut offenders = Vec::new();
for path in files {
let body = fs::read_to_string(&path).expect("read source file");
if body.contains("eprintln!(") {
offenders.push(
path.strip_prefix(env!("CARGO_MANIFEST_DIR"))
.unwrap_or(&path)
.display()
.to_string(),
);
}
}
assert!(
offenders.is_empty(),
"production CLI sources must route human output through Reporter or cabin-diagnostics, not direct eprintln!: {offenders:#?}",
);
}
fn normalize(text: &str, tmpdir: &std::path::Path) -> String {
let canonical = tmpdir
.canonicalize()
.unwrap_or_else(|_| tmpdir.to_path_buf());
let canonical_str = canonical.to_string_lossy();
let original_str = tmpdir.to_string_lossy();
let mut out = text.replace(canonical_str.as_ref(), "<TMPDIR>");
out = out.replace(original_str.as_ref(), "<TMPDIR>");
out
}
#[test]
fn missing_manifest_emits_typed_diagnostic_with_help() {
let dir = TempDir::new().unwrap();
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
let normalized = normalize(&stderr, dir.path());
assert!(
normalized.contains("cabin::workspace::manifest_not_found"),
"missing code: {normalized:?}"
);
assert!(
normalized.contains("× could not find a Cabin workspace at <TMPDIR>/cabin.toml"),
"missing primary message: {normalized:?}"
);
assert!(
normalized.contains("help: run `cabin init`"),
"missing help: {normalized:?}"
);
assert!(
!normalized.contains("os error 2"),
"raw OS error must not appear: {normalized:?}"
);
}
#[test]
fn invalid_toml_manifest_renders_source_snippet() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str("[package\nname = broken\n")
.unwrap();
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path().join("cabin.toml"))
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("cabin::manifest::parse_error"),
"missing parse_error code: {stderr}"
);
assert!(
stderr.contains("× could not parse Cabin manifest"),
"missing primary message: {stderr}"
);
assert!(stderr.contains("╭─["), "missing snippet header: {stderr}");
assert!(stderr.contains("[package"), "missing source line: {stderr}");
assert!(
stderr.contains("help: check that the manifest is valid TOML"),
"missing help: {stderr}"
);
}
#[test]
fn cabin_help_works_outside_workspace() {
let dir = TempDir::new().unwrap();
cabin()
.current_dir(dir.path())
.arg("--help")
.assert()
.success();
}
#[test]
fn cabin_subcommand_help_works_outside_workspace() {
let dir = TempDir::new().unwrap();
for sub in all_subcommand_names() {
cabin()
.current_dir(dir.path())
.args([sub.as_str(), "--help"])
.assert()
.success();
}
}
#[test]
fn manifest_path_pointing_at_directory_emits_unreadable_diagnostic() {
let dir = TempDir::new().unwrap();
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(dir.path())
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("cabin::manifest::unreadable"),
"expected manifest unreadable diagnostic code, got: {stderr}"
);
let occurrences = stderr.matches("Is a directory").count();
assert_eq!(
occurrences, 1,
"expected one `Is a directory` occurrence (no chain dup), got {occurrences}: {stderr}"
);
}
#[test]
#[cfg(unix)]
fn permission_denied_manifest_emits_unreadable_diagnostic() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let manifest = dir.path().join("cabin.toml");
assert_fs::fixture::ChildPath::new(&manifest)
.write_str(
r#"[package]
name = "x"
version = "0.1.0"
"#,
)
.unwrap();
let mut perms = std::fs::metadata(&manifest).unwrap().permissions();
perms.set_mode(0o000);
std::fs::set_permissions(&manifest, perms).unwrap();
let still_readable = std::fs::canonicalize(&manifest).is_ok();
let mut restore = std::fs::metadata(&manifest).unwrap().permissions();
restore.set_mode(0o644);
let _ = std::fs::set_permissions(&manifest, restore);
if still_readable {
eprintln!("test skipped: chmod 0 did not block read access (running as root?)");
return;
}
let mut perms = std::fs::metadata(&manifest).unwrap().permissions();
perms.set_mode(0o000);
std::fs::set_permissions(&manifest, perms).unwrap();
let assertion = cabin()
.args(["metadata", "--manifest-path"])
.arg(&manifest)
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
let mut restore = std::fs::metadata(&manifest).unwrap().permissions();
restore.set_mode(0o644);
let _ = std::fs::set_permissions(&manifest, restore);
assert!(
stderr.contains("cabin::workspace::manifest_unreadable"),
"expected manifest_unreadable code, got: {stderr}"
);
}
}
mod color_control {
use super::*;
const ESC: char = '\x1b';
fn missing_manifest_command(dir: &TempDir) -> Command {
let mut cmd = cabin();
cmd.current_dir(dir.path()).arg("metadata");
cmd
}
#[test]
fn color_value_auto_is_accepted_by_clap() {
let dir = TempDir::new().unwrap();
let assertion = missing_manifest_command(&dir)
.arg("--color")
.arg("auto")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("cabin::workspace::manifest_not_found"),
"expected workspace error reaching the diagnostic renderer, got: {stderr}"
);
}
#[test]
fn color_value_always_is_accepted_by_clap() {
let dir = TempDir::new().unwrap();
missing_manifest_command(&dir)
.arg("--color")
.arg("always")
.assert()
.failure();
}
#[test]
fn color_value_never_is_accepted_by_clap() {
let dir = TempDir::new().unwrap();
missing_manifest_command(&dir)
.arg("--color")
.arg("never")
.assert()
.failure();
}
#[test]
fn color_value_unknown_is_rejected_by_clap() {
let dir = TempDir::new().unwrap();
let assertion = missing_manifest_command(&dir)
.arg("--color")
.arg("sometimes")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("'sometimes'") || stderr.contains("\"sometimes\""),
"expected the invalid value to appear in the error, got: {stderr}"
);
assert!(
stderr.contains("auto") && stderr.contains("always") && stderr.contains("never"),
"expected the valid set to appear in the error, got: {stderr}"
);
}
#[test]
fn cabin_term_color_invalid_value_yields_documented_wording() {
let dir = TempDir::new().unwrap();
let assertion = missing_manifest_command(&dir)
.env("CABIN_TERM_COLOR", "sometimes")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains(
"invalid CABIN_TERM_COLOR value 'sometimes'; expected one of: auto, always, never"
),
"expected documented env-error wording, got: {stderr}"
);
}
#[test]
fn color_always_emits_ansi_escapes_for_workspace_diagnostic() {
let dir = TempDir::new().unwrap();
let assertion = missing_manifest_command(&dir)
.arg("--color")
.arg("always")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains(ESC),
"expected ANSI escape with --color always, got: {stderr:?}"
);
assert!(
stderr.contains("cabin::workspace::manifest_not_found"),
"expected diagnostic code, got: {stderr}"
);
assert!(
stderr.contains("could not find a Cabin workspace"),
"expected diagnostic message body, got: {stderr}"
);
}
#[test]
fn color_never_emits_no_ansi_escapes_for_workspace_diagnostic() {
let dir = TempDir::new().unwrap();
let assertion = missing_manifest_command(&dir)
.arg("--color")
.arg("never")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
!stderr.contains(ESC),
"expected no ANSI escape with --color never, got: {stderr:?}"
);
assert!(
stderr.contains("cabin::workspace::manifest_not_found"),
"expected diagnostic body intact, got: {stderr}"
);
assert!(
stderr.contains("could not find a Cabin workspace"),
"expected diagnostic message body, got: {stderr}"
);
}
#[test]
fn cli_color_always_overrides_env_never() {
let dir = TempDir::new().unwrap();
let assertion = missing_manifest_command(&dir)
.arg("--color")
.arg("always")
.env("CABIN_TERM_COLOR", "never")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains(ESC),
"CLI --color always must override env CABIN_TERM_COLOR=never, got: {stderr:?}"
);
}
#[test]
fn cli_color_never_overrides_env_always() {
let dir = TempDir::new().unwrap();
let assertion = missing_manifest_command(&dir)
.arg("--color")
.arg("never")
.env("CABIN_TERM_COLOR", "always")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
!stderr.contains(ESC),
"CLI --color never must override env CABIN_TERM_COLOR=always, got: {stderr:?}"
);
}
#[test]
fn env_always_applies_when_cli_omitted() {
let dir = TempDir::new().unwrap();
let assertion = missing_manifest_command(&dir)
.env("CABIN_TERM_COLOR", "always")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains(ESC),
"env CABIN_TERM_COLOR=always must apply when --color is omitted, got: {stderr:?}"
);
}
#[test]
fn env_never_applies_when_cli_omitted() {
let dir = TempDir::new().unwrap();
let assertion = missing_manifest_command(&dir)
.env("CABIN_TERM_COLOR", "never")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
!stderr.contains(ESC),
"env CABIN_TERM_COLOR=never must apply when --color is omitted, got: {stderr:?}"
);
}
#[test]
fn metadata_json_output_remains_uncolored_with_color_always() {
let dir = TempDir::new().unwrap();
assert_fs::fixture::ChildPath::new(dir.path().join("cabin.toml"))
.write_str(VALID_MANIFEST)
.unwrap();
assert_fs::fixture::ChildPath::new(dir.path().join("src"))
.create_dir_all()
.unwrap();
assert_fs::fixture::ChildPath::new(dir.path().join("src/main.cc"))
.write_str(HELLO_MAIN_CC)
.unwrap();
let assertion = cabin()
.current_dir(dir.path())
.args(["metadata", "--color", "always"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
assert!(
!stdout.contains(ESC),
"machine-readable JSON must not be colorised, got: {stdout:?}"
);
assert!(stdout.trim_start().starts_with('{'), "got: {stdout:?}");
}
#[test]
fn top_level_help_advertises_color_option() {
let assertion = cabin().arg("--help").assert().success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
assert!(
stdout.contains("--color"),
"top-level --help should mention --color, got: {stdout}"
);
assert!(
stdout.contains("Coloring: auto, always, never"),
"top-level --help should carry our help text, got: {stdout}"
);
assert!(
stdout.contains("auto") && stdout.contains("always") && stdout.contains("never"),
"top-level --help should list the value set, got: {stdout}"
);
}
#[test]
fn subcommand_help_inherits_global_color_option() {
let assertion = cabin().args(["build", "--help"]).assert().success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
assert!(
stdout.contains("--color"),
"subcommand --help should expose the global --color, got: {stdout}"
);
}
#[test]
fn config_term_color_always_applies_when_cli_and_env_silent() {
let dir = TempDir::new().unwrap();
let cfg_home = TempDir::new().unwrap();
cfg_home
.child("config.toml")
.write_str("[term]\ncolor = \"always\"\n")
.unwrap();
let assertion = cabin()
.current_dir(dir.path())
.arg("metadata")
.env_remove("CABIN_NO_CONFIG")
.env_remove("CABIN_TERM_COLOR")
.env("CABIN_CONFIG_HOME", cfg_home.path())
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains(ESC),
"config term.color=always should color output when CLI+env are silent, got: {stderr:?}"
);
}
#[test]
fn cli_color_overrides_config_term_color_always() {
let dir = TempDir::new().unwrap();
let cfg_home = TempDir::new().unwrap();
cfg_home
.child("config.toml")
.write_str("[term]\ncolor = \"always\"\n")
.unwrap();
let assertion = cabin()
.current_dir(dir.path())
.args(["--color", "never", "metadata"])
.env_remove("CABIN_NO_CONFIG")
.env_remove("CABIN_TERM_COLOR")
.env("CABIN_CONFIG_HOME", cfg_home.path())
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
!stderr.contains(ESC),
"--color never must override config term.color=always, got: {stderr:?}"
);
}
#[test]
fn env_term_color_overrides_config_term_color() {
let dir = TempDir::new().unwrap();
let cfg_home = TempDir::new().unwrap();
cfg_home
.child("config.toml")
.write_str("[term]\ncolor = \"always\"\n")
.unwrap();
let assertion = cabin()
.current_dir(dir.path())
.arg("metadata")
.env_remove("CABIN_NO_CONFIG")
.env("CABIN_TERM_COLOR", "never")
.env("CABIN_CONFIG_HOME", cfg_home.path())
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
!stderr.contains(ESC),
"CABIN_TERM_COLOR=never must override config term.color=always, got: {stderr:?}"
);
}
#[test]
fn metadata_json_stderr_remains_uncolored_with_color_always() {
let dir = TempDir::new().unwrap();
assert_fs::fixture::ChildPath::new(dir.path().join("cabin.toml"))
.write_str(VALID_MANIFEST)
.unwrap();
assert_fs::fixture::ChildPath::new(dir.path().join("src"))
.create_dir_all()
.unwrap();
assert_fs::fixture::ChildPath::new(dir.path().join("src/main.cc"))
.write_str(HELLO_MAIN_CC)
.unwrap();
let assertion = cabin()
.current_dir(dir.path())
.args(["metadata", "--color", "always"])
.assert()
.success();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
!stderr.contains(ESC),
"successful metadata run must leave stderr clean, got: {stderr:?}"
);
}
#[test]
fn cli_color_always_paints_help_lead_in() {
let dir = TempDir::new().unwrap();
let assertion = missing_manifest_command(&dir)
.arg("--color")
.arg("always")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
let help_idx = stderr
.find("help:")
.expect("manifest_not_found diagnostic should include a help line");
let leading = &stderr[..help_idx];
assert!(
leading.contains(ESC),
"expected ANSI sequence before `help:`, got: {stderr:?}"
);
}
}
mod fmt_command {
use super::*;
use std::path::PathBuf;
const MARKER: &str = "/* FORMATTED */";
fn cabin_with_fake_formatter() -> Command {
let mut cmd = cabin();
let path = fake_formatter_path();
cmd.env("CABIN_FMT", path);
cmd
}
fn fake_formatter_path() -> PathBuf {
let test_exe = std::env::current_exe().expect("current_exe");
let mut dir = test_exe
.parent()
.expect("test exe should live in a directory")
.to_path_buf();
if dir.file_name().and_then(|n| n.to_str()) == Some("deps") {
dir.pop();
}
let candidate = dir.join("cabin-fmt-fake-formatter");
assert!(
candidate.is_file(),
"expected fake formatter at {}; build cabin-fmt with `--features test-fake-formatter`",
candidate.display()
);
candidate
}
fn write_minimal_project(root: &Path) {
assert_fs::fixture::ChildPath::new(root.join("cabin.toml"))
.write_str(VALID_MANIFEST)
.unwrap();
assert_fs::fixture::ChildPath::new(root.join("src/main.cc"))
.write_str("int main() { return 0; }\n")
.unwrap();
}
fn read(path: &Path) -> String {
fs::read_to_string(path).unwrap()
}
#[test]
fn top_level_help_lists_fmt() {
let assertion = cabin().arg("--help").assert().success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
assert!(
stdout.contains("fmt"),
"top-level help should list fmt: {stdout}"
);
}
#[test]
fn fmt_help_documents_documented_flags() {
let assertion = cabin().args(["fmt", "--help"]).assert().success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
for snippet in ["--check", "--build-dir", "--exclude", "--no-ignore-vcs"] {
assert!(
stdout.contains(snippet),
"`cabin fmt --help` should mention {snippet}: {stdout}"
);
}
}
#[test]
fn write_mode_formats_in_place() {
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
cabin_with_fake_formatter()
.current_dir(dir.path())
.arg("fmt")
.assert()
.success()
.stdout(predicate::str::contains("Formatted 1 file"));
let body = read(&dir.path().join("src/main.cc"));
assert!(body.contains(MARKER), "expected marker, got: {body:?}");
}
#[test]
fn check_mode_passes_when_already_formatted() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml").write_str(VALID_MANIFEST).unwrap();
dir.child("src/main.cc")
.write_str("int main() { return 0; }\n/* FORMATTED */\n")
.unwrap();
cabin_with_fake_formatter()
.current_dir(dir.path())
.args(["fmt", "--check"])
.assert()
.success()
.stdout(predicate::str::contains("already up to date"));
let body = read(&dir.path().join("src/main.cc"));
assert!(body.contains(MARKER));
}
#[test]
fn check_mode_fails_when_files_would_be_reformatted() {
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
let assertion = cabin_with_fake_formatter()
.current_dir(dir.path())
.args(["fmt", "--check"])
.assert()
.failure();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("would be reformatted"),
"expected formatter diagnostic on stderr, got: {stderr}"
);
assert!(
stdout.contains("Failed") && stdout.contains("cabin fmt --check"),
"expected Cabin-owned failure banner on stdout, got: {stdout}"
);
let body = read(&dir.path().join("src/main.cc"));
assert!(!body.contains(MARKER), "check mode mutated file: {body}");
}
#[test]
fn exclude_path_skips_named_file() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml").write_str(VALID_MANIFEST).unwrap();
dir.child("src/main.cc")
.write_str("int main() {}\n")
.unwrap();
dir.child("src/generated.cc")
.write_str("int gen() {}\n")
.unwrap();
cabin_with_fake_formatter()
.current_dir(dir.path())
.args(["fmt", "--exclude", "src/generated.cc"])
.assert()
.success();
assert!(read(&dir.path().join("src/main.cc")).contains(MARKER));
assert!(!read(&dir.path().join("src/generated.cc")).contains(MARKER));
}
#[test]
fn repeated_exclude_accumulates() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml").write_str(VALID_MANIFEST).unwrap();
dir.child("src/main.cc")
.write_str("int main() {}\n")
.unwrap();
dir.child("src/a.cc").write_str("int a() {}\n").unwrap();
dir.child("src/b.cc").write_str("int b() {}\n").unwrap();
cabin_with_fake_formatter()
.current_dir(dir.path())
.args(["fmt", "--exclude", "src/a.cc", "--exclude", "src/b.cc"])
.assert()
.success();
assert!(read(&dir.path().join("src/main.cc")).contains(MARKER));
assert!(!read(&dir.path().join("src/a.cc")).contains(MARKER));
assert!(!read(&dir.path().join("src/b.cc")).contains(MARKER));
}
#[test]
fn vcs_ignored_files_are_skipped_by_default() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml").write_str(VALID_MANIFEST).unwrap();
dir.child("src/main.cc")
.write_str("int main() {}\n")
.unwrap();
dir.child("src/generated.cc")
.write_str("int gen() {}\n")
.unwrap();
dir.child(".gitignore")
.write_str("src/generated.cc\n")
.unwrap();
dir.child(".git/HEAD")
.write_str("ref: refs/heads/main\n")
.unwrap();
cabin_with_fake_formatter()
.current_dir(dir.path())
.arg("fmt")
.assert()
.success();
assert!(read(&dir.path().join("src/main.cc")).contains(MARKER));
assert!(!read(&dir.path().join("src/generated.cc")).contains(MARKER));
}
#[test]
fn no_ignore_vcs_includes_gitignored_files() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml").write_str(VALID_MANIFEST).unwrap();
dir.child("src/main.cc")
.write_str("int main() {}\n")
.unwrap();
dir.child("src/generated.cc")
.write_str("int gen() {}\n")
.unwrap();
dir.child(".gitignore")
.write_str("src/generated.cc\n")
.unwrap();
dir.child(".git/HEAD")
.write_str("ref: refs/heads/main\n")
.unwrap();
cabin_with_fake_formatter()
.current_dir(dir.path())
.args(["fmt", "--no-ignore-vcs"])
.assert()
.success();
assert!(read(&dir.path().join("src/main.cc")).contains(MARKER));
assert!(read(&dir.path().join("src/generated.cc")).contains(MARKER));
}
#[test]
fn build_directory_is_not_formatted() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml").write_str(VALID_MANIFEST).unwrap();
dir.child("src/main.cc")
.write_str("int main() {}\n")
.unwrap();
dir.child("build/dev/scratch.cc")
.write_str("int scratch() {}\n")
.unwrap();
cabin_with_fake_formatter()
.current_dir(dir.path())
.arg("fmt")
.assert()
.success();
assert!(read(&dir.path().join("src/main.cc")).contains(MARKER));
assert!(!read(&dir.path().join("build/dev/scratch.cc")).contains(MARKER));
}
#[test]
fn custom_build_directory_is_not_formatted() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml").write_str(VALID_MANIFEST).unwrap();
dir.child("src/main.cc")
.write_str("int main() {}\n")
.unwrap();
dir.child("out/dev/scratch.cc")
.write_str("int s() {}\n")
.unwrap();
cabin_with_fake_formatter()
.current_dir(dir.path())
.args(["fmt", "--build-dir", "out"])
.assert()
.success();
assert!(read(&dir.path().join("src/main.cc")).contains(MARKER));
assert!(!read(&dir.path().join("out/dev/scratch.cc")).contains(MARKER));
}
#[test]
fn vendor_and_cache_directories_are_not_formatted() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml").write_str(VALID_MANIFEST).unwrap();
dir.child("src/main.cc")
.write_str("int main() {}\n")
.unwrap();
dir.child("target/leftover.cc")
.write_str("int t() {}\n")
.unwrap();
dir.child("dist/staging.cc")
.write_str("int d() {}\n")
.unwrap();
dir.child("node_modules/dep/main.cc")
.write_str("int dep() {}\n")
.unwrap();
cabin_with_fake_formatter()
.current_dir(dir.path())
.arg("fmt")
.assert()
.success();
assert!(read(&dir.path().join("src/main.cc")).contains(MARKER));
for skipped in [
"target/leftover.cc",
"dist/staging.cc",
"node_modules/dep/main.cc",
] {
assert!(
!read(&dir.path().join(skipped)).contains(MARKER),
"{skipped} was unexpectedly formatted"
);
}
}
#[test]
fn missing_formatter_produces_actionable_error() {
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
let assertion = cabin()
.current_dir(dir.path())
.env("CABIN_FMT", "/no-such/clang-format-binary")
.arg("fmt")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("/no-such/clang-format-binary"),
"diagnostic should name the missing executable: {stderr}"
);
assert!(
stderr.contains("install `clang-format`"),
"diagnostic should include the install hint: {stderr}"
);
assert!(
stderr.contains("CABIN_FMT"),
"diagnostic should reference the override env var: {stderr}"
);
}
#[test]
fn cabin_fmt_env_override_routes_through_named_binary() {
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
cabin()
.current_dir(dir.path())
.env("CABIN_FMT", fake_formatter_path())
.arg("fmt")
.assert()
.success();
assert!(read(&dir.path().join("src/main.cc")).contains(MARKER));
}
#[test]
fn quiet_mode_suppresses_normal_status_lines() {
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
let assertion = cabin_with_fake_formatter()
.current_dir(dir.path())
.args(["fmt", "--quiet"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stdout.is_empty() && stderr.is_empty(),
"quiet fmt run must produce no output, got stdout={stdout:?} stderr={stderr:?}"
);
}
#[test]
fn verbose_mode_lists_selected_package_and_file_count() {
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
let assertion = cabin_with_fake_formatter()
.current_dir(dir.path())
.args(["fmt", "--verbose"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
assert!(
stdout.contains("formatting 1 file"),
"verbose fmt must report file count: {stdout}"
);
assert!(
stdout.contains("hello"),
"verbose fmt must name the selected package: {stdout}"
);
}
#[test]
fn very_verbose_lists_command_lines() {
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
let assertion = cabin_with_fake_formatter()
.current_dir(dir.path())
.args(["fmt", "-vv"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
assert!(
stdout.contains("--style=file"),
"very-verbose fmt must echo the formatter command: {stdout}"
);
assert!(
stdout.contains("-i"),
"very-verbose fmt must echo the write-mode flag: {stdout}"
);
}
#[test]
fn nested_workspace_member_is_not_walked_from_outer_root() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["outer", "outer/nested"]
"#,
)
.unwrap();
dir.child("outer/cabin.toml")
.write_str(
r#"[package]
name = "outer"
version = "0.1.0"
"#,
)
.unwrap();
dir.child("outer/src/main.cc")
.write_str("int outer() {}\n")
.unwrap();
dir.child("outer/nested/cabin.toml")
.write_str(
r#"[package]
name = "nested"
version = "0.1.0"
"#,
)
.unwrap();
dir.child("outer/nested/src/main.cc")
.write_str("int nested() {}\n")
.unwrap();
cabin_with_fake_formatter()
.current_dir(dir.path())
.args(["fmt", "-p", "outer"])
.assert()
.success();
assert!(read(&dir.path().join("outer/src/main.cc")).contains(MARKER));
assert!(!read(&dir.path().join("outer/nested/src/main.cc")).contains(MARKER));
}
}
mod jobs_parallelism {
use super::*;
use std::path::PathBuf;
const VALID_C_MANIFEST: &str = r#"[package]
name = "hello"
version = "0.1.0"
[target.hello]
type = "executable"
sources = ["src/main.cc"]
"#;
fn fake_ninja_path() -> PathBuf {
let test_exe = std::env::current_exe().expect("current_exe");
let mut dir = test_exe
.parent()
.expect("test exe should live in a directory")
.to_path_buf();
if dir.file_name().and_then(|n| n.to_str()) == Some("deps") {
dir.pop();
}
let candidate = dir.join("cabin-ninja-fake-ninja");
assert!(
candidate.is_file(),
"expected fake ninja at {}; build cabin-ninja with `--features test-fake-ninja`",
candidate.display()
);
candidate
}
fn cabin_with_fake_ninja(record: &Path) -> Command {
let mut cmd = cabin();
cmd.env("NINJA", fake_ninja_path())
.env("CABIN_FAKE_NINJA_RECORD", record);
cmd
}
fn read_ninja_argvs(record: &Path) -> Vec<Vec<String>> {
let body = fs::read_to_string(record).unwrap_or_default();
body.lines()
.filter(|line| !line.is_empty())
.map(|line| {
line.split('\u{001f}')
.map(std::borrow::ToOwned::to_owned)
.collect::<Vec<_>>()
})
.collect()
}
fn write_minimal_project(root: &Path) {
assert_fs::fixture::ChildPath::new(root.join("cabin.toml"))
.write_str(VALID_C_MANIFEST)
.unwrap();
assert_fs::fixture::ChildPath::new(root.join("src/main.cc"))
.write_str(HELLO_MAIN_CC)
.unwrap();
}
#[test]
fn build_help_documents_jobs() {
let assertion = cabin().args(["build", "--help"]).assert().success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
assert!(
stdout.contains("-j, --jobs <N>"),
"build help should advertise -j/--jobs:\n{stdout}"
);
}
#[test]
fn run_help_documents_jobs() {
let assertion = cabin().args(["run", "--help"]).assert().success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
assert!(
stdout.contains("-j, --jobs <N>"),
"run help should advertise -j/--jobs:\n{stdout}"
);
}
#[test]
fn test_help_does_not_document_jobs() {
let assertion = cabin().args(["test", "--help"]).assert().success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
assert!(
!stdout.contains("-j, --jobs <N>"),
"test help must not advertise -j/--jobs:\n{stdout}"
);
}
#[test]
fn jobs_zero_is_rejected_at_cli() {
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
let assertion = cabin()
.current_dir(dir.path())
.args(["build", "--jobs", "0"])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("positive integer"),
"diagnostic should mention 'positive integer':\n{stderr}"
);
assert!(
stderr.contains('0'),
"diagnostic should echo the offending value:\n{stderr}"
);
}
#[test]
fn jobs_non_numeric_is_rejected_at_cli() {
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
let assertion = cabin()
.current_dir(dir.path())
.args(["build", "--jobs", "many"])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("many"),
"diagnostic should echo the offending value:\n{stderr}"
);
}
#[test]
fn build_with_jobs_forwards_dash_j_to_ninja() {
let dir = TempDir::new().unwrap();
let record = dir.path().join("ninja.log");
write_minimal_project(dir.path());
cabin_with_fake_ninja(&record)
.current_dir(dir.path())
.args(["build", "--jobs", "1"])
.assert()
.success();
let invocations = read_ninja_argvs(&record);
assert_eq!(invocations.len(), 1, "expected one ninja invocation");
assert_eq!(invocations[0][0], "-j1");
}
#[test]
fn build_with_short_j_forwards_to_ninja() {
let dir = TempDir::new().unwrap();
let record = dir.path().join("ninja.log");
write_minimal_project(dir.path());
cabin_with_fake_ninja(&record)
.current_dir(dir.path())
.args(["build", "-j", "2"])
.assert()
.success();
let invocations = read_ninja_argvs(&record);
assert_eq!(invocations.len(), 1);
assert_eq!(invocations[0][0], "-j2");
}
#[test]
fn build_without_jobs_does_not_pass_dash_j() {
let dir = TempDir::new().unwrap();
let record = dir.path().join("ninja.log");
write_minimal_project(dir.path());
cabin_with_fake_ninja(&record)
.current_dir(dir.path())
.arg("build")
.assert()
.success();
let invocations = read_ninja_argvs(&record);
assert_eq!(invocations.len(), 1);
for arg in &invocations[0] {
assert!(
!arg.starts_with("-j"),
"expected no -j argument when --jobs is omitted, got: {arg}"
);
}
}
#[test]
fn test_rejects_jobs_flag() {
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
let assertion = cabin()
.current_dir(dir.path())
.args(["test", "--jobs", "2"])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr);
assert!(
stderr.contains("unexpected argument") && stderr.contains("--jobs"),
"expected `unexpected argument '--jobs'` error, got: {stderr}"
);
}
#[test]
fn cabin_build_jobs_env_var_is_honored() {
let dir = TempDir::new().unwrap();
let record = dir.path().join("ninja.log");
write_minimal_project(dir.path());
cabin_with_fake_ninja(&record)
.current_dir(dir.path())
.env("CABIN_BUILD_JOBS", "3")
.arg("build")
.assert()
.success();
let invocations = read_ninja_argvs(&record);
assert_eq!(invocations.len(), 1);
assert_eq!(invocations[0][0], "-j3");
}
#[test]
fn cli_jobs_overrides_env() {
let dir = TempDir::new().unwrap();
let record = dir.path().join("ninja.log");
write_minimal_project(dir.path());
cabin_with_fake_ninja(&record)
.current_dir(dir.path())
.env("CABIN_BUILD_JOBS", "8")
.args(["build", "--jobs", "2"])
.assert()
.success();
let invocations = read_ninja_argvs(&record);
assert_eq!(invocations[0][0], "-j2");
}
#[test]
fn invalid_env_value_produces_actionable_error() {
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
let assertion = cabin()
.current_dir(dir.path())
.env("NINJA", fake_ninja_path())
.env("CABIN_BUILD_JOBS", "many")
.arg("build")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("CABIN_BUILD_JOBS"),
"diagnostic should name the env var:\n{stderr}"
);
assert!(
stderr.contains("\"many\""),
"diagnostic should echo the offending value:\n{stderr}"
);
}
#[test]
fn env_zero_is_rejected() {
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
let assertion = cabin()
.current_dir(dir.path())
.env("NINJA", fake_ninja_path())
.env("CABIN_BUILD_JOBS", "0")
.arg("build")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("CABIN_BUILD_JOBS"),
"diagnostic should name the env var:\n{stderr}"
);
assert!(
stderr.contains("positive integer"),
"diagnostic should mention 'positive integer':\n{stderr}"
);
}
#[test]
fn build_jobs_config_setting_is_honored() {
let dir = TempDir::new().unwrap();
let record = dir.path().join("ninja.log");
write_minimal_project(dir.path());
let config_dir = dir.path().join(".cabin");
assert_fs::fixture::ChildPath::new(config_dir.join("config.toml"))
.write_str("[build]\njobs = 5\n")
.unwrap();
cabin_with_fake_ninja(&record)
.current_dir(dir.path())
.env_remove("CABIN_NO_CONFIG")
.env("CABIN_CONFIG", config_dir.join("config.toml"))
.arg("build")
.assert()
.success();
let invocations = read_ninja_argvs(&record);
assert_eq!(invocations.len(), 1);
assert_eq!(invocations[0][0], "-j5");
}
#[test]
fn env_overrides_config() {
let dir = TempDir::new().unwrap();
let record = dir.path().join("ninja.log");
write_minimal_project(dir.path());
let config_dir = dir.path().join(".cabin");
assert_fs::fixture::ChildPath::new(config_dir.join("config.toml"))
.write_str("[build]\njobs = 5\n")
.unwrap();
cabin_with_fake_ninja(&record)
.current_dir(dir.path())
.env_remove("CABIN_NO_CONFIG")
.env("CABIN_CONFIG", config_dir.join("config.toml"))
.env("CABIN_BUILD_JOBS", "9")
.arg("build")
.assert()
.success();
let invocations = read_ninja_argvs(&record);
assert_eq!(invocations[0][0], "-j9");
}
#[test]
fn config_zero_is_rejected_with_actionable_error() {
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
let config_dir = dir.path().join(".cabin");
assert_fs::fixture::ChildPath::new(config_dir.join("config.toml"))
.write_str("[build]\njobs = 0\n")
.unwrap();
let assertion = cabin()
.current_dir(dir.path())
.env("NINJA", fake_ninja_path())
.env_remove("CABIN_NO_CONFIG")
.env("CABIN_CONFIG", config_dir.join("config.toml"))
.arg("build")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("cabin::config::invalid_build_jobs"),
"config-layer failures should render with a stable diagnostic code:\n{stderr}"
);
assert!(
stderr.contains("build.jobs"),
"diagnostic should name the config key:\n{stderr}"
);
assert!(
stderr.contains("positive integer"),
"diagnostic should mention 'positive integer':\n{stderr}"
);
}
#[test]
fn run_jobs_before_doubledash_is_consumed_by_cabin() {
let dir = TempDir::new().unwrap();
let record = dir.path().join("ninja.log");
write_minimal_project(dir.path());
let _ = cabin_with_fake_ninja(&record)
.current_dir(dir.path())
.args(["run", "--jobs", "4", "--", "--help"])
.assert()
.failure();
let invocations = read_ninja_argvs(&record);
assert_eq!(invocations.len(), 1);
assert_eq!(invocations[0][0], "-j4");
}
#[test]
fn run_jobs_after_doubledash_does_not_reach_ninja() {
let dir = TempDir::new().unwrap();
let record = dir.path().join("ninja.log");
write_minimal_project(dir.path());
let _ = cabin_with_fake_ninja(&record)
.current_dir(dir.path())
.args(["run", "--", "--jobs", "4"])
.assert()
.failure();
let invocations = read_ninja_argvs(&record);
assert_eq!(invocations.len(), 1);
for arg in &invocations[0] {
assert!(
!arg.starts_with("-j"),
"ninja must not receive jobs when --jobs is after `--`: {arg}"
);
}
}
#[test]
fn metadata_output_is_byte_identical_with_and_without_jobs_env() {
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
let baseline = cabin()
.current_dir(dir.path())
.args(["metadata"])
.assert()
.success()
.get_output()
.stdout
.clone();
let with_jobs = cabin()
.current_dir(dir.path())
.env("CABIN_BUILD_JOBS", "4")
.args(["metadata"])
.assert()
.success()
.get_output()
.stdout
.clone();
assert_eq!(
baseline, with_jobs,
"CABIN_BUILD_JOBS must not change `cabin metadata` JSON",
);
}
}
mod tidy_command {
use super::*;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
const FAIL_MARKER: &str = "// CABIN-TIDY-FAIL";
fn tidy_record_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn cabin_with_fake_tidy() -> Command {
let mut cmd = cabin();
cmd.env("CABIN_TIDY", fake_tidy_path());
cmd.env("CXX", "/bin/sh");
cmd.env("CC", "/bin/sh");
cmd.env("AR", "/bin/sh");
cmd
}
fn fake_tidy_path() -> PathBuf {
let test_exe = std::env::current_exe().expect("current_exe");
let mut dir = test_exe
.parent()
.expect("test exe should live in a directory")
.to_path_buf();
if dir.file_name().and_then(|n| n.to_str()) == Some("deps") {
dir.pop();
}
let candidate = dir.join("cabin-tidy-fake-tidy");
assert!(
candidate.is_file(),
"expected fake tidy at {}; build cabin-tidy with `--features test-fake-tidy`",
candidate.display()
);
candidate
}
fn write_minimal_project(root: &Path) {
assert_fs::fixture::ChildPath::new(root.join("cabin.toml"))
.write_str(VALID_MANIFEST)
.unwrap();
assert_fs::fixture::ChildPath::new(root.join("src/main.cc"))
.write_str("int main() { return 0; }\n")
.unwrap();
}
fn read_record(path: &Path) -> Vec<String> {
std::fs::read_to_string(path)
.unwrap_or_default()
.lines()
.map(str::to_owned)
.collect()
}
#[test]
fn top_level_help_lists_tidy() {
let assertion = cabin().arg("--help").assert().success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
assert!(
stdout.contains("Run clang-tidy"),
"top-level help should describe the tidy subcommand:\n{stdout}"
);
}
#[test]
fn tidy_help_documents_documented_flags() {
let assertion = cabin().args(["tidy", "--help"]).assert().success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
for snippet in [
"--fix",
"--jobs",
"--build-dir",
"--exclude",
"--no-ignore-vcs",
] {
assert!(
stdout.contains(snippet),
"`cabin tidy --help` should mention {snippet}: {stdout}"
);
}
}
#[test]
fn clean_project_tidies_successfully() {
let _guard = tidy_record_lock();
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
cabin_with_fake_tidy()
.current_dir(dir.path())
.arg("tidy")
.assert()
.success()
.stdout(predicate::str::contains("Checked 1 file"));
}
#[test]
fn tidy_analyses_test_and_example_sources() {
let _guard = tidy_record_lock();
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "demo"
version = "0.1.0"
[target.demo]
type = "library"
sources = ["src/lib.cc"]
[target.demo_test]
type = "test"
sources = ["tests/lib_test.cc"]
deps = ["demo"]
[target.hello_example]
type = "example"
sources = ["examples/hello.cc"]
deps = ["demo"]
"#,
)
.unwrap();
dir.child("src/lib.cc")
.write_str("int demo() { return 1; }\n")
.unwrap();
dir.child("tests/lib_test.cc")
.write_str("int main() { return 0; }\n")
.unwrap();
dir.child("examples/hello.cc")
.write_str("int main() { return 0; }\n")
.unwrap();
cabin_with_fake_tidy()
.current_dir(dir.path())
.arg("tidy")
.assert()
.success()
.stdout(predicate::str::contains("Checked 3 files"));
}
#[test]
fn compile_database_is_generated_at_profile_root() {
let _guard = tidy_record_lock();
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
cabin_with_fake_tidy()
.current_dir(dir.path())
.arg("tidy")
.assert()
.success();
let cdb = dir.path().join("build/dev/compile_commands.json");
assert!(
cdb.is_file(),
"compile_commands.json must land at build/dev/compile_commands.json: missing {}",
cdb.display()
);
let body = fs::read_to_string(&cdb).unwrap();
assert!(
body.contains("main.cc"),
"compile database should mention the source file: {body}"
);
}
#[test]
fn dash_p_is_the_compile_database_directory() {
let _guard = tidy_record_lock();
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
let record = dir.path().join("argv.log");
cabin_with_fake_tidy()
.current_dir(dir.path())
.env("CABIN_FAKE_TIDY_RECORD", &record)
.arg("tidy")
.assert()
.success();
let lines = read_record(&record);
assert_eq!(lines.len(), 1, "expected one tidy spawn, got {lines:?}");
let mut parts = lines[0].split('\t');
let argv = parts.next().unwrap();
let _quiet = parts.next().unwrap();
let _fix = parts.next().unwrap();
let cdb = parts.next().unwrap();
assert!(argv.contains("-p"), "argv should contain -p: {argv}");
assert!(
cdb.ends_with("build/dev"),
"compile DB dir should end at <build>/dev: {cdb}"
);
}
#[test]
fn custom_build_dir_is_the_compile_database_directory() {
let _guard = tidy_record_lock();
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
let record = dir.path().join("argv.log");
cabin_with_fake_tidy()
.current_dir(dir.path())
.env("CABIN_FAKE_TIDY_RECORD", &record)
.args(["tidy", "--build-dir", "out"])
.assert()
.success();
assert!(dir.path().join("out/dev/compile_commands.json").is_file());
let lines = read_record(&record);
assert_eq!(lines.len(), 1, "expected one tidy spawn, got {lines:?}");
let cdb = lines[0].split('\t').nth(3).unwrap();
assert!(
cdb.ends_with("out/dev"),
"compile DB dir should end at <custom-build>/dev: {cdb}"
);
}
#[test]
fn fix_flag_is_passed_to_tidy_driver() {
let _guard = tidy_record_lock();
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
let record = dir.path().join("argv.log");
cabin_with_fake_tidy()
.current_dir(dir.path())
.env("CABIN_FAKE_TIDY_RECORD", &record)
.args(["tidy", "--fix"])
.assert()
.success();
let lines = read_record(&record);
let mut parts = lines[0].split('\t');
let argv = parts.next().unwrap();
let _quiet = parts.next().unwrap();
let fix = parts.next().unwrap();
assert!(argv.contains("-fix"), "argv should contain -fix: {argv}");
assert_eq!(fix, "true");
}
#[test]
fn fix_mode_clamps_jobs_to_one() {
let _guard = tidy_record_lock();
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
let record = dir.path().join("argv.log");
let assertion = cabin_with_fake_tidy()
.current_dir(dir.path())
.env("CABIN_FAKE_TIDY_RECORD", &record)
.args(["tidy", "--fix", "--jobs", "4", "--verbose"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
let lines = read_record(&record);
let mut parts = lines[0].split('\t');
let argv = parts.next().unwrap();
let _quiet = parts.next().unwrap();
let _fix = parts.next().unwrap();
let _cdb = parts.next().unwrap();
let jobs = parts.next().unwrap();
assert_eq!(jobs, "1", "fix mode must clamp jobs to 1: argv={argv}");
assert!(
stdout.contains("--fix forces tidy parallelism to 1"),
"verbose mode must surface the clamp: {stdout}"
);
}
#[test]
fn jobs_flag_is_passed_to_tidy_driver_in_check_mode() {
let _guard = tidy_record_lock();
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
let record = dir.path().join("argv.log");
cabin_with_fake_tidy()
.current_dir(dir.path())
.env("CABIN_FAKE_TIDY_RECORD", &record)
.args(["tidy", "--jobs", "2"])
.assert()
.success();
let lines = read_record(&record);
let mut parts = lines[0].split('\t');
let argv = parts.next().unwrap();
let _quiet = parts.next().unwrap();
let _fix = parts.next().unwrap();
let _cdb = parts.next().unwrap();
let jobs = parts.next().unwrap();
assert!(argv.contains("-j 2"), "argv should contain -j 2: {argv}");
assert_eq!(jobs, "2");
}
#[test]
fn invalid_jobs_value_is_rejected_at_parse_time() {
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
let assertion = cabin_with_fake_tidy()
.current_dir(dir.path())
.args(["tidy", "--jobs", "0"])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("got 0") || stderr.contains("expected a positive integer"),
"diagnostic should call out the rejected value: {stderr}"
);
}
#[test]
fn no_files_succeeds_with_message() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str("[package]\nname = \"empty\"\nversion = \"0.1.0\"\n")
.unwrap();
dir.child("docs/README.md")
.write_str("no sources here\n")
.unwrap();
cabin_with_fake_tidy()
.current_dir(dir.path())
.arg("tidy")
.assert()
.success()
.stdout(predicate::str::contains("no C/C++ source files"));
}
#[test]
fn fail_marker_causes_non_zero_exit_and_preserves_diagnostic() {
let _guard = tidy_record_lock();
let dir = TempDir::new().unwrap();
dir.child("cabin.toml").write_str(VALID_MANIFEST).unwrap();
dir.child("src/main.cc")
.write_str("// CABIN-TIDY-FAIL\nint main() {}\n")
.unwrap();
let assertion = cabin_with_fake_tidy()
.current_dir(dir.path())
.arg("tidy")
.assert()
.failure();
let _ = FAIL_MARKER;
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("fake-clang-tidy diagnostic"),
"clang-tidy diagnostic must reach stderr unchanged, got: {stderr}"
);
}
#[test]
fn exclude_path_skips_named_file() {
let _guard = tidy_record_lock();
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str("[package]\nname = \"hello\"\nversion = \"0.1.0\"\n\n[target.hello]\ntype = \"executable\"\nsources = [\"src/main.cc\", \"src/extra.cc\"]\n")
.unwrap();
dir.child("src/main.cc")
.write_str("int main() {}\n")
.unwrap();
dir.child("src/extra.cc")
.write_str("int extra() { return 0; }\n")
.unwrap();
let record = dir.path().join("argv.log");
cabin_with_fake_tidy()
.current_dir(dir.path())
.env("CABIN_FAKE_TIDY_RECORD", &record)
.args(["tidy", "--exclude", "src/extra.cc"])
.assert()
.success();
let body = std::fs::read_to_string(&record).unwrap();
assert!(body.contains("src/main.cc"));
assert!(
!body.contains("src/extra.cc"),
"excluded file leaked into argv: {body}"
);
}
#[test]
fn repeated_exclude_accumulates() {
let _guard = tidy_record_lock();
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str("[package]\nname = \"hello\"\nversion = \"0.1.0\"\n\n[target.hello]\ntype = \"executable\"\nsources = [\"src/main.cc\", \"src/a.cc\", \"src/b.cc\"]\n")
.unwrap();
dir.child("src/main.cc")
.write_str("int main() {}\n")
.unwrap();
dir.child("src/a.cc")
.write_str("int a() { return 0; }\n")
.unwrap();
dir.child("src/b.cc")
.write_str("int b() { return 0; }\n")
.unwrap();
let record = dir.path().join("argv.log");
cabin_with_fake_tidy()
.current_dir(dir.path())
.env("CABIN_FAKE_TIDY_RECORD", &record)
.args(["tidy", "--exclude", "src/a.cc", "--exclude", "src/b.cc"])
.assert()
.success();
let body = std::fs::read_to_string(&record).unwrap();
assert!(body.contains("src/main.cc"));
assert!(!body.contains("src/a.cc"));
assert!(!body.contains("src/b.cc"));
}
#[test]
fn vcs_ignored_files_are_skipped_by_default() {
let _guard = tidy_record_lock();
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str("[package]\nname = \"hello\"\nversion = \"0.1.0\"\n\n[target.hello]\ntype = \"executable\"\nsources = [\"src/main.cc\", \"src/generated.cc\"]\n")
.unwrap();
dir.child("src/main.cc")
.write_str("int main() {}\n")
.unwrap();
dir.child("src/generated.cc")
.write_str("int gen() { return 0; }\n")
.unwrap();
dir.child(".gitignore")
.write_str("src/generated.cc\n")
.unwrap();
dir.child(".git/HEAD")
.write_str("ref: refs/heads/main\n")
.unwrap();
let record = dir.path().join("argv.log");
cabin_with_fake_tidy()
.current_dir(dir.path())
.env("CABIN_FAKE_TIDY_RECORD", &record)
.arg("tidy")
.assert()
.success();
let body = std::fs::read_to_string(&record).unwrap();
assert!(body.contains("src/main.cc"));
assert!(!body.contains("src/generated.cc"));
}
#[test]
fn no_ignore_vcs_includes_gitignored_files() {
let _guard = tidy_record_lock();
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str("[package]\nname = \"hello\"\nversion = \"0.1.0\"\n\n[target.hello]\ntype = \"executable\"\nsources = [\"src/main.cc\", \"src/generated.cc\"]\n")
.unwrap();
dir.child("src/main.cc")
.write_str("int main() {}\n")
.unwrap();
dir.child("src/generated.cc")
.write_str("int gen() { return 0; }\n")
.unwrap();
dir.child(".gitignore")
.write_str("src/generated.cc\n")
.unwrap();
dir.child(".git/HEAD")
.write_str("ref: refs/heads/main\n")
.unwrap();
let record = dir.path().join("argv.log");
cabin_with_fake_tidy()
.current_dir(dir.path())
.env("CABIN_FAKE_TIDY_RECORD", &record)
.args(["tidy", "--no-ignore-vcs"])
.assert()
.success();
let body = std::fs::read_to_string(&record).unwrap();
assert!(body.contains("src/main.cc"));
assert!(body.contains("src/generated.cc"));
}
#[test]
fn build_and_cache_directories_are_not_checked() {
let _guard = tidy_record_lock();
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
dir.child("build/dev/scratch.cc")
.write_str("int s() {}\n")
.unwrap();
dir.child("target/leftover.cc")
.write_str("int t() {}\n")
.unwrap();
dir.child("dist/staging.cc")
.write_str("int d() {}\n")
.unwrap();
dir.child("node_modules/dep/main.cc")
.write_str("int dep() {}\n")
.unwrap();
let record = dir.path().join("argv.log");
cabin_with_fake_tidy()
.current_dir(dir.path())
.env("CABIN_FAKE_TIDY_RECORD", &record)
.arg("tidy")
.assert()
.success();
let body = std::fs::read_to_string(&record).unwrap();
for skipped in [
"build/dev/scratch.cc",
"target/leftover.cc",
"dist/staging.cc",
"node_modules/dep/main.cc",
] {
assert!(
!body.contains(skipped),
"{skipped} should not have been checked: {body}"
);
}
}
#[test]
fn custom_build_directory_is_not_checked() {
let _guard = tidy_record_lock();
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
dir.child("out/dev/scratch.cc")
.write_str("int s() {}\n")
.unwrap();
let record = dir.path().join("argv.log");
cabin_with_fake_tidy()
.current_dir(dir.path())
.env("CABIN_FAKE_TIDY_RECORD", &record)
.args(["tidy", "--build-dir", "out"])
.assert()
.success();
let body = std::fs::read_to_string(&record).unwrap();
assert!(body.contains("src/main.cc"));
assert!(
!body.contains("out/dev/scratch.cc"),
"custom build dir should not have been checked: {body}"
);
}
#[test]
fn missing_tidy_produces_actionable_error() {
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
let assertion = cabin()
.current_dir(dir.path())
.env("CABIN_TIDY", "/no-such/run-clang-tidy-binary")
.env("CXX", "/bin/sh")
.env("CC", "/bin/sh")
.env("AR", "/bin/sh")
.arg("tidy")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("/no-such/run-clang-tidy-binary"),
"diagnostic should name the missing executable: {stderr}"
);
assert!(
stderr.contains("install `clang-tidy`"),
"diagnostic should include the install hint: {stderr}"
);
assert!(
stderr.contains("CABIN_TIDY"),
"diagnostic should reference the override env var: {stderr}"
);
}
#[test]
fn cabin_tidy_env_override_routes_through_named_binary() {
let _guard = tidy_record_lock();
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
let record = dir.path().join("argv.log");
cabin()
.current_dir(dir.path())
.env("CABIN_TIDY", fake_tidy_path())
.env("CABIN_FAKE_TIDY_RECORD", &record)
.env("CXX", "/bin/sh")
.env("CC", "/bin/sh")
.env("AR", "/bin/sh")
.arg("tidy")
.assert()
.success();
let body = std::fs::read_to_string(&record).unwrap();
assert!(body.contains("src/main.cc"));
}
#[test]
fn clang_tidy_config_survives_unmodified_after_tidy() {
let _guard = tidy_record_lock();
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
let cfg_path = dir.path().join(".clang-tidy");
let cfg_body = "Checks: 'modernize-*'\n";
assert_fs::fixture::ChildPath::new(&cfg_path)
.write_str(cfg_body)
.unwrap();
cabin_with_fake_tidy()
.current_dir(dir.path())
.arg("tidy")
.assert()
.success();
assert!(cfg_path.is_file(), "Cabin must not delete .clang-tidy");
let after = fs::read_to_string(&cfg_path).unwrap();
assert_eq!(after, cfg_body, "Cabin must not modify .clang-tidy");
}
#[test]
fn quiet_mode_suppresses_cabin_status_but_not_tidy_output() {
let _guard = tidy_record_lock();
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
let assertion = cabin_with_fake_tidy()
.current_dir(dir.path())
.args(["tidy", "--quiet"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
assert!(
!stdout.contains("cabin: running clang-tidy"),
"quiet mode must suppress Cabin status: {stdout}"
);
assert!(
!stdout.contains("cabin: checked"),
"quiet mode must suppress Cabin summary: {stdout}"
);
}
#[test]
fn verbose_mode_lists_selected_package_and_file_count() {
let _guard = tidy_record_lock();
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
let assertion = cabin_with_fake_tidy()
.current_dir(dir.path())
.args(["tidy", "--verbose"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
assert!(
stdout.contains("tidying 1 file"),
"verbose tidy should report file count: {stdout}"
);
assert!(
stdout.contains("hello"),
"verbose tidy should name the selected package: {stdout}"
);
assert!(
stdout.contains("compile database ="),
"verbose tidy should show compile DB path: {stdout}"
);
}
#[test]
fn very_verbose_lists_command_line() {
let _guard = tidy_record_lock();
let dir = TempDir::new().unwrap();
write_minimal_project(dir.path());
let assertion = cabin_with_fake_tidy()
.current_dir(dir.path())
.args(["tidy", "-vv"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
assert!(
stdout.contains("cabin-tidy-fake-tidy"),
"very-verbose tidy should echo the executable: {stdout}"
);
assert!(
stdout.contains("-p "),
"very-verbose tidy should echo the -p flag: {stdout}"
);
}
#[test]
fn nested_workspace_member_is_not_walked_from_outer_root() {
let _guard = tidy_record_lock();
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["outer", "outer/nested"]
"#,
)
.unwrap();
dir.child("outer/cabin.toml")
.write_str(
r#"[package]
name = "outer"
version = "0.1.0"
[target.outer]
type = "executable"
sources = ["src/main.cc"]
"#,
)
.unwrap();
dir.child("outer/src/main.cc")
.write_str("int main() { return 0; }\n")
.unwrap();
dir.child("outer/nested/cabin.toml")
.write_str(
r#"[package]
name = "nested"
version = "0.1.0"
[target.nested]
type = "executable"
sources = ["src/main.cc"]
"#,
)
.unwrap();
dir.child("outer/nested/src/main.cc")
.write_str("int main() { return 0; }\n")
.unwrap();
let record = dir.path().join("argv.log");
cabin_with_fake_tidy()
.current_dir(dir.path())
.env("CABIN_FAKE_TIDY_RECORD", &record)
.args(["tidy", "-p", "outer"])
.assert()
.success();
let body = std::fs::read_to_string(&record).unwrap();
assert!(body.contains("outer/src/main.cc"));
assert!(
!body.contains("outer/nested/src/main.cc"),
"nested member's sources leaked into outer's tidy: {body}"
);
}
#[test]
fn unrelated_versioned_dependency_does_not_block_selected_package() {
let _guard = tidy_record_lock();
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["clean", "registry-user"]
"#,
)
.unwrap();
dir.child("clean/cabin.toml")
.write_str(
r#"[package]
name = "clean"
version = "0.1.0"
[target.clean]
type = "executable"
sources = ["src/main.cc"]
"#,
)
.unwrap();
dir.child("clean/src/main.cc")
.write_str("int main() { return 0; }\n")
.unwrap();
dir.child("registry-user/cabin.toml")
.write_str(
r#"[package]
name = "registry-user"
version = "0.1.0"
[dependencies]
fmt = "1.0"
"#,
)
.unwrap();
let record = dir.path().join("argv.log");
cabin_with_fake_tidy()
.current_dir(dir.path())
.env("CABIN_FAKE_TIDY_RECORD", &record)
.args(["tidy", "-p", "clean"])
.assert()
.success();
let body = std::fs::read_to_string(&record).unwrap();
assert!(body.contains("clean/src/main.cc"));
assert!(!body.contains("registry-user"));
}
#[test]
fn versioned_dependency_produces_actionable_error() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str("[package]\nname = \"hello\"\nversion = \"0.1.0\"\n\n[target.hello]\ntype = \"executable\"\nsources = [\"src/main.cc\"]\n\n[dependencies]\nfmt = \"1.0\"\n")
.unwrap();
dir.child("src/main.cc")
.write_str("int main() {}\n")
.unwrap();
let assertion = cabin_with_fake_tidy()
.current_dir(dir.path())
.arg("tidy")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("versioned registry dependencies"),
"diagnostic should name the offending situation: {stderr}"
);
assert!(
stderr.contains("does not run the artifact pipeline"),
"diagnostic should explain why tidy refuses: {stderr}"
);
assert!(
!stderr.contains("cabin build"),
"diagnostic must not suggest a build/fetch step that tidy cannot use: {stderr}"
);
}
}
mod system_deps_pkg_config {
use super::*;
use std::path::PathBuf;
fn fake_pkg_config_path() -> PathBuf {
let test_exe = std::env::current_exe().expect("current_exe");
let mut dir = test_exe
.parent()
.expect("test exe should live in a directory")
.to_path_buf();
if dir.file_name().and_then(|n| n.to_str()) == Some("deps") {
dir.pop();
}
let candidate = dir.join("cabin-system-deps-fake-pkg-config");
assert!(
candidate.is_file(),
"expected fake pkg-config at {}; build cabin-system-deps with `--features test-fake-pkg-config`",
candidate.display()
);
candidate
}
pub(super) struct Fixtures {
dir: TempDir,
}
impl Fixtures {
pub(super) fn new() -> Self {
Self {
dir: TempDir::new().expect("tempdir"),
}
}
pub(super) fn write(&self, name: &str, body: &str) {
assert_fs::fixture::ChildPath::new(self.dir.path().join(format!("{name}.json")))
.write_str(body)
.unwrap();
}
pub(super) fn path(&self) -> &Path {
self.dir.path()
}
}
pub(super) fn cabin_with_fake_pkg_config(fixtures: &Fixtures) -> Command {
let mut cmd = cabin();
cmd.env("CABIN_PKG_CONFIG", fake_pkg_config_path());
cmd.env("CABIN_FAKE_PKG_CONFIG_FIXTURES", fixtures.path());
cmd
}
fn manifest_with_system_dep(version: &str, required_clause: &str) -> String {
format!(
"[package]\nname = \"hello\"\nversion = \"0.1.0\"\n\n[target.hello]\ntype = \"executable\"\nsources = [\"src/main.cc\"]\n\n[dependencies]\nzlib = {{ version = \"{version}\", system = true{required_clause} }}\n",
)
}
fn write_hello_main(root: &Path) {
assert_fs::fixture::ChildPath::new(root.join("src/main.cc"))
.write_str(HELLO_MAIN_CC)
.unwrap();
}
#[test]
fn build_succeeds_with_no_system_deps_even_when_pkg_config_missing() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml").write_str(VALID_MANIFEST).unwrap();
write_hello_main(dir.path());
let mut cmd = cabin();
cmd.env("CABIN_PKG_CONFIG", dir.path().join("missing-pkg-config"));
cmd.current_dir(dir.path())
.arg("metadata")
.assert()
.success();
}
#[test]
fn metadata_reflects_pkg_config_cflags_in_build_flags_per_package() {
let fixtures = Fixtures::new();
fixtures.write(
"zlib",
r#"{
"version": "1.2.13",
"cflags": "-I/opt/zlib/include -DZLIB_CONST",
"libs": "-L/opt/zlib/lib -lz"
}"#,
);
let dir = TempDir::new().unwrap();
assert_fs::fixture::ChildPath::new(dir.path().join("cabin.toml"))
.write_str(&manifest_with_system_dep("", ""))
.unwrap();
write_hello_main(dir.path());
let assertion = cabin_with_fake_pkg_config(&fixtures)
.current_dir(dir.path())
.arg("metadata")
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
let view: serde_json::Value =
serde_json::from_str(&stdout).expect("metadata output should be JSON");
let pkg = package_build_flags(&view);
let includes: Vec<String> = pkg["include_dirs"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_owned())
.collect();
assert!(
includes.iter().any(|p| p == "/opt/zlib/include"),
"include dirs must reflect pkg-config -I path: {includes:?}",
);
let extra_compile: Vec<String> = pkg["extra_compile_args"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_owned())
.collect();
assert!(
extra_compile.contains(&"-DZLIB_CONST".to_owned()),
"extra compile args must carry non-include cflags: {extra_compile:?}",
);
let extra_link: Vec<String> = pkg["ldflags"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_owned())
.collect();
assert_eq!(
extra_link,
vec!["-L/opt/zlib/lib".to_owned(), "-lz".to_owned()],
"pkg-config --libs must reach the planner verbatim and in order",
);
}
fn package_build_flags(view: &serde_json::Value) -> &serde_json::Value {
let per_package = view["toolchain"]["build_flags_per_package"]
.as_object()
.expect("toolchain.build_flags_per_package object");
per_package
.values()
.next()
.expect("at least one package with build flags")
}
#[test]
fn metadata_fails_when_system_dep_is_missing() {
let fixtures = Fixtures::new();
let dir = TempDir::new().unwrap();
assert_fs::fixture::ChildPath::new(dir.path().join("cabin.toml"))
.write_str(&manifest_with_system_dep("", ""))
.unwrap();
write_hello_main(dir.path());
let assertion = cabin_with_fake_pkg_config(&fixtures)
.current_dir(dir.path())
.arg("metadata")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("zlib"),
"diagnostic should name the missing dep: {stderr}",
);
assert!(
stderr.contains("not found"),
"diagnostic should describe the failure mode: {stderr}",
);
}
#[test]
fn metadata_fails_when_system_dep_version_unsatisfied() {
let fixtures = Fixtures::new();
fixtures.write(
"zlib",
r#"{
"version": "1.1.0",
"cflags": "",
"libs": "-lz"
}"#,
);
let dir = TempDir::new().unwrap();
assert_fs::fixture::ChildPath::new(dir.path().join("cabin.toml"))
.write_str(&manifest_with_system_dep(">=2", ""))
.unwrap();
write_hello_main(dir.path());
let assertion = cabin_with_fake_pkg_config(&fixtures)
.current_dir(dir.path())
.arg("metadata")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("zlib"),
"diagnostic should name the dep: {stderr}",
);
assert!(
stderr.contains(">=2"),
"diagnostic should quote the requirement: {stderr}",
);
assert!(
stderr.contains("1.1.0"),
"diagnostic should report the installed version: {stderr}",
);
}
#[test]
fn metadata_fails_when_pkg_config_missing_and_system_dep_declared() {
let fixtures = Fixtures::new();
let dir = TempDir::new().unwrap();
let missing_pkg_config = dir.path().join("nope-pkg-config");
assert_fs::fixture::ChildPath::new(dir.path().join("cabin.toml"))
.write_str(&manifest_with_system_dep("", ""))
.unwrap();
write_hello_main(dir.path());
let mut cmd = cabin();
cmd.env("CABIN_PKG_CONFIG", &missing_pkg_config);
cmd.env("CABIN_FAKE_PKG_CONFIG_FIXTURES", fixtures.path());
let assertion = cmd
.current_dir(dir.path())
.arg("metadata")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("not found"),
"diagnostic should mention `not found`: {stderr}",
);
assert!(
stderr.contains("CABIN_PKG_CONFIG"),
"diagnostic should mention the override env var: {stderr}",
);
}
#[test]
fn cabin_pkg_config_env_var_overrides_executable() {
let fixtures = Fixtures::new();
fixtures.write(
"zlib",
r#"{
"version": "1.2.13",
"cflags": "-I/opt/zlib/include",
"libs": "-lz"
}"#,
);
let dir = TempDir::new().unwrap();
assert_fs::fixture::ChildPath::new(dir.path().join("cabin.toml"))
.write_str(&manifest_with_system_dep("", ""))
.unwrap();
write_hello_main(dir.path());
cabin_with_fake_pkg_config(&fixtures)
.current_dir(dir.path())
.arg("metadata")
.assert()
.success();
}
#[test]
fn manifest_rejects_required_field_on_system_dep() {
let dir = TempDir::new().unwrap();
assert_fs::fixture::ChildPath::new(dir.path().join("cabin.toml"))
.write_str(&manifest_with_system_dep(">=1", ", required = false"))
.unwrap();
write_hello_main(dir.path());
let assertion = cabin()
.current_dir(dir.path())
.arg("metadata")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("unknown field `required`"),
"diagnostic should call out the unknown field by name: {stderr}",
);
}
#[test]
fn build_compile_commands_carry_include_paths_from_pkg_config() {
skip_if!(
!build_tools_available(),
"build_compile_commands_carry_include_paths_from_pkg_config",
"ninja or a C++ compiler is not available"
);
let fixtures = Fixtures::new();
fixtures.write(
"zlib",
r#"{
"version": "1.2.13",
"cflags": "-I/opt/zlib/include -DZLIB_CONST",
"libs": "-L/opt/zlib/lib -lz"
}"#,
);
let dir = TempDir::new().unwrap();
assert_fs::fixture::ChildPath::new(dir.path().join("cabin.toml"))
.write_str(&manifest_with_system_dep("", ""))
.unwrap();
write_hello_main(dir.path());
cabin_with_fake_pkg_config(&fixtures)
.current_dir(dir.path())
.arg("build")
.assert()
.success();
let ccdb_path = dir.path().join("build/dev/compile_commands.json");
let ccdb = std::fs::read_to_string(&ccdb_path).expect("compile_commands.json");
assert!(
ccdb.contains("-I /opt/zlib/include"),
"compile_commands.json must carry pkg-config -I: {ccdb}",
);
assert!(
ccdb.contains("-DZLIB_CONST"),
"compile_commands.json must carry pkg-config -D: {ccdb}",
);
let ninja_path = dir.path().join("build/dev/build.ninja");
let ninja = std::fs::read_to_string(&ninja_path).expect("build.ninja");
assert!(
ninja.contains("-lz"),
"build.ninja link command must carry pkg-config -l: {ninja}",
);
assert!(
ninja.contains("-L/opt/zlib/lib"),
"build.ninja link command must carry pkg-config -L: {ninja}",
);
}
#[test]
fn fingerprint_moves_when_pkg_config_flags_change() {
let fixtures = Fixtures::new();
fixtures.write(
"zlib",
r#"{
"version": "1.2.13",
"cflags": "-I/opt/zlib/include",
"libs": "-lz"
}"#,
);
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str("[package]\nname = \"hello\"\nversion = \"0.1.0\"\n\n[target.hello]\ntype = \"executable\"\nsources = [\"src/main.cc\"]\n\n[features]\ndefault = []\nflag-a = []\n\n[dependencies]\nzlib = { version = \"\", system = true }\n")
.unwrap();
write_hello_main(dir.path());
let stdout1 = String::from_utf8_lossy(
&cabin_with_fake_pkg_config(&fixtures)
.current_dir(dir.path())
.arg("metadata")
.assert()
.success()
.get_output()
.stdout,
)
.to_string();
let view1: serde_json::Value = serde_json::from_str(&stdout1).unwrap();
let fp1 = find_fingerprint(&view1);
fixtures.write(
"zlib",
r#"{
"version": "1.2.13",
"cflags": "-I/opt/zlib/include",
"libs": "-lz -lother"
}"#,
);
let stdout2 = String::from_utf8_lossy(
&cabin_with_fake_pkg_config(&fixtures)
.current_dir(dir.path())
.arg("metadata")
.assert()
.success()
.get_output()
.stdout,
)
.to_string();
let view2: serde_json::Value = serde_json::from_str(&stdout2).unwrap();
let fp2 = find_fingerprint(&view2);
assert_ne!(
fp1, fp2,
"fingerprint must move when discovered pkg-config flags change",
);
}
fn find_fingerprint(value: &serde_json::Value) -> String {
fn walk(v: &serde_json::Value) -> Option<String> {
if let Some(map) = v.as_object() {
if let Some(fp) = map.get("fingerprint").and_then(|f| f.as_str()) {
return Some(fp.to_owned());
}
for child in map.values() {
if let Some(found) = walk(child) {
return Some(found);
}
}
}
if let Some(arr) = v.as_array() {
for item in arr {
if let Some(found) = walk(item) {
return Some(found);
}
}
}
None
}
walk(value).expect("metadata view should expose a fingerprint")
}
#[test]
fn non_matching_target_conditional_system_dep_does_not_require_pkg_config() {
let dir = TempDir::new().unwrap();
let unreachable = dir.path().join("never-reached-pkg-config");
dir.child("cabin.toml")
.write_str("[package]\nname = \"hello\"\nversion = \"0.1.0\"\n\n[target.hello]\ntype = \"executable\"\nsources = [\"src/main.cc\"]\n\n[target.'cfg(os = \"none-such\")'.dependencies]\nzlib = { version = \"\", system = true }\n")
.unwrap();
write_hello_main(dir.path());
let mut cmd = cabin();
cmd.env("CABIN_PKG_CONFIG", &unreachable);
cmd.current_dir(dir.path())
.arg("metadata")
.assert()
.success();
}
#[test]
fn matching_target_conditional_system_dep_is_probed() {
let fixtures = Fixtures::new();
fixtures.write(
"zlib",
r#"{
"version": "1.2.13",
"cflags": "-I/opt/zlib/include",
"libs": "-lz"
}"#,
);
let dir = TempDir::new().unwrap();
let host_os = if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "windows") {
"windows"
} else {
"linux"
};
assert_fs::fixture::ChildPath::new(dir.path().join("cabin.toml"))
.write_str(&format!(
"[package]\nname = \"hello\"\nversion = \"0.1.0\"\n\n[target.hello]\ntype = \"executable\"\nsources = [\"src/main.cc\"]\n\n[target.'cfg(os = \"{host_os}\")'.dependencies]\nzlib = {{ version = \"\", system = true }}\n",
))
.unwrap();
write_hello_main(dir.path());
let assertion = cabin_with_fake_pkg_config(&fixtures)
.current_dir(dir.path())
.arg("metadata")
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
let view: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let pkg = package_build_flags(&view);
let includes: Vec<String> = pkg["include_dirs"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_owned())
.collect();
assert!(
includes.iter().any(|p| p == "/opt/zlib/include"),
"matching conditional system dep must contribute flags: {includes:?}",
);
}
#[test]
fn verbose_mode_prints_probe_progress() {
let fixtures = Fixtures::new();
fixtures.write(
"zlib",
r#"{
"version": "1.2.13",
"cflags": "",
"libs": "-lz"
}"#,
);
let dir = TempDir::new().unwrap();
assert_fs::fixture::ChildPath::new(dir.path().join("cabin.toml"))
.write_str(&manifest_with_system_dep("", ""))
.unwrap();
write_hello_main(dir.path());
let assertion = cabin_with_fake_pkg_config(&fixtures)
.current_dir(dir.path())
.arg("-v")
.arg("metadata")
.assert()
.success();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("probing"),
"verbose stderr should mention probing: {stderr}",
);
assert!(
stderr.contains("zlib"),
"verbose stderr should mention the dep name: {stderr}",
);
assert!(
stderr.contains("1.2.13"),
"verbose stderr should mention the resolved version: {stderr}",
);
}
#[test]
fn metadata_stdout_stays_clean_under_verbose_with_system_deps() {
let fixtures = Fixtures::new();
fixtures.write(
"zlib",
r#"{
"version": "1.2.13",
"cflags": "",
"libs": "-lz"
}"#,
);
let dir = TempDir::new().unwrap();
assert_fs::fixture::ChildPath::new(dir.path().join("cabin.toml"))
.write_str(&manifest_with_system_dep("", ""))
.unwrap();
write_hello_main(dir.path());
let assertion = cabin_with_fake_pkg_config(&fixtures)
.current_dir(dir.path())
.arg("-v")
.arg("metadata")
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
let _view: serde_json::Value =
serde_json::from_str(&stdout).expect("metadata stdout must remain valid JSON under -v");
}
}
mod env_build_flags {
use super::*;
fn package_build_flags(view: &serde_json::Value) -> &serde_json::Value {
let per_package = view["toolchain"]["build_flags_per_package"]
.as_object()
.expect("toolchain.build_flags_per_package object");
per_package
.values()
.next()
.expect("at least one package with build flags")
}
fn fingerprint_for(dir: &Path, cmd_env: &[(&str, &str)], package: &str) -> String {
let mut cmd = cabin();
for (k, v) in cmd_env {
cmd.env(k, v);
}
let assertion = cmd
.current_dir(dir)
.args(["explain", "build-config", package, "--format", "json"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
let v: serde_json::Value = serde_json::from_str(&stdout).expect("explain JSON");
v["configuration"]["fingerprint"]
.as_str()
.expect("fingerprint string")
.to_owned()
}
fn write_simple_project(dir: &Path) {
assert_fs::fixture::ChildPath::new(dir.join("cabin.toml"))
.write_str(VALID_MANIFEST)
.unwrap();
assert_fs::fixture::ChildPath::new(dir.join("src/main.cc"))
.write_str(HELLO_MAIN_CC)
.unwrap();
}
fn metadata_view(cmd_env: &[(&str, &str)], dir: &Path) -> serde_json::Value {
let mut cmd = cabin();
for (k, v) in cmd_env {
cmd.env(k, v);
}
let assertion = cmd.current_dir(dir).arg("metadata").assert().success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
serde_json::from_str(&stdout).expect("metadata stdout must be JSON")
}
#[test]
fn cppflags_appear_in_language_neutral_compile_args() {
let dir = TempDir::new().unwrap();
write_simple_project(dir.path());
let view = metadata_view(
&[("CPPFLAGS", "-DENV_FROM_CPP=1 -I/opt/include")],
dir.path(),
);
let pkg = package_build_flags(&view);
let extras: Vec<String> = pkg["extra_compile_args"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_owned())
.collect();
assert!(
extras.contains(&"-DENV_FROM_CPP=1".to_owned()),
"CPPFLAGS must reach language-neutral bucket: {extras:?}",
);
assert!(
extras.contains(&"-I/opt/include".to_owned()),
"CPPFLAGS tokens preserved verbatim: {extras:?}",
);
let c_only: Vec<String> = pkg["cflags"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_owned())
.collect();
let cxx_only: Vec<String> = pkg["cxxflags"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_owned())
.collect();
assert!(
c_only.is_empty(),
"CPPFLAGS must not enter C-only bucket: {c_only:?}"
);
assert!(
cxx_only.is_empty(),
"CPPFLAGS must not enter C++-only bucket: {cxx_only:?}"
);
}
#[test]
fn cflags_only_reach_c_compile_bucket() {
let dir = TempDir::new().unwrap();
write_simple_project(dir.path());
let view = metadata_view(&[("CFLAGS", "-std=c11 -Wmissing-prototypes")], dir.path());
let pkg = package_build_flags(&view);
let c_only: Vec<String> = pkg["cflags"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_owned())
.collect();
assert_eq!(c_only, vec!["-std=c11", "-Wmissing-prototypes"]);
let cxx_only: Vec<String> = pkg["cxxflags"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_owned())
.collect();
assert!(
cxx_only.is_empty(),
"CFLAGS must never reach C++ bucket: {cxx_only:?}"
);
let link: Vec<String> = pkg["ldflags"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_owned())
.collect();
assert!(
link.is_empty(),
"CFLAGS must never reach link bucket: {link:?}"
);
}
#[test]
fn cxxflags_only_reach_cxx_compile_bucket() {
let dir = TempDir::new().unwrap();
write_simple_project(dir.path());
let view = metadata_view(&[("CXXFLAGS", "-fno-rtti -fno-exceptions")], dir.path());
let pkg = package_build_flags(&view);
let cxx_only: Vec<String> = pkg["cxxflags"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_owned())
.collect();
assert_eq!(cxx_only, vec!["-fno-rtti", "-fno-exceptions"]);
let c_only: Vec<String> = pkg["cflags"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_owned())
.collect();
assert!(
c_only.is_empty(),
"CXXFLAGS must never reach C bucket: {c_only:?}"
);
}
#[test]
fn ldflags_only_reach_link_bucket() {
let dir = TempDir::new().unwrap();
write_simple_project(dir.path());
let view = metadata_view(&[("LDFLAGS", "-L/opt/lib -lextra")], dir.path());
let pkg = package_build_flags(&view);
let link: Vec<String> = pkg["ldflags"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_owned())
.collect();
assert_eq!(link, vec!["-L/opt/lib", "-lextra"]);
let extras: Vec<String> = pkg["extra_compile_args"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_owned())
.collect();
assert!(
extras.is_empty(),
"LDFLAGS must never reach compile bucket: {extras:?}"
);
}
#[test]
fn empty_and_whitespace_env_vars_are_ignored() {
let dir = TempDir::new().unwrap();
write_simple_project(dir.path());
let view_empty = metadata_view(
&[
("CPPFLAGS", ""),
("CFLAGS", " \t "),
("CXXFLAGS", "\n"),
("LDFLAGS", ""),
],
dir.path(),
);
let per_package = view_empty["toolchain"]["build_flags_per_package"]
.as_object()
.expect("toolchain.build_flags_per_package object");
assert!(
per_package.is_empty(),
"empty / whitespace env vars must produce no flag contribution: {per_package:?}",
);
let base_fp = fingerprint_for(dir.path(), &[], "hello");
let empty_fp = fingerprint_for(
dir.path(),
&[
("CPPFLAGS", ""),
("CFLAGS", " \t "),
("CXXFLAGS", "\n"),
("LDFLAGS", ""),
],
"hello",
);
assert_eq!(base_fp, empty_fp);
}
#[test]
fn quoted_and_escaped_arguments_parse_correctly() {
let dir = TempDir::new().unwrap();
write_simple_project(dir.path());
let view = metadata_view(&[("CXXFLAGS", "-DNAME='hello world' -O\\ 2")], dir.path());
let pkg = package_build_flags(&view);
let cxx: Vec<String> = pkg["cxxflags"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_owned())
.collect();
assert_eq!(
cxx,
vec!["-DNAME=hello world".to_owned(), "-O 2".to_owned(),],
);
}
#[test]
fn malformed_quote_errors_name_variable() {
let dir = TempDir::new().unwrap();
write_simple_project(dir.path());
let assertion = cabin()
.current_dir(dir.path())
.env("CXXFLAGS", "'oops")
.arg("metadata")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("CXXFLAGS"),
"error must name CXXFLAGS: {stderr}"
);
assert!(
stderr.contains("shell"),
"error must explain the parse issue: {stderr}",
);
}
#[test]
fn malformed_escape_errors_name_variable() {
let dir = TempDir::new().unwrap();
write_simple_project(dir.path());
let assertion = cabin()
.current_dir(dir.path())
.env("LDFLAGS", "-L/lib\\")
.arg("metadata")
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("LDFLAGS"),
"error must name LDFLAGS: {stderr}"
);
assert!(
stderr.contains("shell"),
"error must explain the parse issue: {stderr}",
);
}
#[test]
fn order_preserved_within_a_single_variable() {
let dir = TempDir::new().unwrap();
write_simple_project(dir.path());
let view = metadata_view(&[("CPPFLAGS", "-Dfirst -Dsecond -Dthird")], dir.path());
let pkg = package_build_flags(&view);
let extras: Vec<String> = pkg["extra_compile_args"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_owned())
.collect();
assert_eq!(extras, vec!["-Dfirst", "-Dsecond", "-Dthird"]);
}
#[test]
fn env_flags_append_after_manifest_layer() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "hello"
version = "0.1.0"
[target.hello]
type = "executable"
sources = ["src/main.cc"]
[profile]
cxxflags = ["-DFROM_MANIFEST"]
ldflags = ["-Wl,--as-needed"]
"#,
)
.unwrap();
dir.child("src/main.cc").write_str(HELLO_MAIN_CC).unwrap();
let view = metadata_view(
&[("CXXFLAGS", "-DFROM_ENV"), ("LDFLAGS", "-L/from/env")],
dir.path(),
);
let pkg = package_build_flags(&view);
let cxx: Vec<String> = pkg["cxxflags"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_owned())
.collect();
assert_eq!(
cxx,
vec!["-DFROM_MANIFEST", "-DFROM_ENV"],
"env flags must append *after* manifest [profile] flags",
);
let link: Vec<String> = pkg["ldflags"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_owned())
.collect();
assert_eq!(link, vec!["-Wl,--as-needed", "-L/from/env"]);
}
#[test]
fn fingerprint_changes_when_env_flag_changes() {
let dir = TempDir::new().unwrap();
write_simple_project(dir.path());
let base_fp = fingerprint_for(dir.path(), &[], "hello");
let cpp_fp = fingerprint_for(dir.path(), &[("CPPFLAGS", "-DENV1=1")], "hello");
assert_ne!(base_fp, cpp_fp, "CPPFLAGS change must move the fingerprint");
let cflags_fp = fingerprint_for(dir.path(), &[("CFLAGS", "-std=c11")], "hello");
assert_ne!(
base_fp, cflags_fp,
"CFLAGS change must move the fingerprint"
);
assert_ne!(
cpp_fp, cflags_fp,
"CFLAGS and CPPFLAGS must hash into different fingerprints because they route to different buckets",
);
let cxx_fp = fingerprint_for(dir.path(), &[("CXXFLAGS", "-fno-rtti")], "hello");
assert_ne!(base_fp, cxx_fp, "CXXFLAGS change must move the fingerprint");
let ld_fp = fingerprint_for(dir.path(), &[("LDFLAGS", "-L/opt")], "hello");
assert_ne!(base_fp, ld_fp, "LDFLAGS change must move the fingerprint");
}
#[test]
fn fingerprint_is_deterministic_for_identical_env() {
let dir = TempDir::new().unwrap();
write_simple_project(dir.path());
let env: &[(&str, &str)] = &[
("CPPFLAGS", "-DSHARED=1"),
("CFLAGS", "-std=c11"),
("CXXFLAGS", "-std=c++20"),
("LDFLAGS", "-L/opt/lib"),
];
assert_eq!(
fingerprint_for(dir.path(), env, "hello"),
fingerprint_for(dir.path(), env, "hello"),
);
}
#[cfg(unix)]
#[test]
fn cabin_build_emits_cppflags_into_compile_commands_for_cxx_sources() {
skip_if!(
!build_tools_available(),
"cabin_build_emits_cppflags_into_compile_commands_for_cxx_sources",
"ninja or a C++ compiler is not available"
);
let dir = TempDir::new().unwrap();
write_simple_project(dir.path());
let build_dir = dir.path().join("build");
cabin()
.current_dir(dir.path())
.env("CPPFLAGS", "-DBUILD_FROM_ENV")
.args(["build", "--build-dir"])
.arg(&build_dir)
.assert()
.success();
let cc =
std::fs::read_to_string(build_dir.join("dev").join("compile_commands.json")).unwrap();
assert!(
cc.contains("-DBUILD_FROM_ENV"),
"CPPFLAGS must appear in compile_commands.json: {cc}",
);
}
#[cfg(unix)]
#[test]
fn cabin_build_emits_cxxflags_only_for_cxx_translation_units() {
skip_if!(
!build_tools_available() || !c_compiler_available(),
"cabin_build_emits_cxxflags_only_for_cxx_translation_units",
"ninja or a C/C++ compiler is not available"
);
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "mixed"
version = "0.1.0"
[target.mixed]
type = "executable"
sources = ["src/main.cc", "src/helper.c"]
"#,
)
.unwrap();
dir.child("src/main.cc")
.write_str("extern \"C\" void helper(void);\nint main() { helper(); return 0; }\n")
.unwrap();
dir.child("src/helper.c")
.write_str("void helper(void) {}\n")
.unwrap();
let build_dir = dir.path().join("build");
cabin()
.current_dir(dir.path())
.env("CXXFLAGS", "-DSEEN_BY_CXX_ONLY")
.env("CFLAGS", "-DSEEN_BY_C_ONLY")
.args(["build", "--build-dir"])
.arg(&build_dir)
.assert()
.success();
let cc =
std::fs::read_to_string(build_dir.join("dev").join("compile_commands.json")).unwrap();
let view: serde_json::Value = serde_json::from_str(&cc).unwrap();
let entries = view.as_array().expect("compile_commands is an array");
let mut cxx_seen = false;
let mut c_seen = false;
for entry in entries {
let file = entry["file"].as_str().unwrap();
let command = entry["command"].as_str().unwrap();
if file.ends_with("main.cc") {
cxx_seen = true;
assert!(
command.contains("-DSEEN_BY_CXX_ONLY"),
"C++ compile must include CXXFLAGS: {command}",
);
assert!(
!command.contains("-DSEEN_BY_C_ONLY"),
"C++ compile must NOT include CFLAGS: {command}",
);
} else if file.ends_with("helper.c") {
c_seen = true;
assert!(
command.contains("-DSEEN_BY_C_ONLY"),
"C compile must include CFLAGS: {command}",
);
assert!(
!command.contains("-DSEEN_BY_CXX_ONLY"),
"C compile must NOT include CXXFLAGS: {command}",
);
}
}
assert!(
cxx_seen && c_seen,
"expected both C/C++ entries in the compile DB"
);
}
#[cfg(unix)]
#[test]
fn cabin_build_ldflags_appear_in_ninja_link_command() {
skip_if!(
!build_tools_available(),
"cabin_build_ldflags_appear_in_ninja_link_command",
"ninja or a C++ compiler is not available"
);
let dir = TempDir::new().unwrap();
write_simple_project(dir.path());
let build_dir = dir.path().join("build");
let distinctive = "-L/this/path/should/not/exist/very-distinctive";
cabin()
.current_dir(dir.path())
.env("LDFLAGS", distinctive)
.args(["build", "--build-dir"])
.arg(&build_dir)
.assert()
.success();
let ninja_text =
std::fs::read_to_string(build_dir.join("dev").join("build.ninja")).unwrap();
assert!(
ninja_text.contains(distinctive),
"LDFLAGS must reach the link command in build.ninja: {ninja_text}",
);
let cc =
std::fs::read_to_string(build_dir.join("dev").join("compile_commands.json")).unwrap();
assert!(
!cc.contains(distinctive),
"LDFLAGS must NOT appear in compile_commands.json: {cc}",
);
}
#[cfg(unix)]
#[test]
fn ninja_rebuilds_when_cxxflags_change() {
skip_if!(
!build_tools_available(),
"ninja_rebuilds_when_cxxflags_change",
"ninja or a C++ compiler is not available"
);
let dir = TempDir::new().unwrap();
write_simple_project(dir.path());
let build_dir = dir.path().join("build");
cabin()
.current_dir(dir.path())
.env("CXXFLAGS", "-DFIRST_BUILD")
.args(["build", "--build-dir"])
.arg(&build_dir)
.assert()
.success();
let first = std::fs::read_to_string(build_dir.join("dev").join("build.ninja")).unwrap();
cabin()
.current_dir(dir.path())
.env("CXXFLAGS", "-DSECOND_BUILD")
.args(["build", "--build-dir"])
.arg(&build_dir)
.assert()
.success();
let second = std::fs::read_to_string(build_dir.join("dev").join("build.ninja")).unwrap();
assert!(
first.contains("-DFIRST_BUILD") && !first.contains("-DSECOND_BUILD"),
"first build.ninja should pin the first flag value",
);
assert!(
second.contains("-DSECOND_BUILD") && !second.contains("-DFIRST_BUILD"),
"second build.ninja should pin the second flag value",
);
}
#[cfg(unix)]
#[test]
fn cabin_run_build_phase_uses_env_flags() {
skip_if!(
!build_tools_available(),
"cabin_run_build_phase_uses_env_flags",
"ninja or a C++ compiler is not available"
);
let dir = TempDir::new().unwrap();
write_simple_project(dir.path());
let build_dir = dir.path().join("build");
cabin()
.current_dir(dir.path())
.env("CPPFLAGS", "-DRUN_PHASE_FLAG")
.args(["run", "--build-dir"])
.arg(&build_dir)
.assert()
.success();
let cc =
std::fs::read_to_string(build_dir.join("dev").join("compile_commands.json")).unwrap();
assert!(
cc.contains("-DRUN_PHASE_FLAG"),
"cabin run must propagate CPPFLAGS to the build phase: {cc}",
);
}
#[cfg(unix)]
#[test]
fn cabin_test_build_phase_uses_env_flags() {
skip_if!(
!build_tools_available(),
"cabin_test_build_phase_uses_env_flags",
"ninja or a C++ compiler is not available"
);
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "hello"
version = "0.1.0"
[target.smoke]
type = "test"
sources = ["src/test.cc"]
"#,
)
.unwrap();
dir.child("src/test.cc")
.write_str("int main() { return 0; }\n")
.unwrap();
let build_dir = dir.path().join("build");
cabin()
.current_dir(dir.path())
.env("CXXFLAGS", "-DTEST_PHASE_FLAG")
.args(["test", "--build-dir"])
.arg(&build_dir)
.assert()
.success();
let cc =
std::fs::read_to_string(build_dir.join("dev").join("compile_commands.json")).unwrap();
assert!(
cc.contains("-DTEST_PHASE_FLAG"),
"cabin test must propagate CXXFLAGS to the build phase: {cc}",
);
}
#[cfg(unix)]
#[test]
fn cabin_tidy_compile_db_sees_env_flags() {
skip_if!(
!build_tools_available(),
"cabin_tidy_compile_db_sees_env_flags",
"ninja or a C++ compiler is not available"
);
let fake_tidy = fake_tidy_path();
let dir = TempDir::new().unwrap();
write_simple_project(dir.path());
let build_dir = dir.path().join("build");
cabin()
.current_dir(dir.path())
.env("CABIN_TIDY", &fake_tidy)
.env("CABIN_BUILD_DIR", &build_dir)
.env("CPPFLAGS", "-DTIDY_DB_SEES_THIS")
.arg("tidy")
.assert()
.success();
let cc =
std::fs::read_to_string(build_dir.join("dev").join("compile_commands.json")).unwrap();
assert!(
cc.contains("-DTIDY_DB_SEES_THIS"),
"cabin tidy compile DB must include CPPFLAGS: {cc}",
);
}
fn fake_tidy_path() -> std::path::PathBuf {
let test_exe = std::env::current_exe().expect("current_exe");
let mut dir = test_exe
.parent()
.expect("test exe should live in a directory")
.to_path_buf();
if dir.file_name().and_then(|n| n.to_str()) == Some("deps") {
dir.pop();
}
let candidate = dir.join("cabin-tidy-fake-tidy");
assert!(
candidate.is_file(),
"expected fake tidy at {}; build cabin-tidy with `--features test-fake-tidy`",
candidate.display(),
);
candidate
}
#[test]
fn pkg_config_and_env_flags_coexist_in_documented_order() {
let fixtures = system_deps_pkg_config::Fixtures::new();
fixtures.write(
"zlib",
r#"{
"version": "1.2.13",
"cflags": "-I/opt/zlib/include -DZLIB_CONST",
"libs": "-L/opt/zlib/lib -lz"
}"#,
);
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "hello"
version = "0.1.0"
[target.hello]
type = "executable"
sources = ["src/main.cc"]
[dependencies]
zlib = { version = "", system = true }
"#,
)
.unwrap();
dir.child("src/main.cc").write_str(HELLO_MAIN_CC).unwrap();
let mut cmd = system_deps_pkg_config::cabin_with_fake_pkg_config(&fixtures);
cmd.env("CPPFLAGS", "-DFROM_ENV");
cmd.env("LDFLAGS", "-L/from/env");
let assertion = cmd
.current_dir(dir.path())
.arg("metadata")
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
let view: serde_json::Value = serde_json::from_str(&stdout).expect("metadata JSON");
let pkg = package_build_flags(&view);
let extras: Vec<String> = pkg["extra_compile_args"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_owned())
.collect();
let env_pos = extras
.iter()
.position(|s| s == "-DFROM_ENV")
.expect("env CPPFLAGS present");
let pkg_pos = extras
.iter()
.position(|s| s == "-DZLIB_CONST")
.expect("pkg-config define present");
assert!(
pkg_pos < env_pos,
"pkg-config define must precede env CPPFLAGS in deterministic order: {extras:?}",
);
let link: Vec<String> = pkg["ldflags"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_owned())
.collect();
let env_link = link
.iter()
.position(|s| s == "-L/from/env")
.expect("env LDFLAGS present in link args");
let pkg_link = link
.iter()
.position(|s| s == "-L/opt/zlib/lib")
.expect("pkg-config -L present in link args");
assert!(
pkg_link < env_link,
"pkg-config link flags must precede env LDFLAGS: {link:?}",
);
}
#[test]
fn cabin_fmt_unaffected_by_build_flag_env() {
let dir = TempDir::new().unwrap();
write_simple_project(dir.path());
let assertion = cabin()
.current_dir(dir.path())
.env("CXXFLAGS", "'never parsed")
.arg("fmt")
.arg("--check")
.assert();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
!stderr.contains("CXXFLAGS"),
"cabin fmt must not parse CXXFLAGS: {stderr}",
);
}
#[test]
fn cabin_new_unaffected_by_build_flag_env() {
let dir = TempDir::new().unwrap();
let assertion = cabin()
.current_dir(dir.path())
.env("CXXFLAGS", "'unterminated")
.args(["new", "demo", "--bin"])
.assert();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
!stderr.contains("CXXFLAGS"),
"cabin new must not parse CXXFLAGS: {stderr}",
);
}
#[test]
fn cabin_init_unaffected_by_build_flag_env() {
let dir = TempDir::new().unwrap();
let assertion = cabin()
.current_dir(dir.path())
.env("CPPFLAGS", "'unterminated")
.args(["init", "--bin"])
.assert();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
!stderr.contains("CPPFLAGS"),
"cabin init must not parse CPPFLAGS: {stderr}",
);
}
#[test]
fn cabin_clean_unaffected_by_build_flag_env() {
let dir = TempDir::new().unwrap();
write_simple_project(dir.path());
let assertion = cabin()
.current_dir(dir.path())
.env("LDFLAGS", "-L/lib\\")
.arg("clean")
.assert();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
!stderr.contains("LDFLAGS"),
"cabin clean must not parse LDFLAGS: {stderr}",
);
}
#[test]
fn metadata_stdout_stays_clean_with_env_flags_under_verbose() {
let dir = TempDir::new().unwrap();
write_simple_project(dir.path());
let assertion = cabin()
.current_dir(dir.path())
.env("CPPFLAGS", "-DCHATTY=1")
.env("LDFLAGS", "-L/opt/lib")
.args(["-v", "metadata"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
let _view: serde_json::Value =
serde_json::from_str(&stdout).expect("metadata stdout JSON under -v");
}
}
mod version_output {
use super::*;
fn run_version(args: &[&str]) -> String {
let assertion = cabin().args(args).assert().success();
let out = assertion.get_output();
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
stderr.is_empty(),
"cabin {args:?} stderr should be empty: {stderr}"
);
String::from_utf8(out.stdout.clone()).expect("stdout should be utf-8")
}
#[test]
fn version_subcommand_prints_concise_release_name() {
let stdout = run_version(&["version"]);
assert_eq!(stdout, format!("cabin {CABIN_VERSION}\n"));
}
#[test]
fn top_level_dash_dash_version_still_works() {
let stdout = run_version(&["--version"]);
assert_eq!(stdout, format!("cabin {CABIN_VERSION}\n"));
}
#[test]
fn top_level_dash_v_short_still_works() {
let stdout = run_version(&["-V"]);
assert_eq!(stdout, format!("cabin {CABIN_VERSION}\n"));
}
#[test]
fn version_verbose_emits_cargo_style_block() {
let stdout = run_version(&["version", "-v"]);
let first_line = stdout.lines().next().expect("at least one line");
assert!(
first_line.starts_with(format!("cabin {CABIN_VERSION}").as_str()),
"first line should be the release banner: {first_line}"
);
assert!(
stdout.contains(format!("release: {CABIN_VERSION}").as_str()),
"verbose version missing `release:` line: {stdout}"
);
}
#[test]
fn version_verbose_long_form_matches_short_form() {
let short = run_version(&["version", "-v"]);
let long = run_version(&["version", "--verbose"]);
assert_eq!(
short, long,
"`-v` and `--verbose` should produce identical output"
);
}
#[test]
fn global_verbose_before_subcommand_also_triggers_verbose() {
let trailing = run_version(&["version", "-v"]);
let leading = run_version(&["-v", "version"]);
assert_eq!(
trailing, leading,
"`cabin -v version` and `cabin version -v` should agree"
);
}
#[test]
fn verbose_emits_fields_in_deterministic_order() {
let first = run_version(&["version", "-v"]);
let second = run_version(&["version", "-v"]);
assert_eq!(first, second);
let canonical_order = ["release", "commit-hash", "commit-date", "host", "os"];
let observed: Vec<&str> = first
.lines()
.skip(1) .filter_map(|line| line.split(':').next())
.collect();
let mut next = 0;
for label in &observed {
let position = canonical_order
.iter()
.position(|known| known == label)
.unwrap_or_else(|| panic!("unexpected label `{label}` in {first}"));
assert!(
position >= next,
"labels appeared out of order: `{label}` before expected slot ({first})"
);
next = position;
}
assert!(observed.contains(&"release"));
}
#[test]
fn version_quiet_does_not_suppress_output() {
let stdout = run_version(&["version", "-q"]);
assert_eq!(stdout, format!("cabin {CABIN_VERSION}\n"));
let stdout_leading = run_version(&["-q", "version"]);
assert_eq!(stdout_leading, format!("cabin {CABIN_VERSION}\n"));
}
#[test]
fn version_verbose_host_matches_target_triple_shape() {
let stdout = run_version(&["version", "-v"]);
let host_line = stdout
.lines()
.find(|line| line.starts_with("host:"))
.unwrap_or_else(|| panic!("verbose version missing `host:` line: {stdout}"));
let triple = host_line["host:".len()..].trim();
assert!(
triple.matches('-').count() >= 2,
"host triple should have at least two dashes: {triple}"
);
}
#[test]
fn version_verbose_never_leaks_local_filesystem_paths() {
let stdout = run_version(&["version", "-v"]);
for needle in ["/Users/", "/home/", "/private/", "/opt/", "/tmp/", "/root/"] {
assert!(
!stdout.contains(needle),
"verbose version unexpectedly contains `{needle}`: {stdout}"
);
}
}
#[test]
fn version_verbose_never_leaks_username_or_hostname() {
let stdout = run_version(&["version", "-v"]);
if let Ok(user) = std::env::var("USER")
&& !user.is_empty()
&& user.len() >= 3
&& user != "user"
{
assert!(
!stdout.contains(&user),
"verbose version unexpectedly contains $USER ({user}): {stdout}"
);
}
if let Ok(home) = std::env::var("HOME") {
if let Some(name) = std::path::Path::new(&home)
.file_name()
.and_then(|s| s.to_str())
&& !name.is_empty()
&& name.len() >= 3
{
assert!(
!stdout.contains(name),
"verbose version unexpectedly contains $HOME basename ({name}): {stdout}"
);
}
}
}
#[test]
fn version_verbose_never_leaks_internal_planning_or_prompt_text() {
let stdout = run_version(&["version", "-v"]);
for forbidden in ["TODO", "FIXME", "roadmap"] {
assert!(
!stdout.contains(forbidden),
"verbose version unexpectedly contains `{forbidden}`: {stdout}"
);
}
}
#[test]
fn top_level_help_lists_version_subcommand() {
let assertion = cabin().arg("--help").assert().success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
assert!(
stdout.contains("version"),
"top-level help should list the `version` subcommand: {stdout}"
);
}
#[test]
fn version_help_documents_inheriting_global_verbosity() {
let assertion = cabin().args(["version", "--help"]).assert().success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
assert!(
stdout.contains("--verbose"),
"`cabin version --help` should mention --verbose: {stdout}"
);
assert!(
stdout.contains("--quiet"),
"`cabin version --help` should mention --quiet: {stdout}"
);
}
#[test]
fn version_works_outside_workspace() {
let dir = TempDir::new().unwrap();
let assertion = cabin()
.current_dir(dir.path())
.arg("version")
.assert()
.success();
let stdout = String::from_utf8(assertion.get_output().stdout.clone())
.expect("stdout should be utf-8");
assert_eq!(stdout, format!("cabin {CABIN_VERSION}\n"));
}
#[test]
fn version_verbose_works_outside_workspace() {
let dir = TempDir::new().unwrap();
let assertion = cabin()
.current_dir(dir.path())
.args(["version", "-v"])
.assert()
.success();
let stdout = String::from_utf8(assertion.get_output().stdout.clone())
.expect("stdout should be utf-8");
assert!(stdout.starts_with(format!("cabin {CABIN_VERSION}").as_str()));
assert!(stdout.contains(format!("\nrelease: {CABIN_VERSION}\n").as_str()));
}
#[test]
fn other_cargo_interface_commands_still_appear_in_help() {
let assertion = cabin().arg("--help").assert().success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
for cmd in visible_subcommand_names() {
assert!(
stdout.contains(&cmd),
"top-level help should still list `{cmd}`: {stdout}"
);
}
}
}
mod environment_variable_docs {
#[test]
fn environment_variables_doc_lists_terminal_env_controls() {
let docs = include_str!("../../../docs/environment-variables.md");
for name in [
cabin_env::CABIN_TERM_COLOR,
cabin_env::CABIN_TERM_VERBOSE,
cabin_env::CABIN_TERM_QUIET,
] {
assert!(
docs.contains(&format!("`{name}`")),
"docs/environment-variables.md must list `{name}` because Cabin reads it"
);
}
}
#[test]
fn environment_variables_doc_lists_build_time_version_metadata() {
let docs = include_str!("../../../docs/environment-variables.md");
for name in [
"CABIN_BUILD_COMMIT",
"CABIN_BUILD_COMMIT_DATE",
"CABIN_BUILD_HOST",
] {
assert!(
docs.contains(&format!("`{name}`")),
"docs/environment-variables.md claims to list every CABIN_* variable Cabin reads, so it must list build-time `{name}`"
);
}
assert!(
docs.contains("not runtime controls"),
"build-time metadata docs must make clear these variables are not runtime controls"
);
}
}
mod vendoring_docs {
#[test]
fn quickstart_does_not_advertise_generic_offline_test_after_vendor() {
let docs = include_str!("../../../docs/vendoring-offline.md");
let quickstart = docs
.split("## What `cabin vendor` produces")
.next()
.expect("vendoring docs should have a quickstart section");
assert!(
!quickstart.contains("cabin test --offline --index-path ./vendor"),
"the top-level vendor workflow must not imply that dev-dependency test closures are vendored: {quickstart}"
);
let glue = include_str!("../src/vendor_glue.rs");
assert!(
!glue.contains("cabin test --offline --index-path ./vendor"),
"vendor_glue module docs must not advertise a generic offline test workflow"
);
}
}
mod toolchains_docs {
#[test]
fn deferred_section_does_not_mark_implemented_surfaces_out_of_scope() {
let docs = include_str!("../../../docs/toolchains.md");
let deferred = docs
.split("## Deferred / out of scope")
.nth(1)
.expect("toolchains docs should keep a deferred section");
for implemented in [
"Config files (`~/.cabin/config.toml`-style overrides)",
"patch / override / source replacement, vendoring",
] {
assert!(
!deferred.contains(implemented),
"toolchains deferred section still lists implemented surface `{implemented}`: {deferred}"
);
}
}
}
mod architecture_docs {
#[test]
fn config_section_does_not_mark_source_replacement_or_vendoring_out_of_scope() {
let docs = include_str!("../../../docs/architecture.md");
assert!(
!docs.contains("Auth tokens, source replacement, vendoring, and new registry"),
"architecture docs still group implemented source replacement and vendoring with unsupported auth/protocol work"
);
}
}
mod installation_and_metadata_docs {
#[test]
fn installation_docs_list_the_c_compiler_slot() {
let docs = include_str!("../../../docs/installation.md");
assert!(
docs.contains("GCC- or Clang-style C compiler (`cc`, `clang`, `gcc`)"),
"installation docs must list the separate C compiler requirement for selected `.c` sources"
);
}
#[test]
fn contributing_docs_list_the_c_compiler_slot() {
let docs = include_str!("../../../CONTRIBUTING.md");
assert!(
docs.contains("a **C compiler**"),
"contributing docs must not describe end-to-end C/C++ coverage as requiring only a C++ compiler"
);
assert!(
docs.contains("tests that exercise `.c` sources"),
"contributing docs should explain when the C compiler is required"
);
}
#[test]
fn install_source_docs_do_not_describe_runtime_tools_as_cxx_only() {
let docs = include_str!("../../../INSTALL.md");
assert!(
docs.contains("C/C++ toolchains"),
"source install docs must not point users at runtime requirements as C++-only"
);
}
#[test]
fn toolchain_crate_description_mentions_c_and_cxx() {
let manifest = include_str!("../../../crates/cabin-toolchain/Cargo.toml");
assert!(
manifest.contains("C/C++ toolchain"),
"cabin-toolchain crate metadata should not describe the crate as C++-only: {manifest}"
);
}
}
mod profiles_docs {
#[test]
fn built_in_profile_table_lists_c_and_cxx_standard_flags() {
let docs = include_str!("../../../docs/profiles.md");
assert!(
docs.contains("| Profile | `debug` | `opt-level` | `assertions` | C compile flags"),
"profile docs should distinguish C/C++ standard flags"
);
assert!(
docs.contains("`-std=c11 -O0 -g`") && docs.contains("`-std=c++17 -O0 -g`"),
"profile docs must show both C/C++ built-in standard flags"
);
}
}
mod workspaces_docs {
#[test]
fn build_selection_note_uses_c_and_cxx_target_wording() {
let docs = include_str!("../../../docs/workspaces.md");
assert!(
docs.contains("plans only the C/C++ targets in the selected"),
"workspace docs should not describe build selection as C++-only"
);
}
}
mod cargo_interface_docs {
#[test]
fn version_row_matches_verbose_version_fields() {
let docs = include_str!("../../../docs/cargo-inspired-interface.md");
let row = docs
.lines()
.find(|line| line.starts_with("| `cabin version`"))
.expect("cargo-inspired interface docs must list `cabin version`");
for supported in ["commit", "host", "OS"] {
assert!(
row.contains(supported),
"`cabin version` docs should mention the actual verbose {supported} field: {row}"
);
}
for unsupported in ["rustc", "profile"] {
assert!(
!row.contains(unsupported),
"`cabin version -v` does not print {unsupported}; docs row is misleading: {row}"
);
}
}
}
mod curated_help_and_list {
use super::*;
fn run_ok(args: &[&str]) -> String {
let assertion = cabin().args(args).assert().success();
String::from_utf8(assertion.get_output().stdout.clone()).expect("stdout should be utf-8")
}
#[test]
fn help_does_not_list_hidden_subcommands_in_commands_block() {
let out = run_ok(&["--help"]);
let listed = parse_help_commands_block(&out);
for sub in hidden_subcommand_names() {
assert!(
!listed.contains(&sub),
"`--help` Commands block must not list hidden `{sub}`. Listed: {listed:?}"
);
}
}
#[test]
fn list_includes_every_subcommand_including_hidden() {
let out = run_ok(&["--list"]);
for sub in all_subcommand_names() {
assert!(out.contains(&sub), "`--list` missing `{sub}`: {out}");
}
}
#[test]
fn list_heading_is_present() {
let out = run_ok(&["--list"]);
assert!(
out.starts_with("Installed Commands:\n"),
"`--list` must lead with the Installed Commands heading: {out}"
);
}
#[test]
fn list_output_is_deterministic_across_runs() {
let first = run_ok(&["--list"]);
let second = run_ok(&["--list"]);
assert_eq!(first, second);
}
#[test]
fn list_rows_are_alphabetically_sorted() {
let out = run_ok(&["--list"]);
let names: Vec<&str> = out
.lines()
.skip(1)
.filter_map(|line| line.split_whitespace().next())
.collect();
let mut sorted = names.clone();
sorted.sort();
assert_eq!(names, sorted, "`--list` rows must be sorted: {names:?}");
}
#[test]
fn list_includes_help_subcommand() {
let out = run_ok(&["--list"]);
let names: Vec<&str> = out
.lines()
.skip(1)
.filter_map(|line| line.split_whitespace().next())
.collect();
assert!(
names.contains(&"help"),
"`--list` should surface the `help` subcommand: {names:?}"
);
}
#[test]
fn list_does_not_expose_future_or_internal_commands() {
let out = run_ok(&["--list"]);
for forbidden in ["internal-", "debug-", "experimental-", "step-", "TODO"] {
assert!(
!out.contains(forbidden),
"`--list` exposed unexpected `{forbidden}`: {out}"
);
}
}
#[test]
fn hidden_subcommands_still_run_normally() {
for sub in hidden_subcommand_names() {
let _ = cabin().args([sub.as_str(), "--help"]).assert().success();
}
}
#[test]
fn hidden_subcommands_still_appear_in_shell_completions() {
let out = run_ok(&["compgen", "bash"]);
for sub in hidden_subcommand_names() {
assert!(
out.contains(&sub),
"bash completion must still know about hidden `{sub}`: {out}"
);
}
}
#[test]
fn hidden_subcommands_still_get_individual_man_pages() {
let dir = TempDir::new().unwrap();
let out = dir.path().join("man");
cabin()
.args(["mangen", "--output-dir"])
.arg(&out)
.assert()
.success();
for sub in hidden_subcommand_names() {
let path = out.join(format!("cabin-{sub}.1"));
assert!(
path.is_file(),
"expected per-subcommand man page for hidden `{sub}` at {path:?}"
);
let body = std::fs::read(&path).unwrap();
assert!(!body.is_empty(), "cabin-{sub}.1 must be non-empty");
}
}
#[test]
fn cabin_no_args_prints_curated_help_and_exits_zero() {
let out = run_ok(&[]);
assert!(
out.contains("Usage: cabin"),
"no-arg cabin should print help: {out}"
);
let listed = parse_help_commands_block(&out);
for sub in hidden_subcommand_names() {
assert!(
!listed.contains(&sub),
"no-arg cabin help must not list hidden `{sub}`. Listed: {listed:?}"
);
}
}
#[test]
fn cabin_dash_dash_list_documented_in_top_level_help() {
let out = run_ok(&["--help"]);
assert!(
out.contains("--list"),
"`--help` must mention the `--list` flag: {out}"
);
}
#[test]
fn help_renders_visible_aliases_in_cargo_style() {
let out = run_ok(&["--help"]);
for (canonical, alias) in [("build", "b"), ("run", "r"), ("test", "t")] {
let pattern = format!("{canonical}, {alias}");
assert!(
out.contains(&pattern),
"`--help` should advertise `{pattern}`: {out}"
);
}
assert!(
!out.contains("[aliases:"),
"`--help` must not use clap's `[aliases: ...]` form: {out}"
);
}
#[test]
fn aliases_route_to_their_canonical_subcommand() {
for (alias, expected_summary) in [
("b", "Compile a local package and all of its dependencies"),
("t", "Run the tests of a local package"),
("r", "Run a binary of the local package"),
] {
let out = run_ok(&[alias, "--help"]);
assert!(
out.contains(expected_summary),
"`cabin {alias} --help` should mirror the canonical help: {out}"
);
}
}
#[test]
fn list_renders_visible_aliases_in_cargo_style() {
let out = run_ok(&["--list"]);
for (canonical, alias) in [("build", "b"), ("run", "r"), ("test", "t")] {
let pattern = format!("{canonical}, {alias}");
assert!(
out.contains(&pattern),
"`--list` should advertise `{pattern}`: {out}"
);
}
}
#[test]
fn help_ends_with_cargo_style_see_more_hint() {
let out = run_ok(&["--help"]);
let listed = parse_help_commands_block(&out);
assert!(
listed.contains(&"...".to_owned()),
"`--help` Commands block must include the `...` hint row: {listed:?}"
);
assert_eq!(
listed.last().map(String::as_str),
Some("..."),
"`...` must be the last row in the Commands block: {listed:?}"
);
assert!(
!listed.contains(&"help".to_owned()),
"auto-injected `help` row must be hidden from the curated Commands block: {listed:?}"
);
assert!(
out.contains("See all commands with --list"),
"`--help` must spell out the `--list` hint next to the `...` row: {out}"
);
}
#[test]
fn help_subcommand_still_works_even_when_hidden() {
let from_help_sub = run_ok(&["help", "build"]);
let from_long_flag = run_ok(&["build", "--help"]);
assert_eq!(
from_help_sub, from_long_flag,
"`cabin help build` should mirror `cabin build --help`"
);
}
#[test]
fn cabin_dot_dot_dot_is_a_shortcut_for_list() {
let from_dots = run_ok(&["..."]);
let from_list = run_ok(&["--list"]);
assert_eq!(
from_dots, from_list,
"`cabin ...` should mirror `cabin --list` output"
);
}
#[test]
fn dots_hint_does_not_leak_into_list() {
let out = run_ok(&["--list"]);
let names: Vec<&str> = out
.lines()
.skip(1)
.filter_map(|line| line.split_whitespace().next())
.collect();
assert!(
!names.contains(&"..."),
"`--list` must not list the `...` help-only hint: {names:?}"
);
}
#[test]
fn dots_hint_does_not_leak_into_shell_completions() {
let out = run_ok(&["compgen", "bash"]);
assert!(
!out.contains("cabin__subcmd________"),
"bash completion must not expose `...` as a subcommand: {out}"
);
}
#[test]
fn dots_hint_does_not_get_its_own_man_page() {
let dir = TempDir::new().unwrap();
let out = dir.path().join("man");
cabin()
.args(["mangen", "--output-dir"])
.arg(&out)
.assert()
.success();
let dots_path = out.join("cabin-....1");
assert!(
!dots_path.exists(),
"expected no man page for the `...` hint at {dots_path:?}"
);
}
#[test]
fn subcommand_help_never_leaks_rustdoc_intra_doc_links() {
let mut surfaces: Vec<Vec<&str>> = vec![vec!["--help"]];
let owned_help_args: Vec<[String; 2]> = visible_subcommand_names()
.into_iter()
.map(|name| [name, "--help".to_owned()])
.collect();
for pair in &owned_help_args {
surfaces.push(vec![pair[0].as_str(), pair[1].as_str()]);
}
for args in surfaces {
let assertion = cabin().args(&args).assert().success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
assert!(
!stdout.contains("[`"),
"`cabin {args:?} --help` leaks a rustdoc intra-doc link \
(pattern `[\\`...\\``): {stdout}"
);
}
}
}
#[test]
fn cabin_port_list_prints_zlib() {
let assertion = cabin().args(["port", "list"]).assert().success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
assert!(
stdout.contains("zlib") && stdout.contains("1.3.1"),
"expected name + version in output: {stdout}"
);
}
mod foundation_port_zlib {
use super::*;
use cabin_core::{DependencySource, PortDepSource};
use cabin_manifest::load_manifest;
use flate2::Compression;
use flate2::write::GzEncoder;
use sha2::{Digest, Sha256};
use std::io::Write as _;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread::JoinHandle;
const FAKE_ZLIB_HEADER: &str = r#"#ifndef ZLIB_H
#define ZLIB_H
#ifdef __cplusplus
extern "C" {
#endif
const char *zlibVersion(void);
#ifdef __cplusplus
}
#endif
#endif
"#;
const FAKE_ZLIB_SOURCE: &str = r#"#include "zlib.h"
const char *zlibVersion(void) { return "1.3.1"; }
"#;
fn make_archive(dir: &Path, name: &str, entries: &[(&str, &str)]) -> (PathBuf, String) {
let path = dir.join(name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("archive parent dir");
}
let f = fs::File::create(&path).expect("create archive");
let enc = GzEncoder::new(f, Compression::default());
let mut builder = tar::Builder::new(enc);
for (rel, body) in entries {
let bytes = body.as_bytes();
let mut header = tar::Header::new_gnu();
header.set_size(bytes.len() as u64);
header.set_mode(0o644);
header.set_entry_type(tar::EntryType::Regular);
header.set_cksum();
builder
.append_data(&mut header, rel, &mut std::io::Cursor::new(bytes))
.expect("append entry");
}
let enc = builder.into_inner().expect("finalize tar");
enc.finish().expect("finalize gzip").flush().expect("flush");
let bytes = fs::read(&path).expect("hash archive");
let mut h = Sha256::new();
h.update(&bytes);
(path, cabin_core::hash::hex_digest(&h.finalize()))
}
struct ArchiveServer {
server: Arc<tiny_http::Server>,
thread: Option<JoinHandle<()>>,
url: String,
request_count: Arc<AtomicUsize>,
}
impl ArchiveServer {
fn start(archive_bytes: Vec<u8>) -> Self {
let server = Arc::new(
tiny_http::Server::http("127.0.0.1:0").expect("bind tiny_http on loopback"),
);
let addr = server.server_addr().to_ip().expect("loopback addr");
let url = format!("http://{addr}");
let request_count = Arc::new(AtomicUsize::new(0));
let count_for_thread = Arc::clone(&request_count);
let server_for_thread = Arc::clone(&server);
let bytes = Arc::new(archive_bytes);
let bytes_for_thread = Arc::clone(&bytes);
let thread = std::thread::spawn(move || {
while let Ok(req) = server_for_thread.recv() {
let path = req.url().to_string();
if path.ends_with("/zlib-1.3.1.tar.gz") {
count_for_thread.fetch_add(1, Ordering::SeqCst);
let body = (*bytes_for_thread).clone();
let _ = req.respond(tiny_http::Response::from_data(body));
} else {
let _ = req.respond(tiny_http::Response::empty(404));
}
}
});
Self {
server,
thread: Some(thread),
url,
request_count,
}
}
fn url(&self) -> &str {
&self.url
}
fn request_count(&self) -> usize {
self.request_count.load(Ordering::SeqCst)
}
}
impl Drop for ArchiveServer {
fn drop(&mut self) {
self.server.unblock();
if let Some(handle) = self.thread.take() {
let _ = handle.join();
}
}
}
fn lay_fixture(
tmp: &Path,
archive_url: &str,
sha256_hex: &str,
strip_prefix: Option<&str>,
port_type: &str,
) -> PathBuf {
use std::fmt::Write as _;
let mut port_toml = String::new();
port_toml.push_str("[port]\nname = \"zlib\"\nversion = \"1.3.1\"\n\n[source]\n");
writeln!(port_toml, "type = \"{port_type}\"").unwrap();
writeln!(port_toml, "url = \"{archive_url}\"").unwrap();
writeln!(port_toml, "sha256 = \"{sha256_hex}\"").unwrap();
if let Some(prefix) = strip_prefix {
writeln!(port_toml, "strip_prefix = \"{prefix}\"").unwrap();
}
port_toml.push_str("\n[overlay]\nmanifest = \"cabin.toml\"\n");
assert_fs::fixture::ChildPath::new(tmp.join("ports/zlib/1.3.1/port.toml"))
.write_str(&port_toml)
.unwrap();
assert_fs::fixture::ChildPath::new(tmp.join("ports/zlib/1.3.1/cabin.toml"))
.write_str(
r#"[package]
name = "zlib"
version = "1.3.1"
[target.zlib]
type = "library"
sources = ["zlib.c"]
include_dirs = ["."]
"#,
)
.unwrap();
let consumer_manifest = tmp.join("consumer/cabin.toml");
assert_fs::fixture::ChildPath::new(&consumer_manifest)
.write_str(
r#"[package]
name = "consumer"
version = "0.1.0"
[dependencies]
zlib = { port-path = "../ports/zlib/1.3.1" }
[target.consumer]
type = "executable"
sources = ["src/main.c"]
deps = ["zlib"]
"#,
)
.unwrap();
assert_fs::fixture::ChildPath::new(tmp.join("consumer/src/main.c"))
.write_str(
r#"#include <zlib.h>
#include <stdio.h>
int main(void) {
const char *v = zlibVersion();
if (!v || !*v) return 1;
puts(v);
return 0;
}
"#,
)
.unwrap();
consumer_manifest
}
#[test]
fn builds_and_runs_downstream_consumer() {
skip_if!(
!ninja_available() || !c_compiler_available(),
"foundation_port_zlib::builds_and_runs_downstream_consumer",
"requires ninja + a C compiler"
);
let tmp = TempDir::new().unwrap();
let (archive_path, hex) = make_archive(
&tmp.path().join("downloads"),
"zlib-1.3.1.tar.gz",
&[
("zlib-1.3.1/zlib.h", FAKE_ZLIB_HEADER),
("zlib-1.3.1/zlib.c", FAKE_ZLIB_SOURCE),
],
);
let bytes = fs::read(&archive_path).unwrap();
let server = ArchiveServer::start(bytes);
let archive_url = format!("{}/zlib-1.3.1.tar.gz", server.url());
let consumer_manifest = lay_fixture(
tmp.path(),
&archive_url,
&hex,
Some("zlib-1.3.1"),
"archive",
);
let build_dir = tmp.path().join("build");
let cache_dir = tmp.path().join("cache");
cabin()
.args([
"build",
"--manifest-path",
consumer_manifest.to_str().unwrap(),
"--build-dir",
build_dir.to_str().unwrap(),
"--cache-dir",
cache_dir.to_str().unwrap(),
])
.assert()
.success();
let exe_name = format!("consumer{}", std::env::consts::EXE_SUFFIX);
let candidate_dev = build_dir.join("dev/packages/consumer").join(&exe_name);
let candidate_release = build_dir.join("release/packages/consumer").join(&exe_name);
let exe = if candidate_dev.is_file() {
candidate_dev
} else if candidate_release.is_file() {
candidate_release
} else {
panic!(
"could not find consumer executable under {}; expected `{}` in `dev/packages/consumer/` or `release/packages/consumer/`",
build_dir.display(),
exe_name
);
};
let output = std::process::Command::new(&exe)
.output()
.expect("run consumer");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),
"consumer exited non-zero: {stdout}"
);
assert!(
stdout.contains("1.3.1"),
"expected zlib version output, got {stdout:?}"
);
let first_count = server.request_count();
assert!(first_count >= 1, "expected at least one archive download");
cabin()
.args([
"build",
"--manifest-path",
consumer_manifest.to_str().unwrap(),
"--build-dir",
build_dir.to_str().unwrap(),
"--cache-dir",
cache_dir.to_str().unwrap(),
])
.assert()
.success();
assert_eq!(
server.request_count(),
first_count,
"second cabin build should reuse the cached archive (no new HTTP requests)"
);
}
#[test]
fn checksum_mismatch_surfaces_clear_diagnostic() {
let tmp = TempDir::new().unwrap();
let (archive_path, _real_hex) = make_archive(
&tmp.path().join("downloads"),
"zlib-1.3.1.tar.gz",
&[
("zlib-1.3.1/zlib.h", FAKE_ZLIB_HEADER),
("zlib-1.3.1/zlib.c", FAKE_ZLIB_SOURCE),
],
);
let bytes = fs::read(&archive_path).unwrap();
let server = ArchiveServer::start(bytes);
let archive_url = format!("{}/zlib-1.3.1.tar.gz", server.url());
let bogus = "0".repeat(64);
let consumer_manifest = lay_fixture(
tmp.path(),
&archive_url,
&bogus,
Some("zlib-1.3.1"),
"archive",
);
let assertion = cabin()
.args([
"build",
"--manifest-path",
consumer_manifest.to_str().unwrap(),
"--build-dir",
tmp.path().join("build").to_str().unwrap(),
"--cache-dir",
tmp.path().join("cache").to_str().unwrap(),
])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("checksum mismatch") && stderr.contains("zlib"),
"expected a checksum-mismatch diagnostic mentioning zlib, got: {stderr}"
);
}
#[test]
fn missing_strip_prefix_surfaces_clear_diagnostic() {
let tmp = TempDir::new().unwrap();
let (archive_path, hex) = make_archive(
&tmp.path().join("downloads"),
"zlib-1.3.1.tar.gz",
&[
("other-1.0/zlib.h", FAKE_ZLIB_HEADER),
("other-1.0/zlib.c", FAKE_ZLIB_SOURCE),
],
);
let bytes = fs::read(&archive_path).unwrap();
let server = ArchiveServer::start(bytes);
let archive_url = format!("{}/zlib-1.3.1.tar.gz", server.url());
let consumer_manifest = lay_fixture(
tmp.path(),
&archive_url,
&hex,
Some("zlib-1.3.1"),
"archive",
);
let assertion = cabin()
.args([
"build",
"--manifest-path",
consumer_manifest.to_str().unwrap(),
"--build-dir",
tmp.path().join("build").to_str().unwrap(),
"--cache-dir",
tmp.path().join("cache").to_str().unwrap(),
])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("strip_prefix") && stderr.contains("zlib-1.3.1"),
"expected a missing-strip_prefix diagnostic, got: {stderr}"
);
}
#[test]
fn unsupported_source_type_is_rejected_before_network() {
let tmp = TempDir::new().unwrap();
let consumer_manifest = lay_fixture(
tmp.path(),
"https://example.invalid/zlib.tar.gz",
&"a".repeat(64),
Some("zlib-1.3.1"),
"git",
);
let assertion = cabin()
.args([
"build",
"--manifest-path",
consumer_manifest.to_str().unwrap(),
"--build-dir",
tmp.path().join("build").to_str().unwrap(),
])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("unsupported source type") || stderr.contains("`git`"),
"expected an unsupported-source-type diagnostic, got: {stderr}"
);
}
#[test]
fn cabin_metadata_succeeds_against_unfetched_http_port() {
let tmp = TempDir::new().unwrap();
let consumer_manifest = lay_fixture(
tmp.path(),
"http://127.0.0.1:1/zlib-1.3.1.tar.gz",
&"a".repeat(64),
Some("zlib-1.3.1"),
"archive",
);
cabin()
.env("CABIN_CACHE_DIR", tmp.path().join("cache"))
.args([
"metadata",
"--manifest-path",
consumer_manifest.to_str().unwrap(),
"--format",
"json",
])
.assert()
.success();
}
#[test]
fn cabin_metadata_surfaces_prepared_port_provenance() {
let tmp = TempDir::new().unwrap();
let (archive_path, hex) = make_archive(
&tmp.path().join("downloads"),
"zlib-1.3.1.tar.gz",
&[
("zlib-1.3.1/zlib.h", FAKE_ZLIB_HEADER),
("zlib-1.3.1/zlib.c", FAKE_ZLIB_SOURCE),
],
);
let archive_url = url::Url::from_file_path(&archive_path).unwrap().to_string();
let consumer_manifest = lay_fixture(
tmp.path(),
&archive_url,
&hex,
Some("zlib-1.3.1"),
"archive",
);
let assertion = cabin()
.env("CABIN_CACHE_DIR", tmp.path().join("cache"))
.args([
"metadata",
"--manifest-path",
consumer_manifest.to_str().unwrap(),
"--format",
"json",
])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
let value: serde_json::Value =
serde_json::from_str(&stdout).expect("metadata JSON should parse");
let ports = value
.get("ports")
.and_then(serde_json::Value::as_array)
.expect("metadata view should expose a `ports` array");
assert_eq!(
ports.len(),
1,
"expected exactly one prepared port, got {ports:?}"
);
let port = &ports[0];
assert!(
port.get("port_dir").is_none(),
"top-level port_dir should be replaced by the origin block; got: {port:?}"
);
assert_eq!(port["name"].as_str(), Some("zlib"));
assert_eq!(port["version"].as_str(), Some("1.3.1"));
let origin = port.get("origin").expect("origin block");
assert_eq!(origin["kind"].as_str(), Some("path"));
let port_dir = origin["port_dir"].as_str().expect("port_dir is a string");
assert!(
std::path::Path::new(port_dir).is_absolute(),
"port_dir should be absolute, got {port_dir}"
);
assert!(
port_dir.ends_with("ports/zlib/1.3.1"),
"port_dir should point at the recipe directory, got {port_dir}"
);
let source = port.get("source").expect("source block");
assert_eq!(source["kind"].as_str(), Some("archive"));
assert_eq!(source["url"].as_str(), Some(archive_url.as_str()));
assert_eq!(
source["sha256"].as_str(),
Some(format!("sha256:{hex}").as_str())
);
assert_eq!(source["strip_prefix"].as_str(), Some("zlib-1.3.1"));
let overlay = port["overlay_manifest"]
.as_str()
.expect("overlay_manifest should be a string");
assert!(
std::path::Path::new(overlay).is_absolute(),
"overlay_manifest should be absolute, got {overlay}"
);
assert!(
overlay.ends_with("ports/zlib/1.3.1/cabin.toml"),
"overlay_manifest should point at the port's overlay file, got {overlay}"
);
}
#[test]
fn metadata_discovers_port_introduced_by_patched_manifest() {
let tmp = TempDir::new().unwrap();
let (archive_path, hex) = make_archive(
&tmp.path().join("downloads"),
"zlib-1.3.1.tar.gz",
&[
("zlib-1.3.1/zlib.h", FAKE_ZLIB_HEADER),
("zlib-1.3.1/zlib.c", FAKE_ZLIB_SOURCE),
],
);
let archive_url = url::Url::from_file_path(&archive_path).unwrap().to_string();
tmp.child("ports/zlib/1.3.1/port.toml")
.write_str(&format!(
"[port]\nname = \"zlib\"\nversion = \"1.3.1\"\n\n[source]\ntype = \"archive\"\nurl = \"{archive_url}\"\nsha256 = \"{hex}\"\nstrip_prefix = \"zlib-1.3.1\"\n\n[overlay]\nmanifest = \"cabin.toml\"\n"
))
.unwrap();
tmp.child("ports/zlib/1.3.1/cabin.toml")
.write_str(
r#"[package]
name = "zlib"
version = "1.3.1"
[target.zlib]
type = "library"
sources = ["zlib.c"]
include_dirs = ["."]
"#,
)
.unwrap();
let root = tmp.path().join("app");
assert_fs::fixture::ChildPath::new(root.join("cabin.toml"))
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
foo = ">=0.1.0 <1.0.0"
[patch]
foo = { path = "../foo-fork" }
"#,
)
.unwrap();
tmp.child("foo-fork/cabin.toml")
.write_str(
r#"[package]
name = "foo"
version = "0.1.0"
[dependencies]
zlib = { port-path = "../ports/zlib/1.3.1" }
"#,
)
.unwrap();
let assertion = cabin()
.env("CABIN_CACHE_DIR", tmp.path().join("cache"))
.args([
"metadata",
"--manifest-path",
root.join("cabin.toml").to_str().unwrap(),
"--format",
"json",
])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assertion.get_output().stdout).to_string();
let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let port_names: Vec<&str> = value["ports"]
.as_array()
.expect("metadata view should expose a `ports` array")
.iter()
.filter_map(|p| p["name"].as_str())
.collect();
assert!(
port_names.contains(&"zlib"),
"zlib must enter port discovery through the patched foo manifest, got: {port_names:?}"
);
}
#[test]
fn port_toml_schema_for_real_ports_zlib_matches_published_values() {
let manifest_dir =
std::env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR set during tests");
let port_toml = PathBuf::from(manifest_dir)
.join("../cabin-port/ports/zlib/1.3.1/port.toml")
.canonicalize()
.expect("canonicalize ports/zlib/1.3.1/port.toml");
let descriptor =
cabin_port::load_port(&port_toml).expect("ports/zlib/1.3.1/port.toml should parse");
assert_eq!(descriptor.name.as_str(), "zlib");
assert_eq!(descriptor.version, semver::Version::new(1, 3, 1));
match &descriptor.source {
cabin_port::PortSource::Archive {
url,
sha256,
strip_prefix,
} => {
assert!(
url.as_str().ends_with(".tar.gz"),
"expected a .tar.gz URL, got {url}"
);
assert_eq!(url.scheme(), "https");
assert_eq!(sha256.to_hex().len(), 64);
assert_eq!(strip_prefix.as_deref(), Some("zlib-1.3.1"));
}
}
assert_eq!(
descriptor.overlay.relative_path,
PathBuf::from("cabin.toml")
);
assert_eq!(descriptor.metadata.license.as_deref(), Some("Zlib"));
}
#[test]
fn port_true_resolves_against_bundled_zlib() {
let tmp = TempDir::new().unwrap();
let consumer = tmp.path().join("consumer");
assert_fs::fixture::ChildPath::new(&consumer)
.create_dir_all()
.unwrap();
assert_fs::fixture::ChildPath::new(consumer.join("cabin.toml"))
.write_str(
r#"
[package]
name = "consumer"
version = "0.1.0"
[dependencies]
zlib = { port = true, version = "^1.3" }
"#,
)
.unwrap();
let manifest = load_manifest(consumer.join("cabin.toml")).expect("manifest parses");
let pkg = manifest.package.expect("[package]");
let dep = pkg
.dependencies
.iter()
.find(|d| d.name.as_str() == "zlib")
.unwrap();
match &dep.source {
DependencySource::Port(PortDepSource::Builtin { name, version_req }) => {
assert_eq!(name.as_str(), "zlib");
assert_eq!(version_req.to_string(), "^1.3");
}
other => panic!("expected Builtin, got {other:?}"),
}
let entry =
cabin_port::builtin::lookup("zlib", &semver::VersionReq::parse("^1.3").unwrap())
.expect("bundled zlib");
let descriptor = cabin_port::parse_port_str(
entry.port_toml,
std::path::Path::new("<builtin:zlib>/port.toml"),
)
.unwrap();
assert_eq!(descriptor.name.as_str(), "zlib");
assert_eq!(descriptor.version.to_string(), "1.3.1");
}
#[test]
fn port_true_with_unsatisfiable_version_surfaces_clear_diagnostic() {
let tmp = TempDir::new().unwrap();
let consumer = tmp.path().join("consumer");
assert_fs::fixture::ChildPath::new(&consumer)
.create_dir_all()
.unwrap();
assert_fs::fixture::ChildPath::new(consumer.join("cabin.toml"))
.write_str(
r#"
[package]
name = "consumer"
version = "0.1.0"
[dependencies]
zlib = { port = true, version = "^2" }
"#,
)
.unwrap();
let assertion = cabin()
.args([
"metadata",
"--manifest-path",
consumer.join("cabin.toml").to_str().unwrap(),
"--format",
"json",
"--offline",
])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("no bundled foundation port `zlib` satisfies `^2`")
&& stderr.contains("1.3.1"),
"expected version-not-found diagnostic, got: {stderr}"
);
}
#[test]
fn build_skips_dev_only_port_preparation() {
skip_if!(
!ninja_available() || !c_compiler_available(),
"foundation_port_zlib::build_skips_dev_only_port_preparation",
"requires ninja + a C compiler"
);
let tmp = TempDir::new().unwrap();
let _ = lay_fixture(
tmp.path(),
"http://127.0.0.1:1/zlib-1.3.1.tar.gz",
&"a".repeat(64),
Some("zlib-1.3.1"),
"archive",
);
tmp.child("consumer/cabin.toml")
.write_str(
r#"[package]
name = "consumer"
version = "0.1.0"
[dev-dependencies]
zlib = { port-path = "../ports/zlib/1.3.1" }
[target.consumer]
type = "executable"
sources = ["src/main.c"]
"#,
)
.unwrap();
tmp.child("consumer/src/main.c")
.write_str("int main(void) { return 0; }\n")
.unwrap();
cabin()
.args([
"build",
"--manifest-path",
tmp.path().join("consumer/cabin.toml").to_str().unwrap(),
"--build-dir",
tmp.path().join("build").to_str().unwrap(),
])
.assert()
.success();
}
#[test]
fn test_skips_transitive_path_dep_dev_only_port_preparation() {
skip_if!(
!ninja_available() || !c_compiler_available(),
"foundation_port_zlib::test_skips_transitive_path_dep_dev_only_port_preparation",
"requires ninja + a C compiler"
);
let tmp = TempDir::new().unwrap();
tmp.child("ports/zlib/1.3.1/port.toml")
.write_str("[port]\nname = \"zlib\"\nversion = \"1.3.1\"\n\n[source]\ntype = \"archive\"\nurl = \"http://127.0.0.1:1/zlib-1.3.1.tar.gz\"\nsha256 = \"0000000000000000000000000000000000000000000000000000000000000000\"\n\n[overlay]\nmanifest = \"cabin.toml\"\n")
.unwrap();
tmp.child("ports/zlib/1.3.1/cabin.toml")
.write_str("[package]\nname = \"zlib\"\nversion = \"1.3.1\"\n")
.unwrap();
tmp.child("app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
lib = { path = "../lib" }
[target.app_test]
type = "test"
sources = ["src/test.c"]
"#,
)
.unwrap();
tmp.child("app/src/test.c")
.write_str("int main(void) { return 0; }\n")
.unwrap();
tmp.child("lib/cabin.toml")
.write_str(
r#"[package]
name = "lib"
version = "0.1.0"
[dev-dependencies]
zlib = { port-path = "../ports/zlib/1.3.1" }
[target.lib]
type = "library"
sources = ["src/lib.c"]
"#,
)
.unwrap();
tmp.child("lib/src/lib.c")
.write_str("int lib_dummy(void) { return 0; }\n")
.unwrap();
cabin()
.args([
"test",
"--manifest-path",
tmp.path().join("app/cabin.toml").to_str().unwrap(),
"--build-dir",
tmp.path().join("build").to_str().unwrap(),
])
.assert()
.success();
}
#[test]
fn build_scoped_to_package_ignores_sibling_port() {
skip_if!(
!ninja_available() || !c_compiler_available(),
"foundation_port_zlib::build_scoped_to_package_ignores_sibling_port",
"requires ninja + a C compiler"
);
let tmp = TempDir::new().unwrap();
let _ = lay_fixture(
tmp.path(),
"http://127.0.0.1:1/zlib-1.3.1.tar.gz",
&"a".repeat(64),
Some("zlib-1.3.1"),
"archive",
);
tmp.child("app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[target.app]
type = "executable"
sources = ["src/main.cc"]
"#,
)
.unwrap();
tmp.child("app/src/main.cc")
.write_str("int main() { return 0; }\n")
.unwrap();
tmp.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["consumer", "app"]
"#,
)
.unwrap();
cabin()
.args([
"build",
"--manifest-path",
tmp.path().join("cabin.toml").to_str().unwrap(),
"--package",
"app",
"--build-dir",
tmp.path().join("build").to_str().unwrap(),
])
.assert()
.success();
}
#[test]
fn build_scoped_port_miss_on_selected_package_still_errors() {
let tmp = TempDir::new().unwrap();
tmp.child("consumer/cabin.toml")
.write_str(
r#"[package]
name = "consumer"
version = "0.1.0"
[dependencies]
zlib = { port-path = "../ports/zlib/1.3.1" }
[target.consumer]
type = "executable"
sources = ["src/main.c"]
"#,
)
.unwrap();
tmp.child("consumer/src/main.c")
.write_str("int main(void) { return 0; }\n")
.unwrap();
let assertion = cabin()
.args([
"build",
"--manifest-path",
tmp.path().join("consumer/cabin.toml").to_str().unwrap(),
"--build-dir",
tmp.path().join("build").to_str().unwrap(),
])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("port") && stderr.contains("zlib"),
"expected a port-related diagnostic naming `zlib`, got: {stderr}"
);
}
#[test]
fn conflicting_builtin_version_requirements_surface_clear_diagnostic() {
let tmp = TempDir::new().unwrap();
tmp.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["alpha", "beta"]
"#,
)
.unwrap();
tmp.child("alpha/cabin.toml")
.write_str(
r#"[package]
name = "alpha"
version = "0.1.0"
[dependencies]
zlib = { port = true, version = "^1.3" }
"#,
)
.unwrap();
tmp.child("beta/cabin.toml")
.write_str(
r#"[package]
name = "beta"
version = "0.1.0"
[dependencies]
zlib = { port = true, version = "^2" }
"#,
)
.unwrap();
let assertion = cabin()
.args([
"metadata",
"--manifest-path",
tmp.path().join("cabin.toml").to_str().unwrap(),
"--format",
"json",
"--offline",
])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assertion.get_output().stderr).to_string();
assert!(
stderr.contains("zlib") && stderr.contains("^2"),
"expected a version-not-found diagnostic naming the unsatisfied requirement, got: {stderr}"
);
}
#[test]
fn fmt_succeeds_against_workspace_with_unfetched_http_port() {
let tmp = TempDir::new().unwrap();
let consumer_manifest = lay_fixture(
tmp.path(),
"http://127.0.0.1:1/zlib-1.3.1.tar.gz",
&"a".repeat(64),
Some("zlib-1.3.1"),
"archive",
);
let mut cmd = cabin();
if use_fake_external_tools() {
cmd.env("CABIN_FMT", workspace_test_bin("cabin-fmt-fake-formatter"));
} else {
require_external_tool("clang-format");
}
cmd.args([
"fmt",
"--manifest-path",
consumer_manifest.to_str().unwrap(),
])
.assert()
.success();
}
#[test]
fn clean_succeeds_against_workspace_with_unfetched_http_port() {
let tmp = TempDir::new().unwrap();
let consumer_manifest = lay_fixture(
tmp.path(),
"http://127.0.0.1:1/zlib-1.3.1.tar.gz",
&"a".repeat(64),
Some("zlib-1.3.1"),
"archive",
);
cabin()
.args([
"clean",
"--manifest-path",
consumer_manifest.to_str().unwrap(),
"--build-dir",
tmp.path().join("build").to_str().unwrap(),
])
.assert()
.success();
}
#[test]
fn package_selection_does_not_force_http_port_fetch() {
let tmp = TempDir::new().unwrap();
let _ = lay_fixture(
tmp.path(),
"http://127.0.0.1:1/zlib-1.3.1.tar.gz",
&"a".repeat(64),
Some("zlib-1.3.1"),
"archive",
);
tmp.child("app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[target.app]
type = "executable"
sources = ["src/main.cc"]
"#,
)
.unwrap();
tmp.child("app/src/main.cc")
.write_str("int main() { return 0; }\n")
.unwrap();
tmp.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["consumer", "app"]
"#,
)
.unwrap();
cabin()
.args([
"package",
"--manifest-path",
tmp.path().join("cabin.toml").to_str().unwrap(),
"--package",
"app",
"--output-dir",
tmp.path().join("dist").to_str().unwrap(),
])
.assert()
.success();
}
}