use std::fmt;
use std::path::Path;
use loftspec::check::hashed;
use loftspec::codec::{self, Site, Sites};
use loftspec::document::Document;
use loftspec::hash::DocumentHashes;
use loftspec::identity::{Key, Label};
use loftspec::ids::ElementId;
use loftspec::lint::Severity;
use loftspec::trace::{
self, Reconciled, TRACE, TestFiles, Trace, TraceFinding, TraceRule, accept, accept_stale,
reconcile,
};
use loftspec::yaml::Location;
use loftspec_store::FileStore;
use serde_json::{Map, Value, json};
use crate::finish::Finished;
use crate::json;
use crate::out::outln;
use crate::stop::Stop;
const SCHEMA: &str = "loft-reconcile/1";
pub struct Options<'a> {
pub fail_on: Option<Severity>,
pub accept: &'a [String],
pub accept_stale: bool,
}
struct Read {
store: FileStore,
document: Document,
sites: Sites,
hashes: DocumentHashes,
trace: Trace,
}
pub fn run(root: &Path, options: &Options<'_>, as_json: bool) -> Finished {
let mut read = match read(root) {
Ok(read) => read,
Err(stop) => return stop.exit(SCHEMA, as_json),
};
let accepted = match accepted(&mut read, options) {
Ok(accepted) => accepted,
Err(stop) => return stop.exit(SCHEMA, as_json),
};
let reconciled = reconcile(
&read.document,
&read.hashes,
&read.trace,
&read.store.test_files(),
);
let severities = reconciled.findings.iter().map(TraceFinding::severity);
let failed = json::fails_on(options.fail_on, severities);
let report = Report {
document: &read.document,
sites: &read.sites,
};
let outcome = if failed { "failed" } else { "passed" };
if as_json {
json::print(&report.document(&reconciled, &accepted, outcome));
} else {
report.print(&reconciled, &accepted);
}
if failed {
Finished::failure(outcome)
} else {
Finished::success(outcome)
}
}
fn read(root: &Path) -> Result<Read, Stop> {
let store = FileStore::open(root)?;
let (document, sites) = codec::read_sited(&store.read_files()?)?;
let hashes = hashed(&document, &sites).map_err(|errors| Stop::invalid(&errors))?;
let trace = trace::read(store.trace()?.as_deref())?;
Ok(Read {
store,
document,
sites,
hashes,
trace,
})
}
fn accepted(read: &mut Read, options: &Options<'_>) -> Result<Vec<Key>, Stop> {
let files = read.store.test_files();
let keys = if options.accept_stale {
accept_stale(&mut read.trace, &read.document, &read.hashes, &files)
} else if options.accept.is_empty() {
Vec::new()
} else {
accept_named(read, options.accept, &files)?
};
if !keys.is_empty() {
let text = trace::write(&read.trace, &read.document);
read.store.write_trace(text)?;
}
Ok(keys)
}
fn accept_named(
read: &mut Read,
names: &[String],
files: &impl TestFiles,
) -> Result<Vec<Key>, Stop> {
let resolved: Vec<(&String, Result<Key, &str>)> = names
.iter()
.map(|name| (name, resolve(&read.document, name)))
.collect();
let keys: Vec<Key> = resolved.iter().filter_map(|(_, key)| key.ok()).collect();
let refusals = accept(&mut read.trace, &keys, &read.document, &read.hashes, files)
.err()
.unwrap_or_default();
let messages: Vec<String> = resolved
.iter()
.filter_map(|(name, key)| {
let why = match key {
Err(why) => (*why).to_owned(),
Ok(key) => refusals
.iter()
.find(|refusal| refusal.key() == *key)?
.to_string(),
};
Some(format!("cannot accept {name}: {why}"))
})
.collect();
if messages.is_empty() {
Ok(keys)
} else {
Err(Stop::all("accept", messages))
}
}
fn resolve(document: &Document, name: &str) -> Result<Key, &'static str> {
if let Ok(key) = Key::parse(name) {
return Ok(key);
}
let label = Label::parse(name).map_err(|_| "it is neither a key nor a label")?;
document
.bindings
.resolve(label)
.map(ElementId::key)
.ok_or("no node of the tree carries this label")
}
enum Place<'a> {
Node(&'a Site),
Link(Option<Location>),
}
impl Place<'_> {
fn fields(&self) -> Map<String, Value> {
match self {
Self::Node(site) => json::site(site),
Self::Link(location) => json::site_at(&TRACE, *location),
}
}
}
impl fmt::Display for Place<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Node(site) => site.fmt(formatter),
Self::Link(Some(location)) => write!(formatter, "{TRACE}:{location}"),
Self::Link(None) => formatter.write_str(TRACE),
}
}
}
struct Report<'a> {
document: &'a Document,
sites: &'a Sites,
}
impl Report<'_> {
fn label(&self, key: Key) -> Option<String> {
let label = self.document.bindings.label_of_key(key)?;
Some(label.to_string())
}
fn name(&self, key: Key) -> String {
self.label(key).unwrap_or_else(|| key.to_string())
}
fn place(&self, finding: &TraceFinding) -> Place<'_> {
match self.sites.get(finding.node) {
Some(site) => Place::Node(site),
None => Place::Link(finding.link.map(Location::At)),
}
}
fn print(&self, reconciled: &Reconciled, accepted: &[Key]) {
for key in accepted {
outln!("accepted {}", self.name(*key));
}
for finding in &reconciled.findings {
let label = self.label(finding.node).unwrap_or_else(|| "-".to_owned());
outln!(
"{}: {} {}: {label}: {}",
self.place(finding),
finding.severity(),
finding.rule,
finding.message
);
}
outln!("{}", summary(reconciled));
}
fn document(&self, reconciled: &Reconciled, accepted: &[Key], outcome: &str) -> Value {
let findings: Vec<Value> = reconciled
.findings
.iter()
.map(|finding| {
let mut fields = self.place(finding).fields();
fields.insert("rule".to_owned(), json!(finding.rule.as_str()));
fields.insert("severity".to_owned(), json!(finding.severity().as_str()));
fields.insert("key".to_owned(), json!(finding.node.to_string()));
fields.insert("label".to_owned(), json!(self.label(finding.node)));
fields.insert("message".to_owned(), json!(finding.message));
if let Some(test) = &finding.test {
fields.insert(
"test".to_owned(),
json!({ "file": test.file, "name": test.name }),
);
}
Value::Object(fields)
})
.collect();
let accepted: Vec<String> = accepted.iter().map(ToString::to_string).collect();
let fields = json!({
"findings": findings,
"inScope": reconciled.in_scope,
"linked": reconciled.linked,
"accepted": accepted,
});
json::result(SCHEMA, outcome, fields, Vec::new())
}
}
fn summary(reconciled: &Reconciled) -> String {
let counts: Vec<String> = TraceRule::ALL
.iter()
.map(|rule| {
let count = reconciled
.findings
.iter()
.filter(|finding| finding.rule == *rule)
.count();
format!("{rule} {count}")
})
.collect();
format!(
"{} of {} nodes in scope linked; {}",
reconciled.linked,
reconciled.in_scope,
counts.join(", ")
)
}