cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
use crate::domain::model::entity_ref::EntityRef;
use crate::domain::model::record_kind::RecordKind;
use crate::domain::model::title::Title;

/// A single search result.
#[derive(Debug, Clone, serde::Serialize)]
pub struct SearchHit {
    /// Record identifier (e.g. `ISSUE-0001`, `ADR-0003`).
    pub id: EntityRef,
    /// Record kind (e.g. `issue`, `adr`, `ddr`).
    pub kind: RecordKind,
    /// Record title.
    pub title: Title,
    /// Short excerpt from the body showing the matching context, if available.
    pub excerpt: Option<String>,
}

/// Filters that narrow the search corpus before ranking.
#[derive(Debug, Clone, Default)]
pub struct SearchFilter {
    /// Restrict to a specific record kind (`"issue"`, `"adr"`, …).
    pub kind: Option<RecordKind>,
    /// Maximum number of results to return.
    ///
    /// `None` means no limit — all matching results are returned.
    pub limit: Option<usize>,
}

impl SearchFilter {
    pub fn new() -> Self {
        Self {
            kind: None,
            limit: None,
        }
    }

    pub fn kind(mut self, kind: &str) -> Self {
        self.kind = RecordKind::new(kind).ok();
        self
    }

    pub fn limit(mut self, limit: usize) -> Self {
        self.limit = Some(limit);
        self
    }
}

#[cfg(test)]
pub mod strategy {
    use super::{SearchFilter, SearchHit};
    use crate::domain::model::entity_ref::strategy::entity_ref;
    use crate::domain::model::record_kind::strategy::record_kind;
    use crate::domain::model::title::strategy::arb_title;
    use proptest::prelude::*;

    /// Generate an arbitrary `SearchHit` from leaf strategies.
    pub fn search_hit() -> impl Strategy<Value = SearchHit> {
        (
            entity_ref(),
            record_kind(),
            arb_title(),
            proptest::option::of(any::<String>()),
        )
            .prop_map(|(id, kind, title, excerpt)| SearchHit {
                id,
                kind,
                title,
                excerpt,
            })
    }

    /// Generate an arbitrary `SearchFilter`, including the default form.
    pub fn search_filter() -> impl Strategy<Value = SearchFilter> {
        (
            proptest::option::of(record_kind()),
            proptest::option::of(0usize..1024),
        )
            .prop_map(|(kind, limit)| SearchFilter { kind, limit })
    }
}

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

    proptest! {
        #[test]
        fn search_filter_limit_is_some_when_built_explicitly(n in 0usize..1024) {
            let f = SearchFilter::new().limit(n);
            prop_assert_eq!(f.limit, Some(n));
        }
    }

    fn make_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,
        }
    }

    #[test]
    fn search_hit_fields_are_accessible() {
        let hit = make_hit("ISSUE-0001", "issue", "Add login");
        assert_eq!(hit.id.to_string(), "ISSUE-0001");
        assert_eq!(hit.kind.as_str(), "issue");
        assert_eq!(hit.title.as_str(), "Add login");
        assert!(hit.excerpt.is_none());
    }

    #[test]
    fn search_filter_defaults_have_no_limit() {
        let f = SearchFilter::new();
        assert!(f.kind.is_none());
        assert!(
            f.limit.is_none(),
            "default limit should be None (unlimited)"
        );
    }

    #[test]
    fn search_filter_builder_sets_fields() {
        let f = SearchFilter::new().kind("adr").limit(5);
        assert_eq!(f.kind.as_ref().map(|k| k.as_str()), Some("adr"));
        assert_eq!(f.limit, Some(5));
    }

    #[test]
    fn search_filter_invalid_kind_is_ignored() {
        let f = SearchFilter::new().kind("INVALID KIND");
        assert!(f.kind.is_none());
    }
}