use std::collections::{BTreeMap, BTreeSet, HashMap};
use super::Side;
use crate::site::{DocClass, Kind};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(super) enum Status {
Del,
Add,
Mod,
Move,
}
pub(super) struct Item {
pub status: Status,
pub base: Option<String>,
pub head: Option<String>,
pub id: Option<String>,
pub omit_body: bool,
}
impl Item {
pub(super) fn path(&self) -> &str {
self.head.as_deref().or(self.base.as_deref()).unwrap_or("")
}
}
struct Entry<'a> {
path: &'a str,
class: DocClass,
id: Option<&'a str>,
published: bool,
title: &'a str,
description: Option<&'a str>,
body: &'a str,
}
fn entries<'a>(side: &'a Side) -> Vec<Entry<'a>> {
let published = side
.model
.pages
.iter()
.filter(|(_, p)| p.kind != Kind::Index)
.map(|(path, p)| Entry {
path,
class: p.class,
id: p.front_id.as_deref(),
published: true,
title: &p.title,
description: p.description.as_deref(),
body: &p.body,
});
let unpublished = side.candidates.iter().map(|c| Entry {
path: &c.path,
class: c.class,
id: c.id.as_deref(),
published: false,
title: &c.title,
description: c.description.as_deref(),
body: &c.body,
});
let mut all: Vec<Entry> = published.chain(unpublished).collect();
all.sort_by(|a, b| a.path.cmp(b.path));
all
}
fn check_duplicates(entries: &[Entry], label: &str) -> anyhow::Result<()> {
let mut seen: BTreeMap<(DocClass, &str), Vec<&str>> = BTreeMap::new();
for e in entries.iter().filter(|e| e.published) {
if let Some(id) = e.id {
seen.entry((e.class, id)).or_default().push(e.path);
}
}
let lines: Vec<String> = seen
.into_iter()
.filter(|(_, paths)| paths.len() > 1)
.map(|((_, id), paths)| format!("duplicate id {id} in {label}: {}", paths.join(", ")))
.collect();
if lines.is_empty() {
Ok(())
} else {
anyhow::bail!("{}", lines.join("\n"))
}
}
fn pair_entries(base: &[Entry], head: &[Entry]) -> Vec<(Option<usize>, Option<usize>)> {
let mut by_id: HashMap<(DocClass, &str), usize> = HashMap::new();
for (i, e) in base.iter().enumerate() {
if let Some(id) = e.id {
by_id.entry((e.class, id)).or_insert(i);
}
}
let mut base_used = vec![false; base.len()];
let mut head_pair: Vec<Option<usize>> = vec![None; head.len()];
for (j, e) in head.iter().enumerate() {
let Some(id) = e.id else { continue };
if let Some(&i) = by_id.get(&(e.class, id)) {
if !base_used[i] {
base_used[i] = true;
head_pair[j] = Some(i);
}
}
}
let by_path: HashMap<(&str, DocClass), usize> = base
.iter()
.enumerate()
.filter(|(i, _)| !base_used[*i])
.map(|(i, e)| ((e.path, e.class), i))
.collect();
for (j, e) in head.iter().enumerate() {
if head_pair[j].is_some() {
continue;
}
if let Some(&i) = by_path.get(&(e.path, e.class)) {
if !base_used[i] && (e.id.is_none() || base[i].id.is_none()) {
base_used[i] = true;
head_pair[j] = Some(i);
}
}
}
let mut pairs: Vec<(Option<usize>, Option<usize>)> = head_pair
.iter()
.enumerate()
.map(|(j, i)| (*i, Some(j)))
.collect();
pairs.extend(
base_used
.iter()
.enumerate()
.filter(|(_, used)| !**used)
.map(|(i, _)| (Some(i), None)),
);
pairs
}
fn content_changed(base: &Side, head: &Side, b: &Entry, h: &Entry) -> bool {
if b.title != h.title || b.description != h.description || b.body != h.body {
return true;
}
base.image_hashes(b.path, b.body) != head.image_hashes(h.path, h.body)
}
pub(super) fn pair_documents(base: &Side, head: &Side) -> anyhow::Result<Vec<Item>> {
let (be, he) = (entries(base), entries(head));
check_duplicates(&be, "the base")?;
check_duplicates(&he, "the head")?;
let mut items = Vec::new();
let mut without_id: BTreeSet<&str> = BTreeSet::new();
for (bi, hi) in pair_entries(&be, &he) {
let (b, h) = (bi.map(|i| &be[i]), hi.map(|j| &he[j]));
let content = match (b, h) {
(Some(b), Some(h)) => content_changed(base, head, b, h),
_ => true,
};
let moved = matches!((b, h), (Some(b), Some(h)) if b.path != h.path);
let differs = content || moved;
let item = match (b.filter(|e| e.published), h.filter(|e| e.published)) {
(Some(b), Some(h)) if differs => Some(Item {
status: if b.path == h.path {
Status::Mod
} else {
Status::Move
},
base: Some(b.path.to_string()),
head: Some(h.path.to_string()),
id: h.id.or(b.id).map(str::to_string),
omit_body: !content,
}),
(Some(b), None) => Some(Item {
status: Status::Del,
base: Some(b.path.to_string()),
head: None,
id: b.id.map(str::to_string),
omit_body: false,
}),
(None, Some(h)) => Some(Item {
status: Status::Add,
base: None,
head: Some(h.path.to_string()),
id: h.id.map(str::to_string),
omit_body: false,
}),
_ => None,
};
if let Some(h) = h {
let lost = b.is_some_and(|b| b.id.is_some());
if h.id.is_none() && (lost || differs) {
without_id.insert(h.path);
}
}
items.extend(item);
}
if !without_id.is_empty() {
let list: Vec<&str> = without_id.into_iter().collect();
anyhow::bail!(
"documents in the diff have no id; add \"id:\" to their front matter:\n{}",
list.join("\n")
);
}
Ok(items)
}
pub(super) fn pair_indexes(base: &Side, head: &Side) -> Vec<Item> {
let indexes = |side: &Side| -> BTreeMap<String, (String, Option<String>, String)> {
side.model
.pages
.iter()
.filter(|(_, p)| p.kind == Kind::Index)
.map(|(path, p)| {
(
path.clone(),
(p.title.clone(), p.description.clone(), p.body.clone()),
)
})
.collect()
};
let (b, h) = (indexes(base), indexes(head));
let paths: BTreeSet<&String> = b.keys().chain(h.keys()).collect();
let mut items = Vec::new();
for path in paths {
let status = match (b.get(path), h.get(path)) {
(Some(x), Some(y)) if x == y => continue,
(Some(_), Some(_)) => Status::Mod,
(Some(_), None) => Status::Del,
_ => Status::Add,
};
items.push(Item {
status,
base: b.contains_key(path).then(|| path.clone()),
head: h.contains_key(path).then(|| path.clone()),
id: None,
omit_body: false,
});
}
items
}