use std::path::PathBuf;
use clap::Parser;
use crate::error::Error;
#[derive(Debug, Clone, Parser)]
#[command(
name = "cargo-lihaaf",
bin_name = "cargo lihaaf",
version,
about = "Fast, parallel, non-flaky proc-macro test harness",
long_about = "Fast, parallel, non-flaky proc-macro test harness for compile-fail \
and compile-pass fixtures. The consumer crate is built once as a \
Rust dynamic library at session startup; each fixture is a \
per-fixture rustc invocation that links the dylib via --extern. \
See `target/lihaaf/manifest.json` for the dylib metadata after \
the first run. Configuration: `[package.metadata.lihaaf]` in the \
consumer's Cargo.toml.\n\n\
New setup: add `dylib_crate`, `extern_crates`, and `fixture_dirs` \
to `[package.metadata.lihaaf]`, then place `.rs` fixtures and \
matching `.stderr` snapshots under the configured fixture dirs. \
Run `cargo lihaaf --bless` once to create or refresh snapshots, \
then `cargo lihaaf` in CI.\n\n\
Trybuild migration note: most conversions only need `dev_deps`; \
for split metadata/dylib crates whose fixtures cannot resolve \
metadata-side dev-deps, set `build_targets = [\"tests\"]` on \
each suite that needs staged dev-dep collection. See the \
repository migration guide at `docs/migrating-from-trybuild.md`."
)]
pub struct Cli {
#[arg(long)]
pub bless: bool,
#[arg(long)]
pub compat: bool,
#[arg(long, value_name = "JSON")]
pub compat_cargo_test_argv: Option<String>,
#[arg(long, value_name = "SHA")]
pub compat_commit: Option<String>,
#[arg(long, value_name = "SUBSTR")]
pub compat_filter: Vec<String>,
#[arg(long, value_name = "PATH")]
pub compat_manifest: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
pub compat_report: Option<PathBuf>,
#[arg(long, value_name = "DIR")]
pub compat_root: Option<PathBuf>,
#[arg(short = 'p', long = "package", value_name = "PACKAGE", value_parser = parse_compat_package)]
pub compat_package: Option<String>,
#[arg(long, value_name = "PATH")]
pub compat_trybuild_macro: Vec<String>,
#[arg(long)]
pub filter: Vec<String>,
#[arg(short = 'j', long = "jobs", value_parser = parse_jobs)]
pub jobs: Option<u32>,
#[arg(long, value_name = "NAME")]
pub suite: Vec<String>,
#[arg(long)]
pub no_cache: bool,
#[arg(long, value_name = "PATH")]
pub manifest_path: Option<PathBuf>,
#[arg(long)]
pub list: bool,
#[arg(short = 'q', long)]
pub quiet: bool,
#[arg(short = 'v', long)]
pub verbose: bool,
#[arg(long)]
pub use_symlink: bool,
#[arg(long)]
pub keep_output: bool,
#[arg(skip)]
pub(crate) inner_compat_normalize: bool,
}
fn parse_compat_package(s: &str) -> Result<String, String> {
if s.is_empty() {
return Err("`--package` requires a non-empty package name".to_string());
}
Ok(s.to_string())
}
fn parse_jobs(s: &str) -> Result<u32, String> {
let n: u32 = s
.parse()
.map_err(|_| format!("`{s}` is not a non-negative integer"))?;
if n == 0 {
return Err(
"must be a positive integer (`-j 0` is rejected; omit `-j` to use the default)"
.to_string(),
);
}
Ok(n)
}
pub fn parse_from(argv: Vec<String>) -> Result<Cli, Error> {
use clap::error::ErrorKind;
let cli = match Cli::try_parse_from(argv) {
Ok(cli) => cli,
Err(e) => {
let kind = e.kind();
let exit_code = match kind {
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => 0,
_ => 2,
};
let message = e.to_string();
let _ = e.print();
return Err(Error::Cli {
clap_exit_code: exit_code,
message,
});
}
};
if let Err(e) = cli.validate_mode_consistency() {
if let Error::Cli { message, .. } = &e {
eprintln!("{message}");
}
return Err(e);
}
Ok(cli)
}
impl Cli {
pub fn effective_bless(&self) -> bool {
if self.bless {
return true;
}
std::env::var("LIHAAF_OVERWRITE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
pub(crate) fn validate_mode_consistency(&self) -> Result<(), Error> {
if self.compat {
if !self.filter.is_empty() {
return Err(cli_mode_error(
"--filter",
"--compat-filter",
"compat mode owns the fixture-path filter surface",
));
}
if self.manifest_path.is_some() {
return Err(cli_mode_error(
"--manifest-path",
"--compat-manifest",
"compat mode owns the manifest-path surface",
));
}
if self.compat_root.is_none() {
return Err(missing_required_compat_flag("--compat-root"));
}
if self.compat_report.is_none() {
return Err(missing_required_compat_flag("--compat-report"));
}
if self.compat_package.is_some() && self.compat_manifest.is_some() {
return Err(Error::Cli {
clap_exit_code: 2,
message: "error: `--package` and `--compat-manifest` cannot be combined: \
`--compat-manifest` supplies an explicit manifest path directly to \
compat mode, while `--package` invokes the workspace-member resolver. \
Use one or the other."
.to_string(),
});
}
} else {
if self.compat_cargo_test_argv.is_some() {
return Err(non_compat_mode_error("--compat-cargo-test-argv"));
}
if self.compat_commit.is_some() {
return Err(non_compat_mode_error("--compat-commit"));
}
if !self.compat_filter.is_empty() {
return Err(non_compat_mode_error("--compat-filter"));
}
if self.compat_manifest.is_some() {
return Err(non_compat_mode_error("--compat-manifest"));
}
if self.compat_package.is_some() {
return Err(non_compat_mode_error("--package"));
}
if self.compat_report.is_some() {
return Err(non_compat_mode_error("--compat-report"));
}
if self.compat_root.is_some() {
return Err(non_compat_mode_error("--compat-root"));
}
if !self.compat_trybuild_macro.is_empty() {
return Err(non_compat_mode_error("--compat-trybuild-macro"));
}
}
Ok(())
}
}
fn cli_mode_error(bare_flag: &str, compat_flag: &str, rationale: &str) -> Error {
Error::Cli {
clap_exit_code: 2,
message: format!(
"error: `{bare_flag}` cannot be combined with `--compat`: {rationale}. \
Use `{compat_flag}` instead."
),
}
}
fn non_compat_mode_error(flag: &str) -> Error {
Error::Cli {
clap_exit_code: 2,
message: format!(
"error: `{flag}` requires `--compat` (compat-mode-only flag). \
Pass `--compat` to switch the binary into compat mode, or remove `{flag}`."
),
}
}
fn missing_required_compat_flag(flag: &str) -> Error {
Error::Cli {
clap_exit_code: 2,
message: format!(
"error: `{flag}` is required when `--compat` is set. \
See `cargo lihaaf --help` for the compat-mode invocation shape."
),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Cli {
let argv: Vec<String> = std::iter::once("cargo-lihaaf".to_owned())
.chain(args.iter().map(|s| s.to_string()))
.collect();
parse_from(argv).expect("parse must succeed")
}
#[test]
fn defaults_are_safe_posture() {
let c = parse(&[]);
assert!(!c.bless);
assert!(c.filter.is_empty());
assert!(c.jobs.is_none());
assert!(!c.no_cache);
assert!(c.manifest_path.is_none());
assert!(!c.list);
assert!(!c.quiet);
assert!(!c.verbose);
assert!(!c.use_symlink);
assert!(!c.keep_output);
}
#[test]
fn defaults_for_compat_fields_are_safe_posture() {
let c = parse(&[]);
assert!(!c.compat);
assert!(c.compat_cargo_test_argv.is_none());
assert!(c.compat_commit.is_none());
assert!(c.compat_filter.is_empty());
assert!(c.compat_manifest.is_none());
assert!(c.compat_package.is_none());
assert!(c.compat_report.is_none());
assert!(c.compat_root.is_none());
assert!(c.compat_trybuild_macro.is_empty());
}
#[test]
fn filter_accumulates() {
let c = parse(&["--filter", "phase7", "--filter", "phase8"]);
assert_eq!(c.filter, vec!["phase7".to_string(), "phase8".to_string()]);
}
#[test]
fn jobs_short_long() {
assert_eq!(parse(&["-j", "4"]).jobs, Some(4));
assert_eq!(parse(&["--jobs", "8"]).jobs, Some(8));
}
#[test]
fn jobs_zero_is_rejected_per_spec_section_5_2() {
let argv: Vec<String> = ["cargo-lihaaf", "-j", "0"]
.iter()
.map(|s| s.to_string())
.collect();
let err = parse_from(argv).expect_err("`-j 0` must be rejected");
match err {
Error::Cli { message, .. } => {
assert!(
message.contains("positive integer"),
"diagnostic must explain the requirement: {message}"
);
}
other => panic!("expected Cli error, got {other:?}"),
}
}
#[test]
fn jobs_long_form_zero_also_rejected() {
let argv: Vec<String> = ["cargo-lihaaf", "--jobs", "0"]
.iter()
.map(|s| s.to_string())
.collect();
assert!(parse_from(argv).is_err());
}
#[test]
fn cli_parses_short_p_flag() {
let c = parse(&[
"--compat",
"--compat-root",
"/tmp/ws",
"--compat-report",
"/tmp/r.json",
"-p",
"axum-macros",
]);
assert_eq!(c.compat_package.as_deref(), Some("axum-macros"));
}
#[test]
fn cli_parses_long_package_flag() {
let c = parse(&[
"--compat",
"--compat-root",
"/tmp/ws",
"--compat-report",
"/tmp/r.json",
"--package",
"axum-macros",
]);
assert_eq!(c.compat_package.as_deref(), Some("axum-macros"));
}
#[test]
fn cli_rejects_empty_package_name() {
let argv: Vec<String> = [
"cargo-lihaaf",
"--compat",
"--compat-root",
"/tmp/ws",
"--compat-report",
"/tmp/r.json",
"-p",
"",
]
.iter()
.map(|s| s.to_string())
.collect();
let err = parse_from(argv).expect_err("empty `--package` must be rejected");
match err {
Error::Cli { message, .. } => assert!(
message.contains("`--package` requires a non-empty package name"),
"diagnostic must name the requirement: {message}"
),
other => panic!("expected Cli error, got {other:?}"),
}
}
#[test]
fn cli_rejects_package_outside_compat_mode() {
let argv: Vec<String> = ["cargo-lihaaf", "-p", "axum-macros"]
.iter()
.map(|s| s.to_string())
.collect();
let err = parse_from(argv).expect_err("`--package` outside compat mode must be rejected");
match err {
Error::Cli { message, .. } => {
assert!(
message.contains("`--package` requires `--compat`"),
"diagnostic must name the requirement: {message}"
);
}
other => panic!("expected Cli error, got {other:?}"),
}
}
#[test]
fn cli_rejects_package_with_compat_manifest() {
let argv: Vec<String> = [
"cargo-lihaaf",
"--compat",
"--compat-root",
"/x",
"--compat-manifest",
"/y/Cargo.toml",
"-p",
"foo",
"--compat-report",
"/z",
]
.iter()
.map(|s| s.to_string())
.collect();
let err = parse_from(argv).expect_err("`--package` + `--compat-manifest` must be rejected");
match err {
Error::Cli { message, .. } => {
assert!(
message
.contains("cannot be combined: `--compat-manifest` supplies an explicit"),
"diagnostic must explain mutual exclusion: {message}"
);
}
other => panic!("expected Cli error, got {other:?}"),
}
}
#[test]
fn bless_via_env_when_flag_absent() {
let prev = std::env::var("LIHAAF_OVERWRITE").ok();
unsafe {
std::env::set_var("LIHAAF_OVERWRITE", "1");
}
let c = parse(&[]);
assert!(c.effective_bless());
unsafe {
match prev {
Some(v) => std::env::set_var("LIHAAF_OVERWRITE", v),
None => std::env::remove_var("LIHAAF_OVERWRITE"),
}
}
}
}