use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
use crate::resolve;
#[must_use]
pub fn first_missing(base: &Path, outputs: &[PathBuf]) -> Option<String> {
outputs
.iter()
.find(|output| !base.join(output).exists())
.map(|output| output.display().to_string())
}
pub fn validate(command: &str, outputs: &[PathBuf]) -> Result<()> {
for output in outputs {
let path = output.to_string_lossy();
let reason = if path.trim().is_empty() {
"an output path cannot be blank"
} else if resolve::is_glob(&path) {
"outputs are literal paths, not patterns; a glob would never match here"
} else {
continue;
};
return Err(Error::InvalidOutput {
command: command.to_owned(),
path: path.into_owned(),
reason: reason.to_owned(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::{first_missing, validate};
fn paths(raw: &[&str]) -> Vec<PathBuf> {
raw.iter().map(PathBuf::from).collect()
}
#[test]
fn no_outputs_is_never_missing() {
let dir = tempfile::tempdir().expect("tempdir");
assert_eq!(first_missing(dir.path(), &[]), None, "nothing declared");
}
#[test]
fn reports_the_first_missing_in_declaration_order() {
let dir = tempfile::tempdir().expect("tempdir");
let base = dir.path();
std::fs::write(base.join("present.bin"), b"x").expect("write artifact");
assert_eq!(
first_missing(base, &paths(&["present.bin"])),
None,
"a present output is not missing"
);
assert_eq!(
first_missing(base, &paths(&["present.bin", "gone.bin", "also-gone.bin"])),
Some("gone.bin".to_owned()),
"the first missing path is named, not the last"
);
std::fs::remove_file(base.join("present.bin")).expect("delete artifact");
assert_eq!(
first_missing(base, &paths(&["present.bin"])),
Some("present.bin".to_owned()),
"deleting the artifact makes it missing"
);
}
#[test]
fn a_directory_artifact_counts_as_present() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(dir.path().join("doc")).expect("mkdir");
assert_eq!(
first_missing(dir.path(), &paths(&["doc"])),
None,
"a directory output exists, so it is not missing"
);
}
#[test]
fn nested_output_is_stat_ed_not_walked() {
let dir = tempfile::tempdir().expect("tempdir");
let base = dir.path();
std::fs::write(base.join(".gitignore"), "/target\n").expect("write .gitignore");
std::fs::create_dir(base.join("target")).expect("mkdir target");
std::fs::write(base.join("target/lcov.info"), b"x").expect("write artifact");
assert_eq!(
first_missing(base, &paths(&["target/lcov.info"])),
None,
"a git-ignored artifact is found without any opt-out"
);
}
#[test]
fn literal_paths_validate() {
validate("just cover", &paths(&["target/coverage/lcov.info"])).expect("literal path is ok");
validate("just cover", &[]).expect("no outputs is ok");
}
#[test]
fn a_glob_metacharacter_is_rejected_as_never_matching() {
for pattern in ["target/*.info", "target/?.info", "target/[ab].info"] {
let err = validate("just cover", &paths(&[pattern])).expect_err("glob rejected");
let message = err.to_string();
assert!(
message.contains("literal paths, not patterns"),
"the message says outputs are literal: {message}"
);
assert!(
message.contains(pattern),
"the message names the offending path: {message}"
);
}
}
#[test]
fn a_blank_output_is_rejected() {
let err = validate("just cover", &paths(&[" "])).expect_err("blank rejected");
assert!(
err.to_string().contains("cannot be blank"),
"a blank path would stat the project root and read fresh forever"
);
}
}