cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Port: bulk discovery of load-time defects in one entry corpus.
//!
//! Kind-neutral. A single scanner instance is configured for one TOML
//! corpus block ([issues], [decisions.adr], [decisions.ddr], …) and
//! reports every entry that the adapter could not load into a valid
//! domain value. Well-formed entries are *not* reported here — they
//! reach the domain through [`IssueRepository`] or
//! [`DecisionRecordRepository`].
//!
//! Adapters translate their own error vocabulary (`io::Error`,
//! `serde_yaml::Error`, …) into [`LoadDefect`] variants; the domain
//! never sees adapter types.

use crate::domain::model::malformed_entry::MalformedEntry;

pub trait EntryDefectScanner {
    /// Walks every source (writable home + union[*]) and returns one
    /// `MalformedEntry` per failed-to-load source.
    fn scan(&self) -> anyhow::Result<Vec<MalformedEntry>>;
}

#[cfg(test)]
pub mod test_support {
    //! Reusable in-memory fake and contract runner for
    //! [`EntryDefectScanner`] implementations.
    //!
    //! Adapters under `infra/driven/` register their own integration
    //! tests that call [`check_entry_defect_scanner_contract`] against
    //! a fresh instance; the runner exercises every observable
    //! behaviour the port promises.

    use super::*;
    use crate::domain::model::entry_locator::EntryLocator;
    use crate::domain::model::entry_origin::EntryOrigin;
    use crate::domain::model::load_defect::LoadDefect;

    /// Trivial in-memory implementation: returns whatever defects the
    /// constructor was given. Useful in use-case tests that need a
    /// scanner without touching the filesystem.
    pub struct FakeEntryDefectScanner {
        defects: Vec<MalformedEntry>,
    }

    impl FakeEntryDefectScanner {
        pub fn empty() -> Self {
            Self {
                defects: Vec::new(),
            }
        }

        pub fn with_defects(defects: Vec<MalformedEntry>) -> Self {
            Self { defects }
        }
    }

    impl EntryDefectScanner for FakeEntryDefectScanner {
        fn scan(&self) -> anyhow::Result<Vec<MalformedEntry>> {
            Ok(self.defects.clone())
        }
    }

    /// Verifies that an `EntryDefectScanner` implementation satisfies
    /// the behavioural contract every adapter must honour. Call from a
    /// per-adapter integration test:
    ///
    /// ```ignore
    /// check_entry_defect_scanner_contract(|defects| {
    ///     // build an adapter primed with `defects`
    /// });
    /// ```
    ///
    /// `make` receives a list of defects the scanner is expected to
    /// surface; the runner asserts that `scan()` returns exactly the
    /// same set (order-insensitive).
    pub fn check_entry_defect_scanner_contract<S, F>(make: F)
    where
        S: EntryDefectScanner,
        F: Fn(Vec<MalformedEntry>) -> S,
    {
        // 1. Empty corpus → empty defect list.
        let scanner = make(Vec::new());
        let got = scanner.scan().expect("scan must not fail on empty corpus");
        assert!(got.is_empty(), "empty corpus must yield no defects");

        // 2. Single defect surfaces with all three fields preserved.
        let entry = MalformedEntry {
            location: EntryLocator::new("docs/adr/0001-broken/index.md"),
            origin: EntryOrigin::Local,
            defect: LoadDefect::MissingId,
        };
        let scanner = make(vec![entry.clone()]);
        let got = scanner.scan().expect("scan must succeed");
        assert_eq!(got, vec![entry]);

        // 3. Union origin propagated verbatim.
        let entry = MalformedEntry {
            location: EntryLocator::new("../shared/adr/0042-broken/index.md"),
            origin: EntryOrigin::Union {
                name: "../shared/adr".into(),
            },
            defect: LoadDefect::InvalidFrontmatter {
                reason: "unexpected token".into(),
            },
        };
        let scanner = make(vec![entry.clone()]);
        let got = scanner.scan().expect("scan must succeed");
        assert_eq!(got, vec![entry]);

        // 4. Each LoadDefect variant flows through unchanged.
        let variants = vec![
            LoadDefect::SourceUnreadable {
                reason: "permission denied".into(),
            },
            LoadDefect::InvalidFrontmatter {
                reason: "missing colon".into(),
            },
            LoadDefect::MissingId,
            LoadDefect::IdPrefixMismatch {
                expected: "ADR-".into(),
                found: "DDR-".into(),
            },
            LoadDefect::InvalidStatus {
                value: "Klingon".into(),
            },
        ];
        let entries: Vec<_> = variants
            .into_iter()
            .enumerate()
            .map(|(i, defect)| MalformedEntry {
                location: EntryLocator::new(format!("docs/adr/{i:04}/index.md")),
                origin: EntryOrigin::Local,
                defect,
            })
            .collect();
        let scanner = make(entries.clone());
        let mut got = scanner.scan().expect("scan must succeed");
        got.sort_by(|a, b| a.location.as_str().cmp(b.location.as_str()));
        let mut expected = entries.clone();
        expected.sort_by(|a, b| a.location.as_str().cmp(b.location.as_str()));
        assert_eq!(got, expected, "every LoadDefect variant must round-trip");

        // 5. Mixed origins on the same corpus are kept distinct.
        let local = MalformedEntry {
            location: EntryLocator::new("docs/adr/local/index.md"),
            origin: EntryOrigin::Local,
            defect: LoadDefect::MissingId,
        };
        let unioned = MalformedEntry {
            location: EntryLocator::new("../shared/adr/u/index.md"),
            origin: EntryOrigin::Union {
                name: "../shared/adr".into(),
            },
            defect: LoadDefect::MissingId,
        };
        let scanner = make(vec![local.clone(), unioned.clone()]);
        let got = scanner.scan().expect("scan must succeed");
        assert_eq!(got.len(), 2);
        assert!(got.contains(&local));
        assert!(got.contains(&unioned));
    }

    #[cfg(test)]
    mod contract_self_check {
        //! The contract runner must hold against the in-memory fake;
        //! otherwise no adapter could ever pass it.

        use super::*;

        #[test]
        fn fake_satisfies_contract() {
            check_entry_defect_scanner_contract(FakeEntryDefectScanner::with_defects);
        }
    }
}