cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Shared in-memory test doubles for all `issue` submodule tests.
//!
//! [`FakeIssueRepository`] is a single Fake — in the Meszaros sense — that
//! implements both [`IssueRepository`] and [`IssueScanner`] from internal
//! state. One projection site to [`IssueScanEntry`] means future
//! evolutions of the port (extra fields, provenance, …) touch one place,
//! not ten. Pure model fixtures live in
//! `crate::domain::model::issue::test_fixtures`.

use std::cell::RefCell;
use std::path::PathBuf;

use crate::domain::model::entry_locator::EntryLocator;
use crate::domain::model::entry_origin::EntryOrigin;
use crate::domain::model::issue::companion::{
    CompanionContent, CompanionIdentifier, IssueCompanions,
};
use crate::domain::model::issue::test_fixtures::ir;
use crate::domain::model::issue::Issue;
use crate::domain::model::record_ref::IssueRef;
use crate::domain::usecases::issue::{IndexSampler, IssueIdGenerator, IssueRepository};

/// Deterministic [`IndexSampler`] for tests: returns `0, 1, 2, …, len-1`
/// cyclically. Independent of any PRNG; assertions can be made against
/// the resulting structural properties of a forecast without depending
/// on a specific algorithm's sequence.
#[derive(Default)]
pub struct CycleIndexSampler {
    next: usize,
}

impl IndexSampler for CycleIndexSampler {
    fn next_index(&mut self, len: usize) -> usize {
        let i = self.next % len;
        self.next = self.next.wrapping_add(1);
        i
    }
}

/// Deterministic [`IssueIdGenerator`] for tests: hands out `ISSUE-0001`,
/// `ISSUE-0002`, … in call order. The starting value is configurable so a
/// scenario that pre-seeds issues `ISSUE-0001..0003` can ask the next
/// allocation to be `ISSUE-0004`.
pub struct SequentialIssueIdGenerator {
    next: std::cell::Cell<u64>,
}

impl SequentialIssueIdGenerator {
    pub fn starting_at(n: u64) -> Self {
        Self {
            next: std::cell::Cell::new(n),
        }
    }
}

impl Default for SequentialIssueIdGenerator {
    fn default() -> Self {
        Self::starting_at(1)
    }
}

impl IssueIdGenerator for SequentialIssueIdGenerator {
    fn next_id(&self) -> anyhow::Result<IssueRef> {
        let n = self.next.get();
        self.next.set(n + 1);
        Ok(ir(n))
    }
}

// ── FakeIssueRepository ───────────────────────────────────────────────

/// One scanned entry held by the Fake. Parallel of [`IssueScanEntry`] but
/// kept private so the projection happens in exactly one place.
struct FakeEntry {
    location: EntryLocator,
    result: Result<Issue, String>,
}

/// In-memory Fake of both [`IssueRepository`] and [`IssueScanner`].
///
/// State is a vector of entries (each Ok or Err). `save` always records,
/// so a test interested in writes just queries [`Self::saves`] /
/// [`Self::last_saved`]; tests that don't care ignore them. Construction
/// is by builder — the most common shapes have dedicated constructors.
pub struct FakeIssueRepository {
    entries: RefCell<Vec<FakeEntry>>,
    saves: RefCell<Vec<Issue>>,
    id_prefix: Option<String>,
}

impl Default for FakeIssueRepository {
    fn default() -> Self {
        Self {
            entries: RefCell::new(Vec::new()),
            saves: RefCell::new(Vec::new()),
            id_prefix: None,
        }
    }
}

impl FakeIssueRepository {
    pub fn new() -> Self {
        Self::default()
    }

    /// Seed with a batch of issues. Locations are synthesised from each
    /// issue's id (`docs/issues/<suffix>-issue/index.md`); use
    /// [`Self::with_pairs`] when the test asserts on specific paths.
    pub fn with_issues<I: IntoIterator<Item = Issue>>(issues: I) -> Self {
        let mut me = Self::new();
        for issue in issues {
            me.push_issue(issue);
        }
        me
    }

    /// Seed with explicit `(path, issue)` pairs. Useful for `check_issues`
    /// tests where the violation's path must equal a specific string.
    pub fn with_pairs<I: IntoIterator<Item = (PathBuf, Issue)>>(pairs: I) -> Self {
        let me = Self::new();
        for (path, mut issue) in pairs {
            let location = EntryLocator::new(path.display().to_string());
            issue.location = location.clone();
            me.entries.borrow_mut().push(FakeEntry {
                location,
                result: Ok(issue),
            });
        }
        me
    }

    pub fn with_id_prefix(mut self, prefix: &str) -> Self {
        self.id_prefix = Some(prefix.to_string());
        self
    }

    /// Append one issue with an auto-synthesised location. The issue's
    /// `location` field is overwritten to match — mirroring the FS
    /// adapter, which stamps the on-disk URI at load time. The caller
    /// can still pre-set `origin` for union-load modelling.
    pub fn push_issue(&mut self, mut issue: Issue) {
        let location =
            EntryLocator::new(format!("docs/issues/{}-issue/index.md", issue.id.suffix()));
        issue.location = location.clone();
        self.entries.borrow_mut().push(FakeEntry {
            location,
            result: Ok(issue),
        });
    }

    /// Append one issue sourced from a union — `source` is the
    /// human-readable label the FS adapter would have set (typically
    /// the source dir path). Mutates the issue's `origin` and
    /// `location` to match.
    pub fn push_union_issue(&mut self, mut issue: Issue, source: &str) {
        let location = EntryLocator::new(format!("{source}/{}-issue/index.md", issue.id.suffix()));
        issue.origin = EntryOrigin::Union {
            name: source.to_string(),
        };
        issue.location = location.clone();
        self.entries.borrow_mut().push(FakeEntry {
            location,
            result: Ok(issue),
        });
    }

    /// Append a parse-failure entry — the scanner reports it, the
    /// repository's reads skip it.
    pub fn push_broken(&mut self, location: &str, reason: &str) {
        self.entries.borrow_mut().push(FakeEntry {
            location: EntryLocator::new(location),
            result: Err(reason.to_string()),
        });
    }

    /// Every issue persisted through `save`, in call order.
    pub fn saves(&self) -> Vec<Issue> {
        self.saves.borrow().clone()
    }

    pub fn last_saved(&self) -> Option<Issue> {
        self.saves.borrow().last().cloned()
    }

    pub fn saved_for(&self, id: &IssueRef) -> Option<Issue> {
        self.saves
            .borrow()
            .iter()
            .rev()
            .find(|i| &i.id == id)
            .cloned()
    }

    pub fn save_count(&self) -> usize {
        self.saves.borrow().len()
    }
}

impl IssueRepository for FakeIssueRepository {
    fn save(&self, issue: &Issue) -> anyhow::Result<()> {
        self.saves.borrow_mut().push(issue.clone());
        let mut entries = self.entries.borrow_mut();
        let location =
            EntryLocator::new(format!("docs/issues/{}-issue/index.md", issue.id.suffix()));
        let mut stored = issue.clone();
        if let Some(existing) = entries
            .iter_mut()
            .find(|e| e.result.as_ref().map(|i| i.id == issue.id).unwrap_or(false))
        {
            stored.location = existing.location.clone();
            existing.result = Ok(stored);
        } else {
            stored.location = location.clone();
            entries.push(FakeEntry {
                location,
                result: Ok(stored),
            });
        }
        Ok(())
    }

    fn list(&self) -> anyhow::Result<crate::domain::model::issue::IssueCollection> {
        Ok(self
            .entries
            .borrow()
            .iter()
            .filter_map(|e| e.result.as_ref().ok().cloned())
            .collect())
    }

    fn find_by_id(&self, id: &IssueRef) -> anyhow::Result<Option<Issue>> {
        Ok(self
            .entries
            .borrow()
            .iter()
            .filter_map(|e| e.result.as_ref().ok())
            .find(|i| &i.id == id)
            .cloned())
    }

    fn issue_companions(&self, _id: &IssueRef) -> anyhow::Result<IssueCompanions> {
        Ok(IssueCompanions::new())
    }

    fn read_companion(
        &self,
        _id: &IssueRef,
        _identifier: &CompanionIdentifier,
    ) -> anyhow::Result<Option<CompanionContent>> {
        Ok(None)
    }

    fn configured_id_prefix(&self) -> Option<&str> {
        self.id_prefix.as_deref()
    }
}

impl crate::domain::usecases::entry_defect_scanner::EntryDefectScanner for FakeIssueRepository {
    fn scan(&self) -> anyhow::Result<Vec<crate::domain::model::malformed_entry::MalformedEntry>> {
        use crate::domain::model::load_defect::LoadDefect;
        use crate::domain::model::malformed_entry::MalformedEntry;
        Ok(self
            .entries
            .borrow()
            .iter()
            .filter_map(|e| {
                e.result.as_ref().err().map(|reason| MalformedEntry {
                    location: e.location.clone(),
                    origin: EntryOrigin::Local,
                    defect: LoadDefect::InvalidFrontmatter {
                        reason: reason.clone(),
                    },
                })
            })
            .collect())
    }
}

// ── Contract tests ────────────────────────────────────────────────────

/// Observable-behaviour contract every [`IssueRepository`] adapter must
/// satisfy. Call [`check_issue_repository_contract`] from each adapter's
/// test module with a factory that yields a fresh, empty instance.
pub mod contract {
    use super::*;
    use crate::domain::model::issue::test_fixtures::{make_issue, st};

    fn an_issue(n: u64) -> Issue {
        make_issue(n, "Title", st("open"))
    }

    pub fn empty_list_yields_no_issues<R: IssueRepository>(repo: R) {
        assert!(repo.list().unwrap().is_empty());
    }

    pub fn find_by_id_unknown_returns_none<R: IssueRepository>(repo: R) {
        assert!(repo.find_by_id(&ir(404)).unwrap().is_none());
    }

    pub fn saved_issue_is_findable_by_id<R: IssueRepository>(repo: R) {
        let issue = an_issue(1);
        repo.save(&issue).unwrap();
        let found = repo.find_by_id(&issue.id).unwrap().expect("expected Some");
        assert_eq!(found.id, issue.id);
        assert_eq!(found.title, issue.title);
    }

    pub fn saved_issue_appears_in_list<R: IssueRepository>(repo: R) {
        let issue = an_issue(1);
        repo.save(&issue).unwrap();
        let list = repo.list().unwrap();
        assert!(list.iter().any(|i| i.id == issue.id));
    }

    pub fn saving_twice_keeps_latest<R: IssueRepository>(repo: R) {
        let mut issue = an_issue(1);
        repo.save(&issue).unwrap();
        issue.title = crate::domain::model::title::Title::new("Updated").unwrap();
        repo.save(&issue).unwrap();
        let found = repo.find_by_id(&issue.id).unwrap().unwrap();
        assert_eq!(found.title.as_str(), "Updated");
    }

    pub fn configured_id_prefix_defaults_to_none<R: IssueRepository>(repo: R) {
        assert!(repo.configured_id_prefix().is_none());
    }
}

/// Run every contract scenario against a fresh adapter instance built
/// by `make`.
pub fn check_issue_repository_contract<R, F>(make: F)
where
    R: IssueRepository,
    F: Fn() -> R,
{
    contract::empty_list_yields_no_issues(make());
    contract::find_by_id_unknown_returns_none(make());
    contract::saved_issue_is_findable_by_id(make());
    contract::saved_issue_appears_in_list(make());
    contract::saving_twice_keeps_latest(make());
}

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

    #[test]
    fn fake_issue_repository_satisfies_the_contract() {
        check_issue_repository_contract(FakeIssueRepository::new);
    }

    #[test]
    fn fake_issue_repository_default_has_no_id_prefix() {
        contract::configured_id_prefix_defaults_to_none(FakeIssueRepository::new());
    }
}