cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
use crate::domain::model::body::Body;
use crate::domain::model::description::Description;
use crate::domain::model::entry_locator::EntryLocator;
use crate::domain::model::entry_origin::EntryOrigin;
use crate::domain::model::record_ref::IssueRef;
use crate::domain::model::relates::Relates;
use crate::domain::model::status::Status;
use crate::domain::model::tag_filter::{record_matches_tags, TagFilter};
use crate::domain::model::tag_list::TagList;
use crate::domain::model::temporal::iso_date::IsoDate;
use crate::domain::model::title::Title;

use super::{Assignee, EventLog, IssueLinks, Tracker};

/// A tracked issue.
///
/// Issue type, priority, and size are encoded as structured tags
/// (`flow:<value>`, `priority:<value>`, `size:<value>`) inside `tags`.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Issue {
    pub id: IssueRef,
    pub title: Title,
    /// Optional one-line summary used as `<meta name="description">` in the
    /// generated site and as a fallback for cross-reference link previews.
    pub description: Option<Description>,
    pub(crate) status: Status,
    pub date: IsoDate,
    pub(crate) tags: TagList,
    /// Forwarding identifiers — strings that resolve to this issue when used
    /// in place of `id`. Populated by the v3→v4 migration to keep historical
    /// `ISSUE-NNNN` references working after the switch to TSIDs (ADR-0022).
    /// May also be hand-written for renames or external-system links.
    pub aliases: Vec<String>,
    pub(crate) content: Body,
    pub(crate) events: EventLog,
    pub(crate) links: IssueLinks,
    /// Cross-kind "see also" pointers, separate from typed `links:`.
    /// See ISSUE-018P03NSC7VNQ and DDR-018QWJVHRH35B.
    pub(crate) relates: Relates,
    pub assignee: Option<Assignee>,
    pub due_date: Option<IsoDate>,
    pub tracker: Tracker,
    /// Which workspace side this issue was loaded from. Set by the
    /// adapter at load time; `EntryOrigin::Local` for the writable
    /// home, `EntryOrigin::Union { name }` for read-only union
    /// sources. Local construction (new issues, fixtures) defaults to
    /// Local.
    pub origin: EntryOrigin,
    /// Where this issue was loaded from. Set by the adapter at load
    /// time using a URI-like scheme (`file://…`, future `git://…`).
    /// In-memory construction (new issues, fixtures, use-case
    /// scratchpads) defaults to `memory://`.
    pub location: EntryLocator,
}

impl Issue {
    /// Return a new issue identical to `self` but with `id` replaced.
    /// Pure transform aligned with ADR-00000000218SQ — does not mutate.
    pub fn with_id(self, id: IssueRef) -> Self {
        Self { id, ..self }
    }

    /// Return a new issue identical to `self` but with `tags` replaced.
    /// Pure transform aligned with ADR-00000000218SQ — does not mutate.
    pub fn with_tags(self, tags: TagList) -> Self {
        Self { tags, ..self }
    }

    /// Return a new issue identical to `self` but with `content` replaced.
    /// Pure transform aligned with ADR-00000000218SQ — does not mutate.
    pub fn with_content(self, content: Body) -> Self {
        Self { content, ..self }
    }

    /// Return a new issue identical to `self` but with `title` replaced.
    /// Pure transform aligned with ADR-00000000218SQ — does not mutate.
    pub fn with_title(self, title: Title) -> Self {
        Self { title, ..self }
    }

    /// Return a new issue identical to `self` but with `status` replaced.
    /// Pure transform aligned with ADR-00000000218SQ — does not mutate.
    pub fn with_status(self, status: Status) -> Self {
        Self { status, ..self }
    }

    /// Return a new issue identical to `self` but with `events` replaced.
    /// Pure transform aligned with ADR-00000000218SQ — does not mutate.
    pub fn with_events(self, events: EventLog) -> Self {
        Self { events, ..self }
    }

    /// Return a new issue identical to `self` but with `links` replaced.
    /// Pure transform aligned with ADR-00000000218SQ — does not mutate.
    pub fn with_links(self, links: IssueLinks) -> Self {
        Self { links, ..self }
    }

    /// Return a new issue identical to `self` but with `relates` replaced.
    /// Pure transform aligned with ADR-00000000218SQ — does not mutate.
    pub fn with_relates(self, relates: Relates) -> Self {
        Self { relates, ..self }
    }

    pub fn relates(&self) -> &Relates {
        &self.relates
    }

    /// Iterate the targets of this issue's `child-of` links — its
    /// parents under the mutual-inverse link encoding.
    pub fn parents(&self) -> impl Iterator<Item = &IssueRef> {
        self.links
            .iter()
            .filter(|l| l.relationship == super::IssueRelationship::ChildOf)
            .map(|l| &l.target)
    }

    /// Iterate the targets of this issue's `parent-of` links — its
    /// direct children under the mutual-inverse link encoding.
    pub fn children(&self) -> impl Iterator<Item = &IssueRef> {
        self.links
            .iter()
            .filter(|l| l.relationship == super::IssueRelationship::ParentOf)
            .map(|l| &l.target)
    }
}

/// Filter criteria for issues.
///
/// All fields are optional; a missing field is a no-op.
/// Multiple fields compose with AND semantics.
#[derive(Debug, Default)]
pub struct IssueFilter<'a> {
    pub status: Option<&'a Status>,
    /// When `true`, only issues whose status is `active` (open or
    /// in-progress on the default preset) match. When `false` (the
    /// default), the field is a no-op.
    pub active: bool,
    pub tags: &'a [TagFilter],
}

impl Issue {
    /// Return `true` if this issue matches all criteria in `filter`.
    pub fn matches(&self, filter: &IssueFilter<'_>) -> bool {
        filter.status.is_none_or(|s| &self.status == s)
            && (!filter.active || self.status.active)
            && record_matches_tags(&self.tags, filter.tags)
    }

    /// Build the post-sync state for `self` (a local issue) given the latest
    /// `remote` snapshot. Remote-tracked fields (title, status, content,
    /// events, assignee, due date, …) are taken from `remote`; locally-curated
    /// fields (`id`, `tags`, `links`) are preserved from `self`.
    pub fn merge_with_remote(&self, remote: &Issue) -> Issue {
        Issue {
            id: self.id.clone(),
            tags: self.tags.clone(),
            links: self.links.clone(),
            relates: self.relates.clone(),
            ..remote.clone()
        }
    }
}

#[cfg(test)]
pub mod strategy {
    use super::Issue;
    use crate::domain::model::body::strategy::arb_body;
    use crate::domain::model::entry_locator::EntryLocator;
    use crate::domain::model::entry_origin::EntryOrigin;
    use crate::domain::model::event::strategy::event_log;
    use crate::domain::model::issue::assignee::strategy::assignee;
    use crate::domain::model::issue::issue_link::strategy::issue_links;
    use crate::domain::model::issue::tracker::strategy::tracker;
    use crate::domain::model::record_ref::strategy::issue_ref;
    use crate::domain::model::relates::strategy::relates;
    use crate::domain::model::status::strategy::issue_status;
    use crate::domain::model::tag_list::strategy::tag_list;
    use crate::domain::model::temporal::iso_date::strategy::iso_date;
    use crate::domain::model::title::strategy::arb_title;
    use proptest::prelude::*;

    /// Generate an arbitrary `Issue` from leaf strategies. Description and
    /// due date are sometimes absent.
    pub fn issue() -> impl Strategy<Value = Issue> {
        let head = (
            issue_ref(),
            arb_title(),
            proptest::option::of(crate::domain::model::description::strategy::arb_description()),
            issue_status(),
            iso_date(),
        );
        let mid = (
            tag_list(),
            proptest::collection::vec("[A-Z]+-[0-9]{4}", 0..3),
            arb_body(),
            event_log(),
        );
        let tail = (
            issue_links(),
            relates(),
            proptest::option::of(assignee()),
            proptest::option::of(iso_date()),
            tracker(),
        );
        (head, mid, tail).prop_map(
            |(
                (id, title, description, status, date),
                (tags, aliases, content, events),
                (links, relates, assignee, due_date, tracker),
            )| Issue {
                id,
                title,
                description,
                status,
                date,
                tags,
                aliases,
                content,
                events,
                links,
                relates,
                assignee,
                due_date,
                tracker,
                origin: EntryOrigin::Local,
                location: EntryLocator::default(),
            },
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::model::event::{Event, EventAction};
    use crate::domain::model::status::Status;
    use crate::domain::model::temporal::timestamp::Timestamp;
    use proptest::prelude::*;

    proptest! {
        #[test]
        fn empty_filter_always_matches(i in strategy::issue()) {
            prop_assert!(i.matches(&IssueFilter::default()));
        }
    }

    #[test]
    fn event_creation() {
        let event = Event {
            timestamp: Timestamp::new("2026-03-09T14:30:45Z").unwrap(),
            action: EventAction::Created {
                state: crate::domain::model::event::State::new("open").unwrap(),
            },
        };
        assert!(event.action.is_created());
    }

    #[test]
    fn matches_empty_filter_is_always_true() {
        use crate::domain::model::issue::test_fixtures::{feature, ir};
        let issue = feature("Auth").status("open").build(ir(1));
        assert!(issue.matches(&IssueFilter::default()));
    }

    #[test]
    fn merge_with_remote_preserves_local_identity_and_curation() {
        use crate::domain::model::issue::test_fixtures::{feature, ir};
        use crate::domain::model::tag::Tag;

        let local = feature("Auth")
            .status("open")
            .build(ir(1))
            .with_tags([Tag::new("priority:high").unwrap()].into_iter().collect());

        let mut remote = feature("Auth v2")
            .status("closed")
            .build(ir(99))
            .with_tags([Tag::new("flow:defect").unwrap()].into_iter().collect());
        remote.aliases.push("REMOTE-ALIAS".to_string());

        let merged = local.merge_with_remote(&remote);

        // From local: identity + curated fields
        assert_eq!(merged.id, local.id);
        assert_eq!(merged.tags, local.tags);
        assert_eq!(merged.links, local.links);

        // From remote: content fields (including aliases — out of curation scope)
        assert_eq!(merged.title, remote.title);
        assert_eq!(merged.status, remote.status);
        assert_eq!(merged.aliases, remote.aliases);
    }

    #[test]
    fn matches_status_filter() {
        use crate::domain::model::issue::test_fixtures::{defect, feature, ir};
        let open_issue = feature("Auth").status("open").build(ir(1));
        let closed_issue = defect("Bug").status("closed").build(ir(2));
        let open = Status::new("open").unwrap();
        let f = IssueFilter {
            status: Some(&open),
            ..Default::default()
        };
        assert!(open_issue.matches(&f));
        assert!(!closed_issue.matches(&f));
    }
}