scraps 5.0.0

Scraps is a portable CLI knowledge hub for managing interconnected Markdown documentation with Wiki-link notation.
use std::collections::HashSet;

use scraps_libs::markdown::query::WikiRef;
use scraps_libs::model::{key::ScrapKey, scrap::Scrap};

use super::schema::{EdgeKind, KeyJson, LinkEdge};

/// Resolve every link and embed occurrence against the scraps that exist.
/// This is the link step: the objects say what each scrap references, the
/// table says whether each reference lands.
pub fn link_edges(scraps: &[Scrap]) -> Vec<LinkEdge> {
    let keys: HashSet<ScrapKey> = scraps.iter().map(Scrap::self_key).collect();
    let keys = &keys;

    scraps
        .iter()
        .flat_map(|scrap| {
            let from = KeyJson::from(&scrap.self_key());
            let sources = scrap.source_links().into_iter().filter_map({
                let from = from.clone();
                move |link| {
                    let to = link.target?;
                    Some(LinkEdge {
                        from: from.clone(),
                        resolved: keys.contains(&to),
                        to: KeyJson::from(&to),
                        kind: EdgeKind::Source,
                        heading: link.heading,
                        line: line_of(scrap.md_text(), &link.resource),
                    })
                }
            });
            let refs = scrap.refs().iter().filter_map(move |r| {
                let (to, kind, heading, line) = match r {
                    WikiRef::Link(l) => {
                        (ScrapKey::from(l), EdgeKind::Link, l.heading.clone(), l.line)
                    }
                    WikiRef::Embed(e) => (
                        ScrapKey::from_path_str(&embed_path(&e.ctx_path, &e.title)),
                        EdgeKind::Embed,
                        e.heading.clone(),
                        e.line,
                    ),
                    // A path out of the wiki root names no scrap, so no edge.
                    WikiRef::MdLink(m) => {
                        (m.resolve(scrap.ctx())?, EdgeKind::Link, m.heading(), m.line)
                    }
                    WikiRef::Tag(_) => return None,
                };
                Some(LinkEdge {
                    from: from.clone(),
                    resolved: keys.contains(&to),
                    to: KeyJson::from(&to),
                    kind,
                    heading,
                    line,
                })
            });
            refs.chain(sources)
        })
        .collect()
}

/// The first line holding `resource`, which for a source is the frontmatter
/// line that names it.
fn line_of(text: &str, resource: &str) -> usize {
    text.lines()
        .position(|line| line.contains(resource))
        .map_or(1, |index| index + 1)
}

fn embed_path(ctx_path: &[String], title: &str) -> String {
    if ctx_path.is_empty() {
        title.to_string()
    } else {
        format!("{}/{}", ctx_path.join("/"), title)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_resolves_links_and_embeds_and_skips_tags() {
        let scraps = vec![
            Scrap::new(
                "a",
                &None,
                "line one\n[[b#Sec]] #[[tag]]\n![[Ctx/c]] [[missing]]",
            ),
            Scrap::new("b", &None, "## Sec"),
            Scrap::new("c", &Some("Ctx".into()), ""),
        ];

        let edges = link_edges(&scraps);

        assert_eq!(edges.len(), 3);
        assert_eq!(edges[0].to.stem, "b");
        assert_eq!(edges[0].kind, EdgeKind::Link);
        assert_eq!(edges[0].heading.as_deref(), Some("Sec"));
        assert_eq!(edges[0].line, 2);
        assert!(edges[0].resolved);
        assert_eq!(edges[1].kind, EdgeKind::Embed);
        assert_eq!(edges[1].to.ctx.as_deref(), Some("Ctx"));
        assert!(edges[1].resolved);
        assert_eq!(edges[2].to.stem, "missing");
        assert!(!edges[2].resolved);
        assert!(edges.iter().all(|e| e.from.stem == "a"));
    }

    #[test]
    fn it_adds_an_edge_for_each_source_naming_a_scrap() {
        let scraps = vec![
            Scrap::new(
                "okf",
                &None,
                "---\nsources:\n  - resource: https://example.com\n  - resource: /Blog/Intro.md\n  - resource: /Gone.md\n---\n\nbody\n",
            ),
            Scrap::new("Intro", &Some("Blog".into()), ""),
        ];

        let edges = link_edges(&scraps);

        assert_eq!(edges.len(), 2);
        assert!(edges.iter().all(|e| e.kind == EdgeKind::Source));
        assert_eq!(edges[0].to.stem, "Intro");
        assert_eq!(edges[0].line, 4);
        assert!(edges[0].resolved);
        assert_eq!(edges[1].to.stem, "Gone");
        assert!(!edges[1].resolved);
    }

    #[test]
    fn it_resolves_markdown_links_from_the_linking_scrap() {
        let scraps = vec![
            Scrap::new(
                "orders",
                &Some("tables".into()),
                "[c](/tables/customers.md) [r](../metrics/revenue.md#definition)\n[g](./gone.md) [out](../../out.md)",
            ),
            Scrap::new("customers", &Some("tables".into()), ""),
            Scrap::new("revenue", &Some("metrics".into()), "# Definition"),
        ];

        let edges = link_edges(&scraps);

        assert_eq!(edges.len(), 3);
        assert!(edges.iter().all(|e| e.kind == EdgeKind::Link));
        assert_eq!(edges[0].to.stem, "customers");
        assert_eq!(edges[0].to.ctx.as_deref(), Some("tables"));
        assert!(edges[0].resolved);
        assert_eq!(edges[1].to.ctx.as_deref(), Some("metrics"));
        assert_eq!(edges[1].heading.as_deref(), Some("definition"));
        assert!(edges[1].resolved);
        assert_eq!(edges[2].to.stem, "gone");
        assert_eq!(edges[2].line, 2);
        assert!(!edges[2].resolved);
    }

    #[test]
    fn it_keeps_duplicate_occurrences() {
        let scraps = vec![Scrap::new("a", &None, "[[a]] [[a]]")];
        assert_eq!(link_edges(&scraps).len(), 2);
    }
}