scraps 2.4.0

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

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

use crate::usecase::build::model::backlinks_map::BacklinksMap;
use crate::usecase::lint::rule::{scrap_relative_path, LintRule, LintRuleName, LintWarning};

pub struct OverlinkingRule;

impl LintRule for OverlinkingRule {
    fn name(&self) -> LintRuleName {
        LintRuleName::Overlinking
    }

    fn check(
        &self,
        scraps: &[Scrap],
        _backlinks_map: &BacklinksMap,
        _tags: &Tags,
    ) -> Vec<LintWarning> {
        scraps
            .iter()
            .flat_map(|scrap| {
                let all_links: Vec<ScrapKey> = scrap
                    .refs()
                    .iter()
                    .filter_map(|r| match r {
                        WikiRef::Link(link) => Some(ScrapKey::from(link)),
                        _ => None,
                    })
                    .collect();
                let mut counts: HashMap<ScrapKey, usize> = HashMap::new();
                for link in &all_links {
                    *counts.entry(link.clone()).or_insert(0) += 1;
                }

                counts
                    .into_iter()
                    .filter(|(_, count)| *count > 1)
                    .map(|(key, count)| {
                        let pattern = format!("[[{}]]", key);
                        let source = scrap.md_text().to_string();
                        let span = source.find(&pattern).map(|s| (s, s + pattern.len()));

                        LintWarning {
                            rule_name: self.name(),
                            scrap_path: scrap_relative_path(scrap),
                            message: format!("link [[{}]] appears {} times", key, count),
                            source: Some(source),
                            span,
                        }
                    })
                    .collect::<Vec<_>>()
            })
            .collect()
    }
}

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

    #[test]
    fn detect_duplicate_link() {
        let scrap = Scrap::new("test", &None, "[[a]] text [[a]]");
        let scraps = vec![scrap];
        let backlinks_map = BacklinksMap::new(&scraps);
        let tags = Tags::new(&scraps);

        let warnings = OverlinkingRule.check(&scraps, &backlinks_map, &tags);
        assert_eq!(warnings.len(), 1);
        assert_eq!(warnings[0].rule_name, LintRuleName::Overlinking);
        assert!(warnings[0].message.contains("2 times"));
    }

    #[test]
    fn detect_triple_link_as_one_warning() {
        let scrap = Scrap::new("test", &None, "[[a]] [[a]] [[a]]");
        let scraps = vec![scrap];
        let backlinks_map = BacklinksMap::new(&scraps);
        let tags = Tags::new(&scraps);

        let warnings = OverlinkingRule.check(&scraps, &backlinks_map, &tags);
        assert_eq!(warnings.len(), 1);
        assert!(warnings[0].message.contains("3 times"));
    }

    #[test]
    fn alias_and_plain_same_link() {
        // [[A|B]] resolves to ScrapKey("A"), same as [[A]]
        let scrap = Scrap::new(
            "test",
            &None,
            "[[Domain Driven Design|DDD]] [[Domain Driven Design]]",
        );
        let scraps = vec![scrap];
        let backlinks_map = BacklinksMap::new(&scraps);
        let tags = Tags::new(&scraps);

        let warnings = OverlinkingRule.check(&scraps, &backlinks_map, &tags);
        assert_eq!(warnings.len(), 1);
        assert!(warnings[0].message.contains("2 times"));
    }

    #[test]
    fn skip_all_unique_links() {
        let scrap = Scrap::new("test", &None, "[[a]] [[b]] [[c]]");
        let scraps = vec![scrap];
        let backlinks_map = BacklinksMap::new(&scraps);
        let tags = Tags::new(&scraps);

        let warnings = OverlinkingRule.check(&scraps, &backlinks_map, &tags);
        assert!(warnings.is_empty());
    }
}