use std::path::Path;
use std::path::PathBuf;
use std::path::absolute;
use arrayvec::ArrayString;
use fixedbitset::FixedBitSet;
use path_clean::PathClean;
use thiserror::Error;
use toml_spanner::Toml;
use toml_spanner::helper::display;
use toml_spanner::helper::parse_string;
use url::Url;
#[derive(Debug, Error)]
pub enum Error {
#[error("failed to read baseline file `{path}`", path = path.display())]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse baseline file `{path}`", path = path.display())]
Parse {
path: PathBuf,
#[source]
source: toml_spanner::FromTomlError,
},
#[error("failed to serialize baseline")]
Serialize(#[from] toml_spanner::ToTomlError),
#[error("failed to write baseline file `{path}`", path = path.display())]
Write {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
impl Error {
pub fn is_not_found(&self) -> bool {
matches!(
self,
Self::Read { source, .. } if source.kind() == std::io::ErrorKind::NotFound
)
}
}
pub type Result<T> = std::result::Result<T, Error>;
pub const DEFAULT_BASELINE_FILENAME: &str = "sprocket-baseline.toml";
#[derive(Clone, Debug, Toml)]
#[toml(Toml)]
pub struct BaselineEntry {
rule: String,
path: String,
#[toml(FromToml with = parse_string, ToToml with = display)]
source_hash: ArrayString<64>,
}
impl BaselineEntry {
pub fn new(
rule: impl Into<String>,
path: impl Into<String>,
source_hash: ArrayString<64>,
) -> Self {
Self {
rule: rule.into(),
path: path.into(),
source_hash,
}
}
pub fn rule(&self) -> &str {
&self.rule
}
pub fn path(&self) -> &str {
&self.path
}
pub fn source_hash(&self) -> &str {
&self.source_hash
}
}
#[derive(Clone, Debug, Default, Toml)]
#[toml(Toml)]
pub struct Baseline {
#[toml(default, style = Header)]
diagnostic: Vec<BaselineEntry>,
#[toml(skip)]
index: BaselineIndex,
}
#[derive(Clone, Debug, Default)]
struct BaselineIndex {
resolved: Vec<Option<Url>>,
sorted: Vec<usize>,
base_dir: Option<PathBuf>,
}
impl BaselineIndex {
fn rebuild(&mut self, entries: &[BaselineEntry]) {
self.resolved.clear();
self.resolved.extend(
entries
.iter()
.map(|entry| resolve_entry_ref(entry, self.base_dir.as_deref())),
);
self.sorted.clear();
self.sorted.extend(0..entries.len());
self.sorted.sort_by(|&a, &b| {
self.resolved[a]
.cmp(&self.resolved[b])
.then_with(|| entries[a].rule.cmp(&entries[b].rule))
.then_with(|| entries[a].source_hash.cmp(&entries[b].source_hash))
});
}
}
impl Baseline {
pub fn new(diagnostic: Vec<BaselineEntry>) -> Self {
let mut baseline = Self {
diagnostic,
index: BaselineIndex::default(),
};
baseline.index.rebuild(&baseline.diagnostic);
baseline
}
pub fn with_base_dir(mut self, base_dir: PathBuf) -> Self {
self.index.base_dir = Some(base_dir);
self.index.rebuild(&self.diagnostic);
self
}
pub fn entries(&self) -> &[BaselineEntry] {
&self.diagnostic
}
pub fn load(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path).map_err(|source| Error::Read {
path: path.to_path_buf(),
source,
})?;
let mut baseline: Baseline =
toml_spanner::from_str(&content).map_err(|source| Error::Parse {
path: path.to_path_buf(),
source,
})?;
let abs_path = absolute(path)
.map(|p| p.clean())
.unwrap_or_else(|_| path.to_path_buf());
baseline.index.base_dir = abs_path.parent().map(Path::to_path_buf);
baseline.index.rebuild(&baseline.diagnostic);
Ok(baseline)
}
pub fn load_or_default(path: &Path, required: bool) -> Result<Option<Self>> {
match Self::load(path) {
Ok(baseline) => Ok(Some(baseline)),
Err(e) if !required && e.is_not_found() => Ok(None),
Err(e) => Err(e),
}
}
pub fn write(&self, path: &Path) -> Result<()> {
let content = toml_spanner::to_string(self)?;
std::fs::write(path, content).map_err(|source| Error::Write {
path: path.to_path_buf(),
source,
})
}
pub fn sort(&mut self) {
self.diagnostic.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then_with(|| a.rule.cmp(&b.rule))
.then_with(|| a.source_hash.cmp(&b.source_hash))
});
self.index.rebuild(&self.diagnostic);
}
pub fn matcher(&self) -> BaselineMatcher<'_> {
BaselineMatcher {
baseline: self,
matched: FixedBitSet::with_capacity(self.diagnostic.len()),
}
}
}
fn resolve_entry_ref(entry: &BaselineEntry, base_dir: Option<&Path>) -> Option<Url> {
if entry.path.contains("://") {
return Url::parse(&entry.path).ok();
}
let base_dir = base_dir?;
let joined = base_dir.join(&entry.path);
Url::from_file_path(absolute(joined).ok()?.clean()).ok()
}
#[derive(Debug)]
pub struct BaselineMatcher<'a> {
baseline: &'a Baseline,
matched: FixedBitSet,
}
impl<'a> BaselineMatcher<'a> {
pub fn is_suppressed(
&mut self,
diagnostic: &wdl_ast::Diagnostic,
document: &wdl_analysis::Document,
) -> bool {
if let Some(rule) = diagnostic.rule()
&& let Some(label) = diagnostic.labels().next()
&& let Some(hash) = document.hash_span(label.span())
{
return self.matches_entry(rule, document.uri(), hash.as_str());
}
false
}
pub fn stale_entries(&self) -> impl Iterator<Item = &'a BaselineEntry> + '_ {
self.baseline
.diagnostic
.iter()
.enumerate()
.filter_map(|(i, entry)| (!self.matched.contains(i)).then_some(entry))
}
fn matches_entry(&mut self, rule: &str, doc_ref: &Url, hash: &str) -> bool {
let sorted = &self.baseline.index.sorted;
let key = |i: usize| {
(
self.baseline.index.resolved[i].as_ref(),
self.baseline.diagnostic[i].rule.as_str(),
self.baseline.diagnostic[i].source_hash.as_str(),
)
};
let target = (Some(doc_ref), rule, hash);
let start = sorted.partition_point(|&i| key(i) < target);
let end = sorted.partition_point(|&i| key(i) <= target);
for &i in &sorted[start..end] {
if !self.matched.contains(i) {
self.matched.insert(i);
return true;
}
}
false
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write_source(dir: &Path, name: &str, content: &str) -> PathBuf {
let path = dir.join(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&path, content).unwrap();
path
}
fn doc_ref(path: &Path) -> Url {
Url::from_file_path(absolute(path).unwrap().clean()).unwrap()
}
fn hash(content: &str) -> ArrayString<64> {
blake3::hash(content.as_bytes()).to_hex()
}
#[test]
fn matches_same_content() {
let dir = tempfile::tempdir().unwrap();
let source = write_source(dir.path(), "tasks/align.wdl", "stub");
let baseline = Baseline::new(vec![BaselineEntry::new(
"MissingRuntime",
"tasks/align.wdl",
hash(" runtime {}\n"),
)])
.with_base_dir(dir.path().to_path_buf());
let mut matcher = baseline.matcher();
assert!(matcher.matches_entry(
"MissingRuntime",
&doc_ref(&source),
&hash(" runtime {}\n")
));
}
#[test]
fn does_not_match_different_content() {
let dir = tempfile::tempdir().unwrap();
let source = write_source(dir.path(), "tasks/align.wdl", "stub");
let baseline = Baseline::new(vec![BaselineEntry::new(
"MissingRuntime",
"tasks/align.wdl",
hash(" runtime {}\n"),
)])
.with_base_dir(dir.path().to_path_buf());
let mut matcher = baseline.matcher();
assert!(!matcher.matches_entry(
"MissingRuntime",
&doc_ref(&source),
&hash(" runtime { docker: \"ubuntu\" }\n")
));
}
#[test]
fn does_not_match_different_rule() {
let dir = tempfile::tempdir().unwrap();
let source = write_source(dir.path(), "tasks/align.wdl", "stub");
let baseline = Baseline::new(vec![BaselineEntry::new(
"MissingRuntime",
"tasks/align.wdl",
hash(" runtime {}\n"),
)])
.with_base_dir(dir.path().to_path_buf());
let mut matcher = baseline.matcher();
assert!(!matcher.matches_entry(
"MissingOutput",
&doc_ref(&source),
&hash(" runtime {}\n")
));
}
#[test]
fn does_not_match_different_path() {
let dir = tempfile::tempdir().unwrap();
write_source(dir.path(), "tasks/align.wdl", "stub");
let other = write_source(dir.path(), "tasks/other.wdl", "stub");
let baseline = Baseline::new(vec![BaselineEntry::new(
"MissingRuntime",
"tasks/align.wdl",
hash(" runtime {}\n"),
)])
.with_base_dir(dir.path().to_path_buf());
let mut matcher = baseline.matcher();
assert!(!matcher.matches_entry(
"MissingRuntime",
&doc_ref(&other),
&hash(" runtime {}\n")
));
}
#[test]
fn single_entry_suppresses_one_diagnostic_then_stops() {
let dir = tempfile::tempdir().unwrap();
let source = write_source(dir.path(), "tasks/align.wdl", "stub");
let baseline = Baseline::new(vec![BaselineEntry::new(
"MissingRuntime",
"tasks/align.wdl",
hash(" runtime {}\n"),
)])
.with_base_dir(dir.path().to_path_buf());
let uri = doc_ref(&source);
let mut matcher = baseline.matcher();
assert!(matcher.matches_entry("MissingRuntime", &uri, &hash(" runtime {}\n")));
assert!(
!matcher.matches_entry("MissingRuntime", &uri, &hash(" runtime {}\n")),
"second `MissingRuntime` diagnostic should not be suppressed by a single entry"
);
}
#[test]
fn fresh_matcher_starts_empty() {
let dir = tempfile::tempdir().unwrap();
let source = write_source(dir.path(), "tasks/align.wdl", "stub");
let baseline = Baseline::new(vec![BaselineEntry::new(
"MissingRuntime",
"tasks/align.wdl",
hash(" runtime {}\n"),
)])
.with_base_dir(dir.path().to_path_buf());
let uri = doc_ref(&source);
{
let mut matcher = baseline.matcher();
assert!(matcher.matches_entry("MissingRuntime", &uri, &hash(" runtime {}\n")));
assert_eq!(matcher.stale_entries().count(), 0);
}
let mut matcher = baseline.matcher();
assert_eq!(matcher.stale_entries().count(), 1);
assert!(matcher.matches_entry("MissingRuntime", &uri, &hash(" runtime {}\n")));
assert_eq!(matcher.stale_entries().count(), 0);
}
#[test]
fn round_trip() {
let dir = tempfile::tempdir().unwrap();
let baseline_path = dir.path().join("sprocket-baseline.toml");
write_source(dir.path(), "tasks/align.wdl", "stub");
let mut baseline = Baseline::new(vec![BaselineEntry::new(
"MissingRuntime",
"tasks/align.wdl",
hash(" runtime {}\n"),
)]);
baseline.sort();
baseline.write(&baseline_path).unwrap();
let loaded = Baseline::load(&baseline_path).unwrap();
assert_eq!(loaded.entries().len(), 1);
assert_eq!(loaded.entries()[0].rule(), "MissingRuntime");
}
#[test]
fn load_returns_error_for_missing_file() {
let err = Baseline::load(Path::new("/nonexistent/baseline.toml")).unwrap_err();
assert!(err.is_not_found());
}
#[test]
fn load_or_default_returns_none_when_missing_and_not_required() {
let result = Baseline::load_or_default(Path::new("/nonexistent/baseline.toml"), false)
.expect("missing optional baseline should be `Ok(None)`");
assert!(result.is_none());
}
#[test]
fn load_or_default_returns_error_when_missing_and_required() {
let err = Baseline::load_or_default(Path::new("/nonexistent/baseline.toml"), true)
.expect_err("missing required baseline should error");
assert!(err.is_not_found());
}
#[test]
fn load_or_default_propagates_parse_errors() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sprocket-baseline.toml");
std::fs::write(&path, "not valid toml = = =").unwrap();
let err = Baseline::load_or_default(&path, false)
.expect_err("malformed TOML should error even when not required");
assert!(!err.is_not_found());
assert!(matches!(err, Error::Parse { .. }));
}
#[test]
fn stale_entries_reported_when_unmatched() {
let dir = tempfile::tempdir().unwrap();
let a = write_source(dir.path(), "a.wdl", "stub");
write_source(dir.path(), "b.wdl", "stub");
let baseline = Baseline::new(vec![
BaselineEntry::new("RuleA", "a.wdl", hash("content a")),
BaselineEntry::new("RuleB", "b.wdl", hash("content b")),
])
.with_base_dir(dir.path().to_path_buf());
let mut matcher = baseline.matcher();
matcher.matches_entry("RuleA", &doc_ref(&a), &hash("content a"));
let stale: Vec<_> = matcher.stale_entries().collect();
assert_eq!(stale.len(), 1);
assert_eq!(stale[0].rule(), "RuleB");
}
#[test]
fn no_stale_entries_when_all_matched() {
let dir = tempfile::tempdir().unwrap();
let a = write_source(dir.path(), "a.wdl", "stub");
let baseline = Baseline::new(vec![BaselineEntry::new("RuleA", "a.wdl", hash("content"))])
.with_base_dir(dir.path().to_path_buf());
let mut matcher = baseline.matcher();
matcher.matches_entry("RuleA", &doc_ref(&a), &hash("content"));
assert_eq!(matcher.stale_entries().count(), 0);
}
#[test]
fn surplus_duplicate_entries_are_stale() {
let dir = tempfile::tempdir().unwrap();
let a = write_source(dir.path(), "a.wdl", "stub");
let baseline = Baseline::new(vec![
BaselineEntry::new("RuleA", "a.wdl", hash("content")),
BaselineEntry::new("RuleA", "a.wdl", hash("content")),
])
.with_base_dir(dir.path().to_path_buf());
let mut matcher = baseline.matcher();
assert!(matcher.matches_entry("RuleA", &doc_ref(&a), &hash("content")));
let stale: Vec<_> = matcher.stale_entries().collect();
assert_eq!(
stale.len(),
1,
"one surplus `RuleA` entry should be stale; got: {count}",
count = stale.len()
);
assert_eq!(stale[0].rule(), "RuleA");
}
#[test]
fn surplus_diagnostics_beyond_duplicate_entries_are_not_suppressed() {
let dir = tempfile::tempdir().unwrap();
let a = write_source(dir.path(), "a.wdl", "stub");
let baseline = Baseline::new(vec![
BaselineEntry::new("RuleA", "a.wdl", hash("content")),
BaselineEntry::new("RuleA", "a.wdl", hash("content")),
])
.with_base_dir(dir.path().to_path_buf());
let mut matcher = baseline.matcher();
assert!(matcher.matches_entry("RuleA", &doc_ref(&a), &hash("content")));
assert!(matcher.matches_entry("RuleA", &doc_ref(&a), &hash("content")));
assert!(
!matcher.matches_entry("RuleA", &doc_ref(&a), &hash("content")),
"third `RuleA` diagnostic should not be suppressed once both entries are marked"
);
let stale: Vec<_> = matcher.stale_entries().collect();
assert!(
stale.is_empty(),
"both duplicate entries should be marked; got stale: {stale:?}",
);
}
#[test]
fn sort_is_deterministic() {
let mut baseline = Baseline::new(vec![
BaselineEntry::new("RuleB", "z.wdl", hash("content")),
BaselineEntry::new("RuleA", "a.wdl", hash("content")),
BaselineEntry::new("RuleA", "a.wdl", hash("other")),
]);
baseline.sort();
let entries = baseline.entries();
assert_eq!(entries[0].path(), "a.wdl");
assert_eq!(entries[0].rule(), "RuleA");
assert_eq!(entries[2].path(), "z.wdl");
}
#[test]
fn matches_entry_with_full_uri_path() {
let baseline = Baseline::new(vec![BaselineEntry::new(
"ContainerUri",
"https://example.com/lib/foo.wdl",
hash("task foo {}"),
)]);
let mut matcher = baseline.matcher();
let uri = Url::parse("https://example.com/lib/foo.wdl").unwrap();
assert!(matcher.matches_entry("ContainerUri", &uri, &hash("task foo {}")));
}
#[test]
fn unresolvable_entry_never_matches() {
let dir = tempfile::tempdir().unwrap();
let baseline = Baseline::new(vec![BaselineEntry::new(
"RuleA",
"missing-file.wdl",
hash("content"),
)])
.with_base_dir(dir.path().to_path_buf());
let mut matcher = baseline.matcher();
let uri = Url::parse("file:///whatever/missing-file.wdl").unwrap();
assert!(!matcher.matches_entry("RuleA", &uri, &hash("content")));
}
}