cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Validate the cross-references between an issue's content parts.
//!
//! A part (`index`, a companion) may reference siblings by local
//! identifier. Targets that do not match any existing part become
//! `BrokenReference` violations. Parts that nothing references are
//! *not* orphans: the model accepts attachments and companions whose
//! presence stands on its own.

use std::collections::HashSet;

/// Borrowed view of an issue's content composition, ready for
/// validation. Built from
/// [`IssueContentSet`](crate::domain::usecases::issue::content_reader::IssueContentSet).
pub struct ContentGraph<'a> {
    /// Local identifier of every part of the issue.
    pub parts: &'a [String],
    /// `(part, outgoing references)` pairs for every part that
    /// contains references to other parts.
    pub references: &'a [(String, Vec<String>)],
}

/// A single violation produced by [`validate_content_references`].
#[derive(Debug, PartialEq, Eq)]
pub enum ContentViolation {
    /// A part references a target that is not in the part set.
    BrokenReference { from: String, to: String },
}

pub fn validate_content_references(graph: &ContentGraph) -> Vec<ContentViolation> {
    let parts: HashSet<&str> = graph.parts.iter().map(String::as_str).collect();
    let mut violations = Vec::new();
    for (from, targets) in graph.references {
        for target in targets {
            if !parts.contains(target.as_str()) {
                violations.push(ContentViolation::BrokenReference {
                    from: from.clone(),
                    to: target.clone(),
                });
            }
        }
    }
    violations
}

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

    fn s(v: &str) -> String {
        v.to_string()
    }

    fn owned(
        parts: &[&str],
        references: &[(&str, &[&str])],
    ) -> (Vec<String>, Vec<(String, Vec<String>)>) {
        let parts = parts.iter().map(|f| s(f)).collect();
        let references = references
            .iter()
            .map(|(name, targets)| (s(name), targets.iter().map(|t| s(t)).collect()))
            .collect();
        (parts, references)
    }

    fn run(parts: &[&str], references: &[(&str, &[&str])]) -> Vec<ContentViolation> {
        let (parts, references) = owned(parts, references);
        let graph = ContentGraph {
            parts: &parts,
            references: &references,
        };
        validate_content_references(&graph)
    }

    #[test]
    fn empty_content_set_has_no_violations() {
        assert!(run(&[], &[]).is_empty());
    }

    #[test]
    fn lone_index_with_no_refs_is_clean() {
        assert!(run(&["index"], &[("index", &[])]).is_empty());
    }

    #[test]
    fn broken_ref_from_index_is_reported() {
        let v = run(&["index"], &[("index", &["plan"])]);
        assert_eq!(
            v,
            vec![ContentViolation::BrokenReference {
                from: s("index"),
                to: s("plan"),
            }]
        );
    }

    #[test]
    fn broken_ref_from_companion_is_reported() {
        let v = run(
            &["index", "plan"],
            &[("index", &[]), ("plan", &["mockup.png"])],
        );
        assert_eq!(
            v,
            vec![ContentViolation::BrokenReference {
                from: s("plan"),
                to: s("mockup.png"),
            }]
        );
    }

    #[test]
    fn unreferenced_companions_are_not_orphans() {
        assert!(run(&["index", "notes"], &[("index", &[]), ("notes", &[])]).is_empty());
    }

    #[test]
    fn unreferenced_attachments_are_not_orphans() {
        assert!(run(&["index", "mockup UX.xml"], &[("index", &[])]).is_empty());
    }
}