use headwater_doc::Document;
use headwater_yaml::Mapping;
use std::path::Path;
pub const FORMAT: &str = "headwater change 1";
#[derive(Clone, Debug)]
enum Held {
Added,
Committed { digest: String, document: Document },
Unreadable { why: String },
Unmatched { prior: Option<(String, Document)> },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Prior<'a> {
Unchanged,
Added,
Committed {
digest: &'a str,
facets: &'a Mapping,
},
}
impl Prior<'_> {
pub(crate) fn key(&self) -> String {
match self {
Prior::Unchanged => "unchanged".to_string(),
Prior::Added => "added".to_string(),
Prior::Committed { digest, .. } => format!("committed {digest}"),
}
}
}
#[derive(Clone, Debug)]
pub struct Unbound {
entries: Vec<(String, Held)>,
}
#[derive(Clone, Debug, Default)]
pub struct Change {
entries: Vec<(String, Held)>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Named {
pub documents: usize,
pub added: usize,
pub carried: usize,
pub unreadable: usize,
pub unmatched: usize,
}
impl Unbound {
pub fn read(
manifest: &str,
open: impl Fn(&Path) -> std::io::Result<Vec<u8>>,
) -> Result<Unbound, String> {
let mut lines = manifest.lines();
match lines.next() {
Some(FORMAT) => {}
Some(other) => {
return Err(format!(
"a change manifest opens with `{FORMAT}`, and this one opens with `{other}`"
))
}
None => {
return Err(format!(
"a change manifest opens with `{FORMAT}`, and this one is empty"
))
}
}
let mut entries: Vec<(String, Held)> = Vec::new();
for (number, line) in lines.enumerate() {
let number = number + 2;
if line.trim().is_empty() {
continue;
}
let mut fields = line.split('\t');
let (path, held) = match (fields.next(), fields.next(), fields.next(), fields.next()) {
(Some("added"), Some(path), None, _) => (path, Held::Added),
(Some("prior"), Some(path), Some(source), None) => {
(path, read_prior(source, &open))
}
_ => {
return Err(format!(
"line {number} of the change manifest is neither `added\\t<path>` nor \
`prior\\t<path>\\t<file>`: `{line}`"
))
}
};
if entries.iter().any(|(known, _)| known == path) {
return Err(format!(
"the change manifest names `{path}` twice, and a document has one prior version"
));
}
entries.push((path.to_string(), held));
}
entries.sort_by(|(a, _), (b, _)| a.cmp(b));
Ok(Unbound { entries })
}
pub fn at(manifest: &Path) -> Result<Unbound, String> {
let text = std::fs::read_to_string(manifest)
.map_err(|error| format!("{}: {error}", manifest.display()))?;
Unbound::read(&text, |path| std::fs::read(path))
}
pub fn bind(self, holds: impl Fn(&str) -> bool) -> Change {
Change {
entries: self
.entries
.into_iter()
.map(|(path, held)| match (holds(&path), held) {
(true, held) => (path, held),
(false, Held::Committed { digest, document }) => (
path,
Held::Unmatched {
prior: Some((digest, document)),
},
),
(false, _) => (path, Held::Unmatched { prior: None }),
})
.collect(),
}
}
pub fn paths(&self) -> Vec<&str> {
self.entries.iter().map(|(path, _)| path.as_str()).collect()
}
}
impl Change {
pub fn named(&self) -> Named {
let mut named = Named {
documents: self.entries.len(),
..Named::default()
};
for (_, held) in &self.entries {
match held {
Held::Added => named.added += 1,
Held::Committed { .. } => named.carried += 1,
Held::Unreadable { .. } => named.unreadable += 1,
Held::Unmatched { .. } => named.unmatched += 1,
}
}
named
}
pub fn unmatched(&self) -> Vec<&str> {
self.entries
.iter()
.filter(|(_, held)| matches!(held, Held::Unmatched { .. }))
.map(|(path, _)| path.as_str())
.collect()
}
pub fn departed(&self) -> Vec<Departed<'_>> {
self.entries
.iter()
.filter_map(|(path, held)| match held {
Held::Unmatched {
prior: Some((digest, document)),
} => Some(Departed {
path: path.as_str(),
digest: digest.as_str(),
facets: &document.facets,
}),
_ => None,
})
.collect()
}
pub(crate) fn prior_of(&self, path: &str) -> Result<Prior<'_>, &str> {
let Ok(index) = self
.entries
.binary_search_by(|(known, _)| known.as_str().cmp(path))
else {
return Ok(Prior::Unchanged);
};
match &self.entries[index].1 {
Held::Added => Ok(Prior::Added),
Held::Committed { digest, document } => Ok(Prior::Committed {
digest,
facets: &document.facets,
}),
Held::Unreadable { why } => Err(why),
Held::Unmatched { .. } => Err(UNMATCHED),
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct Departed<'a> {
pub path: &'a str,
pub digest: &'a str,
pub facets: &'a Mapping,
}
const UNMATCHED: &str = "the change manifest names this path and this corpus holds no row at it";
fn read_prior(source: &str, open: impl Fn(&Path) -> std::io::Result<Vec<u8>>) -> Held {
let bytes = match open(Path::new(source)) {
Ok(bytes) => bytes,
Err(error) => {
return Held::Unreadable {
why: format!("the prior version at `{source}` did not open: {error}"),
}
}
};
let digest = headwater_hash::digest(&bytes);
let Ok(text) = String::from_utf8(bytes) else {
return Held::Unreadable {
why: format!("the prior version at `{source}` is not UTF-8"),
};
};
match headwater_doc::parse(&text) {
Ok(document) => Held::Committed { digest, document },
Err(errors) => Held::Unreadable {
why: format!(
"the prior version at `{source}` did not parse: {} error(s)",
errors.len()
),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tree<'a>(
files: &'a [(&'a str, &'a str)],
) -> impl Fn(&Path) -> std::io::Result<Vec<u8>> + 'a {
move |path| {
files
.iter()
.find(|(name, _)| Path::new(name) == path)
.map(|(_, text)| text.as_bytes().to_vec())
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"))
}
}
const ASSERTED: &str = "---\nid: A\nprovenance:\n warrant: asserted\n---\n\n# A\n";
fn every(_: &str) -> bool {
true
}
fn manifest() -> String {
format!("{FORMAT}\nadded\tdocs/b.md\nprior\tdocs/a.md\tprior/a.md\n")
}
#[test]
fn a_change_holds_three_states_and_refuses_to_hand_over_the_fourth() {
let change = Unbound::read(&manifest(), tree(&[("prior/a.md", ASSERTED)]))
.expect("a change")
.bind(every);
assert!(matches!(
change.prior_of("docs/a.md"),
Ok(Prior::Committed { .. })
));
assert_eq!(change.prior_of("docs/b.md"), Ok(Prior::Added));
assert_eq!(change.prior_of("docs/c.md"), Ok(Prior::Unchanged));
let missing = Unbound::read(&manifest(), tree(&[]))
.expect("a change")
.bind(every);
let why = missing.prior_of("docs/a.md").expect_err("a reason");
assert!(why.contains("prior/a.md"), "{why}");
}
#[test]
fn every_state_of_a_prior_version_keys_apart() {
let keys = [
Prior::Unchanged.key(),
Prior::Added.key(),
Prior::Committed {
digest: "sha256:one",
facets: &Mapping::default(),
}
.key(),
Prior::Committed {
digest: "sha256:two",
facets: &Mapping::default(),
}
.key(),
];
for (index, key) in keys.iter().enumerate() {
for other in &keys[index + 1..] {
assert_ne!(key, other, "two states of the prior version share a key");
}
}
}
#[test]
fn a_manifest_this_reader_cannot_parse_is_refused() {
for manifest in [
"",
"headwater change 2\nadded\tdocs/a.md\n",
&format!("{FORMAT}\nremoved\tdocs/a.md\n"),
&format!("{FORMAT}\nadded\n"),
&format!("{FORMAT}\nadded\tdocs/a.md\textra\n"),
&format!("{FORMAT}\nprior\tdocs/a.md\n"),
&format!("{FORMAT}\nprior\tdocs/a.md\tprior/a.md\textra\n"),
&format!("{FORMAT}\nadded\tdocs/a.md\nadded\tdocs/a.md\n"),
] {
assert!(
Unbound::read(manifest, tree(&[("prior/a.md", ASSERTED)])).is_err(),
"read as a change: {manifest:?}"
);
}
}
#[test]
fn a_change_states_what_it_named() {
let manifest = format!(
"{FORMAT}\nadded\tdocs/b.md\nprior\tdocs/a.md\tprior/a.md\nprior\tdocs/c.md\tgone.md\n"
);
let change = Unbound::read(&manifest, tree(&[("prior/a.md", ASSERTED)]))
.expect("a change")
.bind(every);
assert_eq!(
change.named(),
Named {
documents: 3,
added: 1,
carried: 1,
unreadable: 1,
unmatched: 0,
}
);
}
#[test]
fn a_path_no_row_holds_binds_to_nothing_and_says_so() {
let unbound =
|| Unbound::read(&manifest(), tree(&[("prior/a.md", ASSERTED)])).expect("a change");
assert_eq!(unbound().paths(), vec!["docs/a.md", "docs/b.md"]);
let bound = unbound().bind(|path| path == "docs/b.md");
assert_eq!(
bound.named(),
Named {
documents: 2,
added: 1,
carried: 0,
unreadable: 0,
unmatched: 1,
}
);
assert_eq!(bound.unmatched(), vec!["docs/a.md"]);
assert!(bound.prior_of("docs/a.md").is_err());
assert_eq!(bound.prior_of("docs/b.md"), Ok(Prior::Added));
}
}