links.rs
Rust · 152 lines · 5.9 KB
//! Links between entries: `related` (both directions, shown as backlinks), `supersedes` /
//! `replaced_by` (a newer entry takes over), and `status: retired`. Retired and superseded
//! entries drop out of normal search and lists, so an old procedure can't outrank its successor;
//! they stay readable (`brain get`, `--all`) and in git.
use crate::store::{Brain, Meta};
use std::collections::{BTreeMap, BTreeSet};
/// Why an entry is out of normal results.
#[derive(Clone, Debug, PartialEq)]
pub enum Retired {
/// `status: retired`, with the reason and replacement if given.
Retired { why: String, by: String },
/// A newer entry says it supersedes this one.
Superseded { by: String },
}
impl Retired {
pub fn label(&self) -> String {
match self {
Retired::Retired { why, by } => {
let mut s = "retired".to_string();
if !why.is_empty() {
s.push_str(&format!(": {why}"));
}
if !by.is_empty() {
s.push_str(&format!(" (replaced by {by})"));
}
s
}
Retired::Superseded { by } => format!("superseded by {by}"),
}
}
}
#[derive(Default)]
pub struct Graph {
/// id → title, for every entry.
pub titles: BTreeMap<String, String>,
/// id → entries that list it in `related` (or that it supersedes / replaces): incoming links.
pub backlinks: BTreeMap<String, BTreeSet<String>>,
/// id → why it is out of normal results.
pub retired: BTreeMap<String, Retired>,
/// (entry, field, missing target) for links that point nowhere.
pub broken: Vec<(String, &'static str, String)>,
/// Entries in a replacement loop (A supersedes B supersedes A); they stay visible until fixed.
pub cycles: Vec<String>,
/// Every entry's frontmatter, loaded once (hooks and maintain reuse it).
pub metas: BTreeMap<String, Meta>,
}
/// Link targets an entry declares, by field.
pub fn outgoing(m: &Meta) -> Vec<(&'static str, String)> {
let mut v: Vec<(&'static str, String)> = m.related.iter().map(|r| ("related", r.trim().to_string())).collect();
if !m.supersedes.trim().is_empty() {
v.push(("supersedes", m.supersedes.trim().to_string()));
}
if !m.replaced_by.trim().is_empty() {
v.push(("replaced_by", m.replaced_by.trim().to_string()));
}
v.retain(|(_, t)| !t.is_empty());
v
}
impl Graph {
pub fn build(brain: &Brain) -> Graph {
let mut g = Graph::default();
let mut metas = vec![];
for id in brain.all_ids().unwrap_or_default() {
if let Ok(e) = brain.load(&id) {
g.titles.insert(id.clone(), e.meta.title.clone());
metas.push((id, e.meta));
}
}
// Declared replacements, newer → older (each entry names at most one).
let mut supersedes: BTreeMap<String, String> = BTreeMap::new();
for (id, m) in &metas {
if m.status.trim().eq_ignore_ascii_case("retired") {
g.retired.insert(id.clone(), Retired::Retired { why: m.retired_why.clone(), by: m.replaced_by.clone() });
}
for (field, target) in outgoing(m) {
if !g.titles.contains_key(&target) {
g.broken.push((id.clone(), field, target));
continue;
}
if target == *id {
g.broken.push((id.clone(), field, format!("{target} (itself)")));
continue;
}
g.backlinks.entry(target.clone()).or_default().insert(id.clone());
if field == "supersedes" {
supersedes.insert(id.clone(), target);
}
}
}
// Replacement loops, found on the declared graph: following `supersedes` from an entry
// comes back to it. Loop members are reported and none of them is hidden.
let mut in_loop: BTreeSet<String> = BTreeSet::new();
for start in supersedes.keys() {
let mut cur = start.clone();
for _ in 0..supersedes.len() {
match supersedes.get(&cur) {
Some(next) if next == start => {
in_loop.insert(start.clone());
break;
}
Some(next) => cur = next.clone(),
None => break,
}
}
}
g.cycles = in_loop.iter().cloned().collect();
for (newer, older) in &supersedes {
if in_loop.contains(older) && in_loop.contains(newer) {
continue;
}
// The newer entry wins unless the older one was explicitly retired already.
g.retired.entry(older.clone()).or_insert(Retired::Superseded { by: newer.clone() });
}
g.metas = metas.into_iter().collect();
g
}
/// Would `id` superseding `target` close a replacement loop? (`target` is, directly or through
/// others, already replacing `id`.)
pub fn would_loop(&self, id: &str, target: &str) -> bool {
let mut cur = target.to_string();
let mut seen = BTreeSet::new();
loop {
if cur == id {
return true;
}
// An existing loop elsewhere isn't a new one through `id`.
if !seen.insert(cur.clone()) {
return false;
}
match self.metas.get(&cur).map(|m| m.supersedes.trim().to_string()).filter(|s| !s.is_empty()) {
Some(next) => cur = next,
None => return false,
}
}
}
pub fn is_retired(&self, id: &str) -> bool {
self.retired.contains_key(id)
}
/// Ids that a new or updated entry links to but that don't exist.
pub fn missing_targets(&self, m: &Meta) -> Vec<String> {
outgoing(m).into_iter().map(|(_, t)| t).filter(|t| !self.titles.contains_key(t)).collect()
}
}