use std::path::PathBuf;
use crate::cli::Cli;
use crate::error::Error;
fn absolutize_optional_path(base: Option<PathBuf>) -> Result<Option<PathBuf>, Error> {
match base {
None => Ok(None),
Some(p) if p.is_absolute() => Ok(Some(p)),
Some(p) => {
let cwd = std::env::current_dir().map_err(|e| Error::Io {
source: e,
context: "obtaining cwd to absolutize --compat-root / --compat-manifest"
.to_string(),
path: None,
})?;
Ok(Some(cwd.join(p)))
}
}
}
fn absolutize_required_path(base: PathBuf) -> Result<PathBuf, Error> {
if base.is_absolute() {
return Ok(base);
}
let cwd = std::env::current_dir().map_err(|e| Error::Io {
source: e,
context: "obtaining cwd to absolutize --compat-root / --compat-report".to_string(),
path: None,
})?;
Ok(cwd.join(base))
}
#[derive(Debug, Clone)]
pub struct CompatArgs {
pub(crate) compat_root: PathBuf,
pub(crate) compat_report: PathBuf,
pub(crate) compat_cargo_test_argv: Vec<String>,
pub(crate) compat_manifest: Option<PathBuf>,
pub(crate) compat_package: Option<String>,
pub(crate) compat_commit: Option<String>,
pub(crate) compat_filter: Vec<String>,
pub(crate) compat_trybuild_macro: Vec<String>,
pub(crate) inner_cli: Cli,
}
impl CompatArgs {
pub fn from_cli(cli: Cli) -> Result<Self, Error> {
debug_assert!(
cli.compat,
"CompatArgs::from_cli called outside compat mode; validate_mode_consistency \
must have been bypassed"
);
let compat_root = absolutize_required_path(
cli.compat_root
.clone()
.expect("validate_mode_consistency ensures compat_root is set"),
)?;
let compat_report = absolutize_required_path(
cli.compat_report
.clone()
.expect("validate_mode_consistency ensures compat_report is set"),
)?;
let compat_cargo_test_argv = parse_argv_json(
cli.compat_cargo_test_argv
.as_deref()
.unwrap_or(DEFAULT_CARGO_TEST_ARGV_JSON),
)?;
let compat_manifest = absolutize_optional_path(cli.compat_manifest.clone())?;
let compat_package = cli.compat_package.clone();
let compat_commit = cli.compat_commit.clone();
let compat_filter = cli.compat_filter.clone();
let compat_trybuild_macro = cli.compat_trybuild_macro.clone();
Ok(Self {
compat_root,
compat_report,
compat_cargo_test_argv,
compat_manifest,
compat_package,
compat_commit,
compat_filter,
compat_trybuild_macro,
inner_cli: cli,
})
}
}
const DEFAULT_CARGO_TEST_ARGV_JSON: &str = r#"["cargo","test"]"#;
fn parse_argv_json(s: &str) -> Result<Vec<String>, Error> {
let value: serde_json::Value = serde_json::from_str(s).map_err(|e| Error::Cli {
clap_exit_code: 2,
message: format!(
"error: `--compat-cargo-test-argv` must be a JSON array of strings \
(e.g. `[\"cargo\",\"test\"]`); failed to parse as JSON: {e}"
),
})?;
let arr = match value {
serde_json::Value::Array(a) => a,
other => {
return Err(Error::Cli {
clap_exit_code: 2,
message: format!(
"error: `--compat-cargo-test-argv` must be a JSON array of strings \
(e.g. `[\"cargo\",\"test\"]`); got a JSON {}",
json_value_kind(&other),
),
});
}
};
let mut argv = Vec::with_capacity(arr.len());
for (idx, elem) in arr.into_iter().enumerate() {
match elem {
serde_json::Value::String(s) => argv.push(s),
other => {
return Err(Error::Cli {
clap_exit_code: 2,
message: format!(
"error: `--compat-cargo-test-argv` element at index {idx} is a JSON {} \
but every element must be a JSON string",
json_value_kind(&other),
),
});
}
}
}
Ok(argv)
}
fn json_value_kind(v: &serde_json::Value) -> &'static str {
match v {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "boolean",
serde_json::Value::Number(_) => "number",
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "array",
serde_json::Value::Object(_) => "object",
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static CWD_MUTEX: Mutex<()> = Mutex::new(());
#[test]
fn from_cli_absolutizes_relative_compat_root() {
let tmp = tempfile::tempdir().expect("creating tempdir for cli absolutize test");
let subdir_name = "my-crate-root";
let subdir = tmp.path().join(subdir_name);
std::fs::create_dir_all(&subdir).expect("creating subdir inside tempdir");
let original_cwd = std::env::current_dir().expect("getting cwd before test");
let result = {
let _guard = CWD_MUTEX
.lock()
.expect("CWD_MUTEX lock should not be poisoned");
std::env::set_current_dir(tmp.path()).expect("cd into tempdir");
let cli = crate::cli::Cli {
bless: false,
compat: true,
compat_cargo_test_argv: None,
compat_commit: None,
compat_filter: Vec::new(),
compat_manifest: None,
compat_package: None,
compat_report: Some(tmp.path().join("report.json")),
compat_root: Some(std::path::PathBuf::from(subdir_name)),
compat_trybuild_macro: Vec::new(),
filter: Vec::new(),
jobs: None,
suite: Vec::new(),
no_cache: false,
manifest_path: None,
list: false,
quiet: false,
verbose: false,
use_symlink: false,
keep_output: false,
inner_compat_normalize: false,
};
let r = CompatArgs::from_cli(cli);
std::env::set_current_dir(&original_cwd)
.expect("restoring original cwd after absolutize test");
r
};
let args = result.expect("from_cli must succeed with a valid relative compat_root");
assert!(
args.compat_root.is_absolute(),
"from_cli must absolutize --compat-root; got `{}`",
args.compat_root.display()
);
assert!(
args.compat_root
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n == subdir_name),
"absolutized compat_root must end with `{subdir_name}`; got `{}`",
args.compat_root.display()
);
}
#[test]
fn default_argv_parses_to_cargo_test() {
let argv = parse_argv_json(DEFAULT_CARGO_TEST_ARGV_JSON).expect("default must parse");
assert_eq!(argv, vec!["cargo".to_string(), "test".to_string()]);
}
#[test]
fn parse_argv_json_rejects_object() {
let err = parse_argv_json(r#"{"cargo":"test"}"#).expect_err("object must be rejected");
match err {
Error::Cli { message, .. } => assert!(
message.contains("JSON object"),
"diagnostic must name the JSON kind: {message}"
),
other => panic!("expected Cli error, got {other:?}"),
}
}
#[test]
fn parse_argv_json_rejects_string() {
let err = parse_argv_json(r#""cargo test""#).expect_err("string must be rejected");
match err {
Error::Cli { message, .. } => assert!(
message.contains("JSON string"),
"diagnostic must name the JSON kind: {message}"
),
other => panic!("expected Cli error, got {other:?}"),
}
}
#[test]
fn parse_argv_json_rejects_non_string_element() {
let err = parse_argv_json(r#"["cargo", 42]"#).expect_err("number element must be rejected");
match err {
Error::Cli { message, .. } => {
assert!(
message.contains("index 1"),
"diagnostic must name the failing index: {message}"
);
assert!(
message.contains("JSON number"),
"diagnostic must name the JSON kind: {message}"
);
}
other => panic!("expected Cli error, got {other:?}"),
}
}
#[test]
fn parse_argv_json_rejects_malformed_json() {
let err =
parse_argv_json(r#"["cargo","test"#).expect_err("malformed JSON must be rejected");
match err {
Error::Cli { message, .. } => assert!(
message.contains("failed to parse as JSON"),
"diagnostic must surface the parse failure: {message}"
),
other => panic!("expected Cli error, got {other:?}"),
}
}
#[test]
fn compat_args_from_cli_carries_compat_package() {
let tmp = tempfile::tempdir().expect("creating tempdir for projection test");
let cli = crate::cli::Cli {
bless: false,
compat: true,
compat_cargo_test_argv: None,
compat_commit: None,
compat_filter: Vec::new(),
compat_manifest: None,
compat_package: Some("axum-macros".to_string()),
compat_report: Some(tmp.path().join("report.json")),
compat_root: Some(tmp.path().to_path_buf()),
compat_trybuild_macro: Vec::new(),
filter: Vec::new(),
jobs: None,
suite: Vec::new(),
no_cache: false,
manifest_path: None,
list: false,
quiet: false,
verbose: false,
use_symlink: false,
keep_output: false,
inner_compat_normalize: false,
};
let args = CompatArgs::from_cli(cli).expect("from_cli must succeed with valid Cli");
assert_eq!(
args.compat_package.as_deref(),
Some("axum-macros"),
"from_cli must carry compat_package through unchanged"
);
}
#[test]
fn parse_argv_json_accepts_extended_argv() {
let argv = parse_argv_json(r#"["cargo","+nightly","test","--","--ignored"]"#)
.expect("extended argv must parse");
assert_eq!(
argv,
vec![
"cargo".to_string(),
"+nightly".to_string(),
"test".to_string(),
"--".to_string(),
"--ignored".to_string(),
]
);
}
}