use std::path::{Path, PathBuf};
use crate::checker::observe::UnknownEvents;
use crate::test::{ErrorKind, SetupError};
const TABLE: &str = "package.metadata.snappea";
const KEYS: &[&str] = &["spec_files", "specs", "exclude", "strict", "check_hot", "unknown_events", "coverage", "mode"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Auto,
Marked,
Off,
}
impl Mode {
pub(crate) fn parse(s: &str, origin: &str) -> Result<Mode, SetupError> {
match s {
"auto" => Ok(Mode::Auto),
"marked" => Ok(Mode::Marked),
"off" => Ok(Mode::Off),
_ => Err(config(format!("{origin}: mode must be auto, marked or off, not '{s}'"))),
}
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct FileConfig {
pub dir: PathBuf,
pub spec_files: Option<Vec<String>>,
pub specs: Option<Vec<String>>,
pub exclude: Option<Vec<String>>,
pub strict: Option<bool>,
pub check_hot: Option<bool>,
pub unknown_events: Option<UnknownEvents>,
pub coverage: Option<PathBuf>,
pub mode: Option<Mode>,
}
#[derive(Debug, Clone, PartialEq)]
enum Val {
Str(String),
Bool(bool),
List(Vec<String>),
}
pub(crate) fn config(message: impl Into<String>) -> SetupError {
SetupError { kind: ErrorKind::Config, message: message.into(), artifacts: None }
}
pub(crate) fn read(manifest: &Path) -> Result<FileConfig, SetupError> {
let text =
std::fs::read_to_string(manifest).map_err(|e| config(format!("cannot read {}: {e}", manifest.display())))?;
let at = |e: String| config(format!("{} [{TABLE}]: {e}", manifest.display()));
let mut cfg = FileConfig { dir: manifest.parent().unwrap_or(Path::new(".")).to_path_buf(), ..Default::default() };
for (key, value) in table(&text).map_err(at)? {
let strings = |v: Val| match v {
Val::List(l) => Ok(l),
_ => Err(at(format!("{key} must be a list of strings"))),
};
let boolean = |v: Val| match v {
Val::Bool(b) => Ok(b),
_ => Err(at(format!("{key} must be true or false"))),
};
let string = |v: Val| match v {
Val::Str(s) => Ok(s),
_ => Err(at(format!("{key} must be a string"))),
};
match key.as_str() {
"spec_files" => cfg.spec_files = Some(strings(value)?),
"specs" => cfg.specs = Some(strings(value)?),
"exclude" => cfg.exclude = Some(strings(value)?),
"strict" => cfg.strict = Some(boolean(value)?),
"check_hot" => cfg.check_hot = Some(boolean(value)?),
"unknown_events" => cfg.unknown_events = Some(string(value)?.parse().map_err(at)?),
"coverage" => cfg.coverage = Some(cfg.dir.join(string(value)?)),
"mode" => cfg.mode = Some(Mode::parse(&string(value)?, &manifest.display().to_string())?),
_ => return Err(at(format!("unknown key '{key}'; expected one of {}", KEYS.join(", ")))),
}
}
Ok(cfg)
}
fn table(text: &str) -> Result<Vec<(String, Val)>, String> {
let mut lines = text.lines();
let header = |line: &str| {
let line = strip_comment(line).trim();
let inner = line.strip_prefix('[')?.strip_suffix(']')?;
Some(inner.split('.').map(str::trim).collect::<Vec<_>>().join("."))
};
if !lines.by_ref().any(|l| header(l).as_deref() == Some(TABLE)) {
return Ok(Vec::new());
}
let body: Vec<&str> = lines.take_while(|l| !l.trim_start().starts_with('[')).collect();
let body = body.join("\n");
let mut p = Reader { s: &body, i: 0 };
let mut out: Vec<(String, Val)> = Vec::new();
loop {
p.skip(true);
if p.i >= p.s.len() {
return Ok(out);
}
let key = p.key()?;
p.skip(false);
if !p.eat('=') {
return Err(format!("expected '=' after {key}"));
}
p.skip(false);
let value = p.value()?;
if out.iter().any(|(k, _)| *k == key) {
return Err(format!("{key} is set twice"));
}
out.push((key, value));
p.skip(false);
if p.i < p.s.len() && !p.eat('\n') {
return Err("expected a new line after a value".into());
}
}
}
fn strip_comment(line: &str) -> &str {
let mut quote = None;
for (i, c) in line.char_indices() {
match (quote, c) {
(None, '#') => return &line[..i],
(None, '"' | '\'') => quote = Some(c),
(Some(q), c) if c == q => quote = None,
_ => {}
}
}
line
}
struct Reader<'a> {
s: &'a str,
i: usize,
}
impl Reader<'_> {
fn peek(&self) -> Option<char> {
self.s[self.i..].chars().next()
}
fn eat(&mut self, c: char) -> bool {
if self.peek() == Some(c) {
self.i += c.len_utf8();
true
} else {
false
}
}
fn skip(&mut self, newlines: bool) {
while let Some(c) = self.peek() {
if c == '#' {
while self.peek().is_some_and(|c| c != '\n') {
self.i += self.peek().unwrap().len_utf8();
}
} else if c == ' ' || c == '\t' || c == '\r' || (newlines && c == '\n') {
self.i += 1;
} else {
break;
}
}
}
fn key(&mut self) -> Result<String, String> {
if matches!(self.peek(), Some('"' | '\'')) {
return self.string();
}
let start = self.i;
while self.peek().is_some_and(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
self.i += 1;
}
if start == self.i {
return Err(format!("expected a key at '{}'", self.s[self.i..].lines().next().unwrap_or("")));
}
Ok(self.s[start..self.i].to_string())
}
fn string(&mut self) -> Result<String, String> {
let quote = self.peek().unwrap();
self.i += 1;
let mut out = String::new();
loop {
let c = self.peek().ok_or("unterminated string")?;
self.i += c.len_utf8();
match c {
'\n' => return Err("unterminated string".into()),
c if c == quote => return Ok(out),
'\\' if quote == '"' => {
let e = self.peek().ok_or("unterminated string")?;
self.i += e.len_utf8();
out.push(match e {
'n' => '\n',
't' => '\t',
'"' | '\\' => e,
_ => return Err(format!("unsupported escape \\{e}")),
});
}
c => out.push(c),
}
}
}
fn value(&mut self) -> Result<Val, String> {
match self.peek() {
Some('"' | '\'') => Ok(Val::Str(self.string()?)),
Some('[') => {
self.i += 1;
let mut items = Vec::new();
loop {
self.skip(true);
if self.eat(']') {
return Ok(Val::List(items));
}
match self.peek() {
Some('"' | '\'') => items.push(self.string()?),
_ => return Err("lists may only hold strings".into()),
}
self.skip(true);
if !self.eat(',') && self.peek() != Some(']') {
return Err("expected ',' or ']' in a list".into());
}
}
}
_ if self.s[self.i..].starts_with("true") => {
self.i += 4;
Ok(Val::Bool(true))
}
_ if self.s[self.i..].starts_with("false") => {
self.i += 5;
Ok(Val::Bool(false))
}
_ => Err("values must be strings, true/false, or lists of strings".into()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_only_the_snappea_table() {
let text = r#"
[package]
name = "x" # [package.metadata.snappea] in a comment
[package.metadata.snappea]
spec_files = [
"specs/a.p", # first
'specs/*.p',
]
specs = ["A"]
strict = false
unknown_events = "warn" # comment
[dependencies]
specs = 3
"#;
let t = table(text).unwrap();
assert_eq!(t[0], ("spec_files".into(), Val::List(vec!["specs/a.p".into(), "specs/*.p".into()])));
assert_eq!(t[1], ("specs".into(), Val::List(vec!["A".into()])));
assert_eq!(t[2], ("strict".into(), Val::Bool(false)));
assert_eq!(t[3], ("unknown_events".into(), Val::Str("warn".into())));
assert_eq!(t.len(), 4);
assert!(table("[package]\nname = 'x'\n").unwrap().is_empty());
assert!(table("[package.metadata.snappea]\nspecs = [1]\n").is_err());
assert!(table("[package.metadata.snappea]\nspecs = ['a'] strict = true\n").is_err());
}
}