use std::{
collections::{BTreeMap, BTreeSet},
env,
error::Error,
fmt, io,
path::{Path, PathBuf},
process::{Command, Output},
string::FromUtf8Error,
};
#[derive(Debug)]
pub enum BehaviorError {
CurrentExecutable(io::Error),
DiscoveryIo {
executable: PathBuf,
source: io::Error,
},
DiscoveryFailed {
executable: PathBuf,
output: Output,
},
ListingUtf8(FromUtf8Error),
InvalidListing {
line: String,
},
DuplicateCheck {
name: String,
},
UnknownIgnoredCheck {
name: String,
},
InvalidSnapshotModule {
module: String,
},
EmptyScope {
module: String,
},
RecursiveDiscovery,
}
impl fmt::Display for BehaviorError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::CurrentExecutable(source) => {
write!(formatter, "could not locate the test executable: {source}")
}
Self::DiscoveryIo { executable, source } => write!(
formatter,
"could not list tests in `{}`: {source}",
executable.display()
),
Self::DiscoveryFailed { executable, output } => write!(
formatter,
"test listing in `{}` failed with {}",
executable.display(),
output.status
),
Self::ListingUtf8(source) => write!(formatter, "test listing was not UTF-8: {source}"),
Self::InvalidListing { line } => {
write!(formatter, "unrecognized test listing line: `{line}`")
}
Self::DuplicateCheck { name } => {
write!(formatter, "test `{name}` was listed more than once")
}
Self::UnknownIgnoredCheck { name } => write!(
formatter,
"ignored test `{name}` was absent from the complete listing"
),
Self::InvalidSnapshotModule { module } => write!(
formatter,
"snapshot module `{module}` was not a fully qualified child module"
),
Self::EmptyScope { module } => {
write!(formatter, "no compiled checks were found beside `{module}`")
}
Self::RecursiveDiscovery => formatter.write_str(
"behavior discovery must run inside a libtest test, not its listing process",
),
}
}
}
impl Error for BehaviorError {}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CheckDisposition {
Runnable,
Ignored,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BehaviorCheck {
qualified_name: String,
disposition: CheckDisposition,
}
impl BehaviorCheck {
pub fn qualified_name(&self) -> &str {
&self.qualified_name
}
pub const fn disposition(&self) -> CheckDisposition {
self.disposition
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BehaviorInventory {
checks: Vec<BehaviorCheck>,
}
impl BehaviorInventory {
pub fn for_snapshot(snapshot_module: &str) -> Result<Self, BehaviorError> {
if env::args_os().any(|argument| argument == "--list") {
return Err(BehaviorError::RecursiveDiscovery);
}
let executable = env::current_exe().map_err(BehaviorError::CurrentExecutable)?;
Self::from_test_binary(executable)?.beside_snapshot(snapshot_module)
}
pub fn from_test_binary(executable: impl AsRef<Path>) -> Result<Self, BehaviorError> {
let executable = executable.as_ref();
let list = |ignored| {
let mut command = Command::new(executable);
command.args(["--list", "--format", "terse"]);
if ignored {
command.arg("--ignored");
}
let output = command
.output()
.map_err(|source| BehaviorError::DiscoveryIo {
executable: executable.to_owned(),
source,
})?;
test_listing(executable, output)
};
Self::from_listings(&list(false)?, &list(true)?)
}
fn from_listings(all: &str, ignored: &str) -> Result<Self, BehaviorError> {
let all = test_names(all)?;
let ignored = test_names(ignored)?;
if let Some(name) = ignored.difference(&all).next() {
return Err(BehaviorError::UnknownIgnoredCheck { name: name.clone() });
}
Ok(Self {
checks: all
.into_iter()
.map(|qualified_name| {
let disposition = if ignored.contains(&qualified_name) {
CheckDisposition::Ignored
} else {
CheckDisposition::Runnable
};
BehaviorCheck {
qualified_name,
disposition,
}
})
.collect(),
})
}
fn beside_snapshot(mut self, snapshot_module: &str) -> Result<Self, BehaviorError> {
let invalid = || BehaviorError::InvalidSnapshotModule {
module: snapshot_module.to_owned(),
};
let (crate_name, relative_module) = snapshot_module.split_once("::").ok_or_else(invalid)?;
if crate_name.is_empty() || relative_module.split("::").any(str::is_empty) {
return Err(invalid());
}
let parent = relative_module
.rsplit_once("::")
.map_or("", |(parent, _)| parent);
let owner_prefix = format!("{parent}::");
let snapshot_prefix = format!("{relative_module}::");
self.checks.retain(|check| {
(parent.is_empty() || check.qualified_name.starts_with(&owner_prefix))
&& !check.qualified_name.starts_with(&snapshot_prefix)
});
if self.checks.is_empty() {
return Err(BehaviorError::EmptyScope {
module: snapshot_module.to_owned(),
});
}
Ok(self)
}
pub fn checks(&self) -> &[BehaviorCheck] {
&self.checks
}
pub fn render(&self) -> String {
let mut sentences = BTreeMap::<String, Vec<&BehaviorCheck>>::new();
for check in &self.checks {
let name = check
.qualified_name
.rsplit("::")
.next()
.unwrap_or(&check.qualified_name);
let words = name.replace('_', " ");
let sentence = words
.chars()
.take(1)
.flat_map(char::to_uppercase)
.chain(words.chars().skip(1))
.collect();
sentences.entry(sentence).or_default().push(check);
}
let mut rendering = String::new();
for (sentence, checks) in sentences {
for check in &checks {
rendering.push_str("- ");
rendering.push_str(&sentence);
if checks.len() > 1 {
rendering.push_str(" (");
rendering.push_str(&check.qualified_name);
rendering.push(')');
}
match check.disposition {
CheckDisposition::Runnable => {}
CheckDisposition::Ignored => rendering.push_str(" [ignored]"),
}
rendering.push('\n');
}
}
rendering
}
}
fn test_listing(executable: &Path, output: Output) -> Result<String, BehaviorError> {
if !output.status.success() {
return Err(BehaviorError::DiscoveryFailed {
executable: executable.to_owned(),
output,
});
}
String::from_utf8(output.stdout).map_err(BehaviorError::ListingUtf8)
}
fn test_names(listing: &str) -> Result<BTreeSet<String>, BehaviorError> {
let mut names = BTreeSet::new();
for line in listing.lines().filter(|line| !line.is_empty()) {
if let Some(name) = line.strip_suffix(": test") {
if name.is_empty() || name.split("::").any(str::is_empty) {
return Err(BehaviorError::InvalidListing {
line: line.to_owned(),
});
}
if !names.insert(name.to_owned()) {
return Err(BehaviorError::DuplicateCheck {
name: name.to_owned(),
});
}
} else if !line.ends_with(": benchmark") {
return Err(BehaviorError::InvalidListing {
line: line.to_owned(),
});
}
}
Ok(names)
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod snapshot;