use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::editorial::{EditorialFinding, EditorialReport, Severity};
pub mod store;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Direction {
Better,
Worse,
Same,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct TrendDelta {
pub key: String,
pub old: i64,
pub new: i64,
pub direction: Direction,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct Trend {
pub headline: Vec<TrendDelta>,
pub categories: Vec<TrendDelta>,
pub old_intensity: Option<f32>,
pub new_intensity: Option<f32>,
}
fn direction(old: i64, new: i64) -> Direction {
use std::cmp::Ordering::*;
match new.cmp(&old) {
Less => Direction::Better,
Greater => Direction::Worse,
Equal => Direction::Same,
}
}
fn delta(key: &str, old: usize, new: usize) -> TrendDelta {
TrendDelta { key: key.to_string(), old: old as i64, new: new as i64, direction: direction(old as i64, new as i64) }
}
fn worse_first(d: Direction) -> u8 {
match d {
Direction::Worse => 0,
Direction::Better => 1,
Direction::Same => 2,
}
}
pub fn diff_vectors(old: &MetricVector, new: &MetricVector) -> Trend {
let headline = vec![
delta("findings", old.total, new.total),
delta("errors", old.errors, new.errors),
delta("warnings", old.warnings, new.warnings),
delta("infos", old.infos, new.infos),
];
let keys: BTreeSet<&String> = old.by_category.keys().chain(new.by_category.keys()).collect();
let mut categories: Vec<TrendDelta> = keys
.into_iter()
.filter_map(|k| {
let o = old.by_category.get(k).copied().unwrap_or(0);
let n = new.by_category.get(k).copied().unwrap_or(0);
(o != n).then(|| delta(k, o, n))
})
.collect();
categories.sort_by(|a, b| {
worse_first(a.direction)
.cmp(&worse_first(b.direction))
.then((b.new - b.old).abs().cmp(&(a.new - a.old).abs()))
.then(a.key.cmp(&b.key))
});
Trend { headline, categories, old_intensity: old.mean_intensity, new_intensity: new.mean_intensity }
}
fn sev_word(s: Severity) -> &'static str {
match s {
Severity::Error => "error",
Severity::Warn => "warn",
Severity::Info => "info",
}
}
pub fn summarise(report: &EditorialReport) -> (MetricVector, Vec<FindingRef>) {
let mut m = MetricVector {
total: report.findings.len(),
errors: report.errors,
warnings: report.warnings,
infos: report.infos,
deferred: report.deferred,
stale: report.stale,
..Default::default()
};
for f in &report.findings {
*m.by_category.entry(f.category.clone()).or_insert(0) += 1;
*m.by_response.entry(f.response().label().to_string()).or_insert(0) += 1;
*m.by_source.entry(f.source.to_string()).or_insert(0) += 1;
}
m.sag_count = m.by_category.get("shape_sag").copied().unwrap_or(0);
let refs = report.findings.iter().map(finding_ref).collect();
(m, refs)
}
fn finding_ref(f: &EditorialFinding) -> FindingRef {
FindingRef {
fingerprint: f.fingerprint(),
category: f.category.clone(),
severity: sev_word(f.severity).to_string(),
location: Some(f.location.label()).filter(|s| s.as_str() != "—"),
paragraph: f.location.paragraph,
}
}
pub fn capture(project: &Path, book_name: Option<&str>) -> crate::error::Result<(MetricVector, Vec<FindingRef>)> {
let report = crate::cli::editorial::collect(project, book_name, None, false)?;
Ok(summarise(&report))
}
pub fn record(
project: &Path,
label: &str,
book_name: Option<&str>,
book_slug: Option<String>,
git_ref: Option<String>,
) -> crate::error::Result<MetricVector> {
let (metrics, findings) = capture(project, book_name)?;
let milestone = Milestone {
id: Uuid::new_v4(),
label: label.to_string(),
day: crate::dayclock::today_days(),
ts: crate::dayclock::now_secs(),
book_slug,
git_ref,
metrics: metrics.clone(),
};
let store = store::ChronicleStore::open_for_project(project)
.map_err(|e| crate::error::Error::Store(e.to_string()))?;
store
.insert_milestone(&milestone, &findings)
.map_err(|e| crate::error::Error::Store(e.to_string()))?;
Ok(metrics)
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct MetricVector {
pub total: usize,
pub errors: usize,
pub warnings: usize,
pub infos: usize,
#[serde(default)]
pub by_category: BTreeMap<String, usize>,
#[serde(default)]
pub by_response: BTreeMap<String, usize>,
#[serde(default)]
pub by_source: BTreeMap<String, usize>,
pub deferred: usize,
pub stale: bool,
#[serde(default)]
pub mean_intensity: Option<f32>,
#[serde(default)]
pub sag_count: usize,
}
#[derive(Debug, Clone)]
pub struct Milestone {
pub id: Uuid,
pub label: String,
pub day: i64,
pub ts: i64,
pub book_slug: Option<String>,
pub git_ref: Option<String>,
pub metrics: MetricVector,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FindingRef {
pub fingerprint: String,
pub category: String,
pub severity: String,
pub location: Option<String>,
pub paragraph: Option<Uuid>,
}
impl FindingRef {
pub fn is_error(&self) -> bool {
self.severity == "error"
}
pub fn message(&self) -> &str {
self.fingerprint.split('\u{1}').nth(1).unwrap_or(self.fingerprint.as_str())
}
}
#[derive(Debug, Clone, Default)]
pub struct FindingDiff {
pub cleared: Vec<FindingRef>,
pub introduced: Vec<FindingRef>,
pub persisted: Vec<FindingRef>,
}
fn sev_rank(sev: &str) -> u8 {
match sev {
"error" => 0,
"warn" => 1,
_ => 2,
}
}
pub fn diff_findings(old: &[FindingRef], new: &[FindingRef]) -> FindingDiff {
let old_fps: BTreeSet<&str> = old.iter().map(|f| f.fingerprint.as_str()).collect();
let new_fps: BTreeSet<&str> = new.iter().map(|f| f.fingerprint.as_str()).collect();
let mut cleared: Vec<FindingRef> =
old.iter().filter(|f| !new_fps.contains(f.fingerprint.as_str())).cloned().collect();
let mut introduced: Vec<FindingRef> =
new.iter().filter(|f| !old_fps.contains(f.fingerprint.as_str())).cloned().collect();
let persisted: Vec<FindingRef> =
new.iter().filter(|f| old_fps.contains(f.fingerprint.as_str())).cloned().collect();
let by_severity = |v: &mut Vec<FindingRef>| {
v.sort_by(|a, b| {
sev_rank(&a.severity)
.cmp(&sev_rank(&b.severity))
.then(a.category.cmp(&b.category))
.then(a.fingerprint.cmp(&b.fingerprint))
});
};
by_severity(&mut cleared);
by_severity(&mut introduced);
FindingDiff { cleared, introduced, persisted }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn metric_vector_json_round_trips() {
let mut mv = MetricVector { total: 5, errors: 1, warnings: 2, infos: 2, ..Default::default() };
mv.by_category.insert("echo".into(), 2);
mv.by_response.insert("rewrite".into(), 3);
mv.by_source.insert("continuity".into(), 1);
mv.mean_intensity = Some(0.5);
mv.sag_count = 1;
let s = serde_json::to_string(&mv).unwrap();
let back: MetricVector = serde_json::from_str(&s).unwrap();
assert_eq!(mv, back);
}
#[test]
fn summarise_tallies_the_report_by_every_axis() {
use crate::editorial::{Location, Severity};
let mk = |cat: &str, sev: Severity, src: &'static str, para: bool| EditorialFinding {
category: cat.into(),
severity: sev,
location: Location {
chapter: Some("ch. 3".into()),
paragraph: para.then(uuid::Uuid::now_v7),
..Default::default()
},
message: format!("{cat} msg"),
hint: None,
source: src,
autofixable: false,
};
let report = EditorialReport {
findings: vec![
mk("echo", Severity::Warn, "doctor", true), mk("shape_sag", Severity::Info, "read-through", false), mk("co_location", Severity::Error, "continuity", true), ],
errors: 1,
warnings: 1,
infos: 1,
deferred: 2,
stale: true,
};
let (m, refs) = summarise(&report);
assert_eq!(m.total, 3);
assert_eq!((m.errors, m.warnings, m.infos), (1, 1, 1));
assert_eq!(m.deferred, 2);
assert!(m.stale);
assert_eq!(m.by_category.get("echo"), Some(&1));
assert_eq!(m.by_source.get("continuity"), Some(&1));
assert_eq!(m.by_response.get("rewrite"), Some(&1)); assert_eq!(m.by_response.get("decision"), Some(&1)); assert_eq!(m.by_response.get("brief"), Some(&1)); assert_eq!(m.sag_count, 1);
assert_eq!(refs.len(), 3);
let co = refs.iter().find(|r| r.category == "co_location").unwrap();
assert_eq!(co.severity, "error");
assert!(co.paragraph.is_some());
assert_eq!(co.location.as_deref(), Some("ch. 3"));
}
#[test]
fn diff_vectors_polarity_and_regressions_first() {
let mut old = MetricVector { total: 31, errors: 4, warnings: 12, infos: 15, ..Default::default() };
old.by_category.insert("echo".into(), 4);
old.by_category.insert("co_location".into(), 2);
old.by_category.insert("shape_sag".into(), 3);
old.by_category.insert("filter".into(), 1);
let mut new = MetricVector { total: 27, errors: 2, warnings: 11, infos: 14, ..Default::default() };
new.by_category.insert("echo".into(), 2); new.by_category.insert("co_location".into(), 0); new.by_category.insert("shape_sag".into(), 1); new.by_category.insert("filter".into(), 1); new.by_category.insert("confusion".into(), 1);
let t = diff_vectors(&old, &new);
let findings = t.headline.iter().find(|d| d.key == "findings").unwrap();
assert_eq!((findings.old, findings.new), (31, 27));
assert_eq!(findings.direction, Direction::Better);
assert_eq!(t.headline.iter().find(|d| d.key == "errors").unwrap().direction, Direction::Better);
assert_eq!(t.categories.first().unwrap().key, "confusion");
assert_eq!(t.categories.first().unwrap().direction, Direction::Worse);
assert!(t.categories.iter().all(|d| d.key != "filter"), "unchanged category omitted");
let colo = t.categories.iter().find(|d| d.key == "co_location").unwrap();
assert_eq!((colo.old, colo.new, colo.direction), (2, 0, Direction::Better));
}
#[test]
fn diff_vectors_omits_unchanged_categories() {
let mut old = MetricVector::default();
old.by_category.insert("echo".into(), 2);
let mut new = MetricVector::default();
new.by_category.insert("echo".into(), 2);
assert!(diff_vectors(&old, &new).categories.is_empty());
}
fn fref(fp: &str, cat: &str, sev: &str) -> FindingRef {
FindingRef {
fingerprint: fp.into(),
category: cat.into(),
severity: sev.into(),
location: None,
paragraph: None,
}
}
#[test]
fn diff_findings_partitions_and_extracts_message() {
let old = vec![
fref("echo\u{1}about ×5", "echo", "warn"),
fref("co_location\u{1}Mara in two places", "co_location", "error"),
];
let new = vec![
fref("echo\u{1}about ×5", "echo", "warn"), fref("confusion\u{1}who is Cael?", "confusion", "warn"), ];
let d = diff_findings(&old, &new);
assert_eq!(cats(&d.cleared), vec!["co_location"]); assert_eq!(cats(&d.introduced), vec!["confusion"]); assert_eq!(cats(&d.persisted), vec!["echo"]); assert_eq!(d.introduced[0].message(), "who is Cael?");
assert_eq!(d.cleared[0].message(), "Mara in two places");
}
#[test]
fn diff_findings_sorts_errors_first() {
let new = vec![
fref("z\u{1}i", "z-info", "info"),
fref("a\u{1}e", "a-error", "error"),
fref("m\u{1}w", "m-warn", "warn"),
];
let d = diff_findings(&[], &new);
assert_eq!(
d.introduced.iter().map(|f| f.severity.as_str()).collect::<Vec<_>>(),
vec!["error", "warn", "info"]
);
}
fn cats(v: &[FindingRef]) -> Vec<&str> {
v.iter().map(|f| f.category.as_str()).collect()
}
#[test]
fn finding_ref_error_gate() {
let mk = |sev: &str| FindingRef {
fingerprint: "k".into(),
category: "c".into(),
severity: sev.into(),
location: None,
paragraph: None,
};
assert!(mk("error").is_error());
assert!(!mk("warn").is_error());
assert!(!mk("info").is_error());
}
}