use std::collections::BTreeMap;
use serde::Serialize;
use serde_json::{Value, json};
use crate::paths::ProjectPath;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Classification {
Resource,
Document,
Manifest,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SurfaceEntry {
pub classification: Classification,
pub digest: String,
pub bytes: u64,
}
impl SurfaceEntry {
#[must_use]
pub fn new(classification: Classification, bytes: &[u8]) -> Self {
Self {
classification,
digest: digest(bytes),
bytes: bytes.len() as u64,
}
}
}
pub type Surface = BTreeMap<ProjectPath, SurfaceEntry>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
Added,
Removed,
Modified,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Change {
pub path: ProjectPath,
pub kind: Kind,
pub before: Option<SurfaceEntry>,
pub after: Option<SurfaceEntry>,
}
impl Change {
#[must_use]
pub fn view(&self) -> Value {
json!({
"path": self.path.as_str(),
"change": self.kind,
"before": self.before,
"after": self.after,
})
}
}
#[must_use]
pub fn digest(bytes: &[u8]) -> String {
format!("blake3:{}", blake3::hash(bytes).to_hex())
}
#[must_use]
pub fn between(before: &Surface, after: &Surface) -> Vec<Change> {
let mut paths: Vec<&ProjectPath> = before.keys().chain(after.keys()).collect();
paths.sort();
paths.dedup();
paths
.into_iter()
.filter_map(|path| {
let (old, new) = (before.get(path), after.get(path));
let kind = match (old, new) {
(None, Some(_)) => Kind::Added,
(Some(_), None) => Kind::Removed,
(Some(old), Some(new)) if old != new => Kind::Modified,
_ => return None,
};
Some(Change {
path: path.clone(),
kind,
before: old.cloned(),
after: new.cloned(),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn surface(entries: &[(&str, Classification, &[u8])]) -> Surface {
entries
.iter()
.map(|(path, classification, bytes)| {
(
ProjectPath::parse(path).unwrap(),
SurfaceEntry::new(*classification, bytes),
)
})
.collect()
}
#[test]
fn equal_surfaces_have_no_changes() {
let a = surface(&[
("bearout.toml", Classification::Manifest, b"m"),
("records/a.md", Classification::Resource, b"a"),
("docs/x.md", Classification::Document, b"x"),
]);
assert!(between(&a, &a).is_empty());
assert!(between(&Surface::new(), &Surface::new()).is_empty());
}
#[test]
fn additions_removals_modifications_and_reclassifications_sort_by_path() {
let before = surface(&[
("bearout.toml", Classification::Manifest, b"m"),
("records/a.md", Classification::Resource, b"a"),
("records/gone.md", Classification::Resource, b"g"),
("notes/n.md", Classification::Document, b"n"),
]);
let after = surface(&[
("bearout.toml", Classification::Manifest, b"m2"),
("records/a.md", Classification::Resource, b"a"),
("records/new.md", Classification::Resource, b"g"),
("notes/n.md", Classification::Resource, b"n"),
]);
let changes = between(&before, &after);
let summary: Vec<(&str, Kind)> = changes
.iter()
.map(|change| (change.path.as_str(), change.kind))
.collect();
assert_eq!(
summary,
[
("bearout.toml", Kind::Modified),
("notes/n.md", Kind::Modified),
("records/gone.md", Kind::Removed),
("records/new.md", Kind::Added),
]
);
let reclassified = &changes[1];
assert_eq!(
reclassified.before.as_ref().unwrap().classification,
Classification::Document
);
assert_eq!(
reclassified.after.as_ref().unwrap().classification,
Classification::Resource
);
assert_eq!(
reclassified.before.as_ref().unwrap().digest,
reclassified.after.as_ref().unwrap().digest,
"same bytes, different classification"
);
assert!(changes[2].after.is_none());
assert!(changes[3].before.is_none());
let json = changes[3].view();
assert_eq!(json["change"], "added");
assert_eq!(json["before"], Value::Null);
assert_eq!(json["after"]["classification"], "resource");
assert_eq!(json["after"]["bytes"], 1);
assert!(
json["after"]["digest"]
.as_str()
.unwrap()
.starts_with("blake3:")
);
}
}