use crate::rule::frx::{parse_runtime_rules, source_format, strip_source_bom, SourceFormat};
use crate::rules_list::RulesEntry;
use crate::runtime_matcher::RuntimeRules;
use anyhow::{anyhow, Result};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
struct Present {
bytes: Vec<u8>,
text: String,
}
struct ReadEntry {
path: PathBuf,
present: Option<Present>,
}
pub(crate) struct RulesSnapshot {
entries: Vec<ReadEntry>,
text: String,
}
impl RulesSnapshot {
pub(crate) fn read(entries: &[RulesEntry]) -> Result<Self> {
let mut read_entries: Vec<ReadEntry> = Vec::with_capacity(entries.len());
for entry in entries {
match std::fs::read(&entry.path) {
Ok(bytes) => {
let text: String = String::from_utf8(bytes.clone()).map_err(|_| {
return anyhow!(
"read rules {}: stream did not contain valid UTF-8",
entry.path.display(),
);
})?;
read_entries.push(ReadEntry {
path: entry.path.clone(),
present: Some(Present { bytes, text }),
});
}
Err(error) if entry.optional && error.kind() == std::io::ErrorKind::NotFound => {
read_entries.push(ReadEntry { path: entry.path.clone(), present: None });
}
Err(error) => return Err(anyhow!("read rules {}: {}", entry.path.display(), error)),
}
}
let text: String = join(&present_files(&read_entries))?;
return Ok(Self { entries: read_entries, text });
}
pub(crate) fn text(&self) -> &str {
return &self.text;
}
pub(crate) fn is_empty(&self) -> bool {
return self.entries.iter().all(|entry| return entry.present.is_none());
}
pub(crate) fn skipped(&self) -> Vec<PathBuf> {
return self
.entries
.iter()
.filter(|entry| return entry.present.is_none())
.map(|entry| return entry.path.clone())
.collect();
}
pub(crate) fn require_present(&self) -> Result<()> {
if !self.is_empty() {
return Ok(());
}
let listed: Vec<String> =
self.entries.iter().map(|entry| return entry.path.display().to_string()).collect();
return Err(anyhow!(
"rules {}: no rules loaded, because every entry is optional and none of those files exists",
listed.join(", "),
));
}
pub(crate) fn label(&self) -> String {
let names: Vec<String> = self
.entries
.iter()
.filter(|entry| return entry.present.is_some())
.map(|entry| return entry.path.display().to_string())
.collect();
return names.join(", ");
}
pub(crate) fn still_current(&self) -> bool {
return self.entries.iter().all(|entry| match (&entry.present, std::fs::read(&entry.path)) {
(Some(expected), Ok(now)) => return expected.bytes == now,
(None, Err(error)) => return error.kind() == std::io::ErrorKind::NotFound,
_ => return false,
});
}
pub(crate) fn compile(&self) -> Result<RuntimeRules> {
match RuntimeRules::compile(&self.text) {
Ok(compiled) => return Ok(compiled),
Err(joined_error) => {
let attributed: Option<anyhow::Error> = self.attribute_failure();
return Err(attributed.unwrap_or_else(|| {
return anyhow!("rules {}: {}", self.label(), joined_error);
}));
}
}
}
fn attribute_failure(&self) -> Option<anyhow::Error> {
let present: Vec<(&Path, &str)> = present_files(&self.entries);
if present.len() < 2 {
return None;
}
let mut declared_by: HashMap<String, &Path> = HashMap::new();
for (path, text) in &present {
if source_format(text) == SourceFormat::Empty {
continue;
}
if let Err(error) = RuntimeRules::compile(text) {
return Some(anyhow!("rules {}: {}", path.display(), error));
}
let Ok(inputs) = parse_runtime_rules(strip_source_bom(text)) else {
continue;
};
for input in inputs {
let Some(name) = input.name else {
continue;
};
if let Some(first) = declared_by.get(&name) {
return Some(anyhow!(
"rules {}: section name '{}' is already declared by {}",
path.display(),
name,
first.display(),
));
}
declared_by.insert(name, path);
}
}
return None;
}
}
fn present_files(entries: &[ReadEntry]) -> Vec<(&Path, &str)> {
return entries
.iter()
.filter_map(|entry| {
return entry.present.as_ref().map(|file| return (entry.path.as_path(), file.text.as_str()));
})
.collect();
}
fn join(present: &[(&Path, &str)]) -> Result<String> {
if let [(_, only)] = present {
return Ok((*only).to_string());
}
let mut first_format: Option<(&Path, SourceFormat)> = None;
for (path, text) in present {
let format: SourceFormat = source_format(text);
if format == SourceFormat::Empty {
continue;
}
match first_format {
None => first_format = Some((path, format)),
Some((first_path, expected)) if expected != format => {
return Err(anyhow!(
"rules {}: this file is in the {} format but {} is in the {} format; every file of a rules \
list must use one format, because joining them would reinterpret the later file's lines",
path.display(),
format_name(format),
first_path.display(),
format_name(expected),
));
}
Some(_) => {}
}
}
let mut joined: String = String::new();
for (_, text) in present {
joined.push_str(strip_source_bom(text));
if !joined.ends_with('\n') {
joined.push('\n');
}
}
return Ok(joined);
}
fn format_name(format: SourceFormat) -> &'static str {
match format {
SourceFormat::Tail => return "tail ('==> name <==' sections)",
SourceFormat::Legacy => return "legacy (one rule per line)",
SourceFormat::Empty => return "empty",
}
}
#[cfg(test)]
#[path = "rules_snapshot_tests.rs"]
mod tests;