blob: 8f0f0c8eeb3ca60aae5fa7d07cbccac84f26af16 [file] [log] [blame]
// Copyright 2021 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
use {
anyhow::{Context, Result},
std::{
collections::HashSet,
fs::File,
io::{BufRead, BufReader, Cursor, Read},
path::{Path, PathBuf},
},
};
/// Compares either match or they have a set of mismatch errors.
#[derive(Debug, Eq, PartialEq, Clone)]
pub enum CompareResult {
Matches,
Mismatch { errors: Vec<String> },
}
/// A golden file consists of lines of input that annotate expected data. Each
/// line may contain a '?' prefix which indicates an optional entry or a '#'
/// prefix which indicates the entry is a comment and should be ignored.
pub struct GoldenFile {
path: PathBuf,
required: HashSet<String>,
optional: HashSet<String>,
required_prefix: HashSet<String>,
optional_prefix: HashSet<String>,
}
/// Returns the element of `prefixes` that `name` matched, or `None`.
fn matches_prefix(name: &String, prefixes: &HashSet<String>) -> Option<String> {
for p in prefixes.iter() {
if name.starts_with(p) {
// The wildcard prefix cannot match if the remaining suffix
// contains the path separator.
let remainder = &name[p.len()..];
if !remainder.contains('/') {
return Some(p.to_string());
}
}
}
None
}
impl GoldenFile {
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref();
let golden_file = File::open(path).context("failed to open golden file")?;
Self::parse(path, BufReader::new(golden_file))
}
pub fn from_contents<P: AsRef<Path>>(path: P, contents: Vec<u8>) -> Result<Self> {
Self::parse(path, BufReader::new(Cursor::new(contents)))
}
/// Parses the lines of `reader` as follows:
///
/// * lines beginning with "#" are ignored
/// * blank lines are ignored
/// * lines beginning with "?" are treated as optional: the system may or
/// may not include the named file
/// * lines ending with "*" are prefixes by which file names should be
/// matched, rather than matched exactly
///
/// For prefix matching, the suffixes cannot contain the path separator
/// character '/'. For example, a golden file with this line:
///
/// ?/bin/goat*
///
/// indicates that the system image may or may not contain /bin/goat,
/// /bin/goats, or /bin/goat_teleporter, but it says nothing about whether
/// /bin/goats/Buttermilk is allowed.
fn parse<P: AsRef<Path>, R: Read>(path: P, reader: BufReader<R>) -> Result<Self> {
let mut required: HashSet<String> = HashSet::new();
let mut optional: HashSet<String> = HashSet::new();
let mut required_prefix: HashSet<String> = HashSet::new();
let mut optional_prefix: HashSet<String> = HashSet::new();
for line in reader.lines() {
let line = line?;
match line.chars().next() {
// Skip comment lines.
Some('#') => {}
// Optional entry
Some('?') => {
let mut stripped = line.chars();
stripped.next();
let name = String::from(stripped.as_str());
if name.ends_with("*") {
optional_prefix.insert(name.strip_suffix("*").unwrap().to_string());
} else {
optional.insert(name);
};
}
// Normal entry
Some(_) => {
let name = line.clone();
if name.ends_with("*") {
required_prefix.insert(name.strip_suffix("*").unwrap().to_string());
} else {
required.insert(name);
}
}
// Skip empty lines
None => {}
};
}
Ok(Self {
path: path.as_ref().to_path_buf(),
required,
optional,
required_prefix,
optional_prefix,
})
}
/// Returns the set of differences between the golden file and the content
/// provided. This should be an empty vector unless there is a mismatch.
pub fn compare(&self, content: Vec<String>) -> CompareResult {
let mut not_permitted: Vec<String> = Vec::new();
let mut observed: Vec<String> = Vec::new();
// Take copies of all the sets and work on those, so that this function
// does not mutate `self`.
let mut required = self.required.clone();
let mut optional = self.optional.clone();
let mut required_prefix = self.required_prefix.clone();
let mut optional_prefix = self.optional_prefix.clone();
for line in content.iter() {
if required.contains(line) {
observed.push(line.clone());
required.remove(line);
} else if optional.contains(line) {
observed.push(line.clone());
optional.remove(line);
} else if let Some(found) = matches_prefix(line, &required_prefix) {
observed.push(line.clone());
required_prefix.remove(&found);
} else if let Some(found) = matches_prefix(line, &optional_prefix) {
observed.push(line.clone());
optional_prefix.remove(&found);
} else {
not_permitted.push(line.clone());
}
}
not_permitted.sort();
observed.sort();
let mut errors: Vec<String> = Vec::new();
for entry in not_permitted.iter() {
errors.push(format!(
"{0} is not listed in {1:?} but was found in the build. If the addition to the build was intended, add a line '{0}' to {1:?}.",
entry,
self.path,
));
}
for entry in required.iter().chain(required_prefix.iter()) {
errors.push(format!(
"{0} was declared as required in {1:?} but was not found in the build. If the removal from the build was intended, update {1:?} to remove the line '{0}'.",
entry,
self.path,
));
}
if errors.is_empty() {
CompareResult::Matches
} else {
CompareResult::Mismatch { errors }
}
}
}
#[cfg(test)]
mod tests {
use {
super::*,
std::{fs::File, io::Write},
tempfile::tempdir,
};
#[test]
fn test_required_golden_files() {
let golden_path = tempdir().unwrap().into_path().join("golden.txt");
let mut golden_file = File::create(&golden_path).expect("failed to create golden");
writeln!(golden_file, "foo").expect("failed to write");
writeln!(golden_file, "bar").expect("failed to write");
writeln!(golden_file, "baz").expect("failed to write");
writeln!(golden_file, "goat*").expect("failed to write");
drop(golden_file);
let golden = GoldenFile::open(golden_path).expect("failed to open golden");
let result = golden.compare(vec![
"foo".to_string(),
"bar".to_string(),
"baz".to_string(),
"goats".to_string(),
]);
assert_eq!(result, CompareResult::Matches);
let extra_entry_result = golden.compare(vec![
"foo".to_string(),
"bar".to_string(),
"baz".to_string(),
"extra".to_string(),
]);
assert_ne!(extra_entry_result, CompareResult::Matches);
let extra_entry_result = golden.compare(vec![
"foo".to_string(),
"bar".to_string(),
"baz".to_string(),
"goats/Buttermilk".to_string(),
]);
assert_ne!(extra_entry_result, CompareResult::Matches);
let omitted_entry_result = golden.compare(vec!["foo".to_string(), "bar".to_string()]);
assert_ne!(omitted_entry_result, CompareResult::Matches);
}
#[test]
fn test_optional_golden_files() {
let golden_path = tempdir().unwrap().into_path().join("golden.txt");
let mut golden_file = File::create(&golden_path).expect("failed to create golden");
writeln!(golden_file, "foo").expect("failed to write");
writeln!(golden_file, "bar").expect("failed to write");
writeln!(golden_file, "?baz").expect("failed to write");
drop(golden_file);
let golden = GoldenFile::open(golden_path).expect("failed to open golden");
let result = golden.compare(vec!["foo".to_string(), "bar".to_string(), "baz".to_string()]);
assert_eq!(result, CompareResult::Matches);
let omitted_entry_result = golden.compare(vec!["foo".to_string(), "bar".to_string()]);
assert_eq!(omitted_entry_result, CompareResult::Matches);
let extra_entry_result = golden.compare(vec![
"foo".to_string(),
"bar".to_string(),
"baz".to_string(),
"extra".to_string(),
]);
assert_ne!(extra_entry_result, CompareResult::Matches);
}
}