Big Brain v0.4.0

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()
    }
}