cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
use crate::domain::model::search::{SearchFilter, SearchHit};

/// Port: search across all records in the corpus.
///
/// The adapter receives the raw query string and returns a ranked list of
/// hits. Filtering by kind and limiting the result count is applied by the
/// use case after the adapter returns its full ranked list, so that adapters
/// need not re-implement filter logic.
pub trait SearchRepository {
    /// Return all hits matching `query`, ranked by relevance, with no limit.
    ///
    /// The use case applies `filter.kind` and `filter.limit` on top.
    fn search(&self, query: &str) -> anyhow::Result<Vec<SearchHit>>;
}

/// Search all records for `query`, applying `filter` to narrow and limit results.
///
/// The repository returns hits sorted by relevance (best match first).
/// If `filter.limit` is `Some(n)`, only the first `n` results are returned.
/// If `filter.limit` is `None`, all matching results are returned.
pub fn search_records(
    repo: &dyn SearchRepository,
    query: &str,
    filter: &SearchFilter,
) -> anyhow::Result<Vec<SearchHit>> {
    let hits = repo.search(query)?;

    let filtered = hits.into_iter().filter(|h| match &filter.kind {
        Some(k) => &h.kind == k,
        None => true,
    });

    let hits: Vec<SearchHit> = match filter.limit {
        Some(n) => filtered.take(n).collect(),
        None => filtered.collect(),
    };

    Ok(hits)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::model::entity_ref::EntityRef;
    use crate::domain::model::record_kind::RecordKind;
    use crate::domain::model::title::Title;

    // ── Tests ─────────────────────────────────────────────────────────────────

    #[test]
    fn returns_all_hits_when_no_filter() {
        scenario()
            .given_hit(hit("ISSUE-0001", "issue", "Add login"))
            .given_hit(hit("ADR-0001", "adr", "Use PostgreSQL"))
            .when_search("login", SearchFilter::new())
            .then_count(2);
    }

    #[test]
    fn filter_by_kind_returns_only_matching_kind() {
        scenario()
            .given_hit(hit("ISSUE-0001", "issue", "Auth feature"))
            .given_hit(hit("ADR-0001", "adr", "Auth architecture"))
            .when_search("Auth", SearchFilter::new().kind("issue"))
            .then_ids(&["ISSUE-0001"]);
    }

    #[test]
    fn filter_by_kind_adr_excludes_issues() {
        scenario()
            .given_hit(hit("ISSUE-0001", "issue", "Auth feature"))
            .given_hit(hit("ADR-0001", "adr", "Auth architecture"))
            .when_search("Auth", SearchFilter::new().kind("adr"))
            .then_ids(&["ADR-0001"]);
    }

    #[test]
    fn limit_caps_results() {
        scenario()
            .given_hit(hit("ISSUE-0001", "issue", "Auth login"))
            .given_hit(hit("ISSUE-0002", "issue", "Auth signup"))
            .given_hit(hit("ISSUE-0003", "issue", "Auth logout"))
            .when_search("Auth", SearchFilter::new().limit(2))
            .then_count(2);
    }

    #[test]
    fn empty_corpus_returns_empty() {
        scenario()
            .when_search("anything", SearchFilter::new())
            .then_empty();
    }

    #[test]
    fn kind_filter_with_no_match_returns_empty() {
        scenario()
            .given_hit(hit("ISSUE-0001", "issue", "Auth feature"))
            .when_search("Auth", SearchFilter::new().kind("adr"))
            .then_empty();
    }

    // ── Stub ──────────────────────────────────────────────────────────────────

    struct StubSearchRepo {
        hits: Vec<SearchHit>,
    }

    impl StubSearchRepo {
        fn with(hits: Vec<SearchHit>) -> Self {
            Self { hits }
        }
    }

    impl SearchRepository for StubSearchRepo {
        fn search(&self, _query: &str) -> anyhow::Result<Vec<SearchHit>> {
            Ok(self.hits.clone())
        }
    }

    fn hit(id: &str, kind: &str, title: &str) -> SearchHit {
        SearchHit {
            id: EntityRef::new(id).unwrap(),
            kind: RecordKind::new(kind).unwrap(),
            title: Title::new(title).unwrap(),
            excerpt: None,
        }
    }

    // ── Scenario DSL ──────────────────────────────────────────────────────────

    struct Scenario {
        hits: Vec<SearchHit>,
    }

    fn scenario() -> Scenario {
        Scenario { hits: vec![] }
    }

    impl Scenario {
        fn given_hit(mut self, h: SearchHit) -> Self {
            self.hits.push(h);
            self
        }

        fn when_search(self, query: &str, filter: SearchFilter) -> SearchOutcome {
            let repo = StubSearchRepo::with(self.hits);
            let result = search_records(&repo, query, &filter).unwrap();
            SearchOutcome { result }
        }
    }

    struct SearchOutcome {
        result: Vec<SearchHit>,
    }

    impl SearchOutcome {
        fn then_ids(self, expected: &[&str]) -> Self {
            let actual: Vec<String> = self.result.iter().map(|h| h.id.to_string()).collect();
            let expected: Vec<&str> = expected.to_vec();
            assert_eq!(
                actual, expected,
                "expected ids {expected:?}, got {actual:?}"
            );
            self
        }

        fn then_empty(self) -> Self {
            assert!(
                self.result.is_empty(),
                "expected empty result, got {} hits",
                self.result.len()
            );
            self
        }

        fn then_count(self, n: usize) -> Self {
            assert_eq!(
                self.result.len(),
                n,
                "expected {n} hits, got {}",
                self.result.len()
            );
            self
        }
    }
}