cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Pure model-side test fixtures: builders and constructors that only
//! touch domain model types. Port stubs (repository/sampler/generator
//! adapters) live alongside the use cases in
//! `crate::domain::usecases::issue::test_support`.

use crate::domain::model::body::Body;
use crate::domain::model::entry_locator::EntryLocator;
use crate::domain::model::entry_origin::EntryOrigin;
use crate::domain::model::issue::Issue;
use crate::domain::model::record_ref::IssueRef;
use crate::domain::model::status::Status;
use crate::domain::model::title::Title;

pub fn ir(n: u64) -> IssueRef {
    IssueRef::new(format!("ISSUE-{n:04}")).unwrap()
}

pub fn st(s: &str) -> Status {
    Status::new(s).unwrap()
}

pub fn make_issue(id: u64, title: &str, status: Status) -> Issue {
    Issue {
        id: ir(id),
        title: Title::new(title).unwrap(),
        description: None,
        status,
        date: crate::domain::model::temporal::iso_date::IsoDate::new("2026-01-01").unwrap(),
        tags: crate::domain::model::tag_list::TagList::new(),
        aliases: Vec::new(),
        content: Body::default(),
        events: crate::domain::model::issue::EventLog::new(),
        links: crate::domain::model::issue::IssueLinks::new(),
        relates: crate::domain::model::relates::Relates::default(),
        assignee: None,
        due_date: None,
        tracker: crate::domain::model::issue::Tracker::local("ISSUE-0000"),
        origin: EntryOrigin::Local,
        location: EntryLocator::default(),
    }
}

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

/// Fluent builder for an issue fixture.
///
/// `kind`/`priority`/`size` set on the fixture become structured tags
/// (`flow:<value>` / `priority:<value>` / `size:<value>`) on the produced issue.
pub struct IssueFixture {
    pub(crate) title: String,
    /// Becomes a `flow:<value>` tag on build (unless empty).
    pub(crate) kind: String,
    pub(crate) status: String,
    pub(crate) tags: Vec<String>,
    pub(crate) id: Option<String>,
    pub(crate) body: Option<String>,
    pub(crate) links: Vec<(String, String)>,
    pub(crate) relates_targets: Vec<String>,
    pub(crate) events: Vec<(String, String, Option<String>, Option<String>)>,
    pub(crate) date: Option<String>,
    pub(crate) priority: Option<String>,
    pub(crate) size: Option<String>,
}

/// Start building an issue fixture with the given title.
pub fn issue(title: &str) -> IssueFixture {
    IssueFixture {
        title: title.to_string(),
        kind: String::new(),
        status: "open".to_string(),
        tags: vec![],
        id: None,
        body: None,
        links: vec![],
        relates_targets: vec![],
        events: vec![],
        date: None,
        priority: None,
        size: None,
    }
}

pub fn feature(title: &str) -> IssueFixture {
    issue(title).kind("feature")
}

pub fn defect(title: &str) -> IssueFixture {
    issue(title).kind("defect")
}

pub fn debt(title: &str) -> IssueFixture {
    issue(title).kind("debt")
}

pub fn risk(title: &str) -> IssueFixture {
    issue(title).kind("risk")
}

impl IssueFixture {
    /// Set the issue kind. Becomes a `flow:<value>` structured tag on build.
    pub fn kind(mut self, kind: &str) -> Self {
        self.kind = kind.to_string();
        self
    }

    pub fn status(mut self, status: &str) -> Self {
        self.status = status.to_string();
        self
    }

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

    pub fn with_link(mut self, target: &str, relationship: &str) -> Self {
        self.links
            .push((target.to_string(), relationship.to_string()));
        self
    }

    /// Add a pre-existing `relates:` target (cross-kind flat reference).
    pub fn with_relates(mut self, target: &str) -> Self {
        self.relates_targets.push(target.to_string());
        self
    }

    pub fn with_event(mut self, action: &str, from: Option<&str>, to: Option<&str>) -> Self {
        self.events.push((
            "2026-01-01T00:00:00Z".to_string(),
            action.to_string(),
            from.map(|s| s.to_string()),
            to.map(|s| s.to_string()),
        ));
        self
    }

    pub fn with_timestamped_event(
        mut self,
        timestamp: &str,
        action: &str,
        from: Option<&str>,
        to: Option<&str>,
    ) -> Self {
        self.events.push((
            timestamp.to_string(),
            action.to_string(),
            from.map(|s| s.to_string()),
            to.map(|s| s.to_string()),
        ));
        self
    }

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

    /// Set the issue priority. Becomes a `priority:<value>` structured tag (lowercased).
    pub fn priority(mut self, priority: &str) -> Self {
        self.priority = Some(priority.to_string());
        self
    }

    /// Set the issue size. Becomes a `size:<value>` structured tag (lowercased).
    pub fn size(mut self, size: &str) -> Self {
        self.size = Some(size.to_string());
        self
    }

    pub fn tags(mut self, tags: &str) -> Self {
        self.tags = tags
            .split(',')
            .map(|t| t.trim().to_string())
            .filter(|t| !t.is_empty())
            .collect();
        self
    }

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

    /// Build the domain `Issue`. Panics on invalid field values.
    /// `auto_id` is the fallback id used when [`with_id`](Self::with_id) was
    /// not set; if `with_id` was set, `auto_id` is ignored.
    pub fn build(self, auto_id: IssueRef) -> Issue {
        use crate::domain::model::tag::Tag;
        use crate::domain::model::tag_list::TagList;

        let id = match self.id {
            Some(ref s) => {
                IssueRef::new(s).unwrap_or_else(|_| panic!("IssueFixture: invalid id {s:?}"))
            }
            None => auto_id,
        };
        let status = Status::new(&self.status)
            .unwrap_or_else(|_| panic!("IssueFixture: invalid status {:?}", self.status));
        // Inject structured tags first so they appear before free-form tags.
        let mut staged: Vec<Tag> = Vec::new();
        if !self.kind.is_empty() {
            let v = format!("flow:{}", self.kind);
            staged.push(
                Tag::new(&v)
                    .unwrap_or_else(|_| panic!("IssueFixture: invalid kind {:?}", self.kind)),
            );
        }
        if let Some(ref p) = self.priority {
            let v = format!("priority:{}", p.to_lowercase());
            staged.push(
                Tag::new(&v).unwrap_or_else(|_| panic!("IssueFixture: invalid priority {p:?}")),
            );
        }
        if let Some(ref s) = self.size {
            let v = format!("size:{}", s.to_lowercase());
            staged
                .push(Tag::new(&v).unwrap_or_else(|_| panic!("IssueFixture: invalid size {s:?}")));
        }
        for raw in &self.tags {
            staged.push(
                Tag::new(raw).unwrap_or_else(|_| panic!("IssueFixture: invalid tag {raw:?}")),
            );
        }
        let tag_list: TagList = staged.into_iter().collect();
        let content = self.body.as_deref().map(Body::new).unwrap_or_default();
        let mut link_list = crate::domain::model::issue::IssueLinks::new();
        for (target, relationship) in &self.links {
            link_list.push(issue_link(target, relationship));
        }
        let event_log: crate::domain::model::event::EventLog = self
            .events
            .iter()
            .map(|(timestamp, action, from, to)| {
                use crate::domain::model::event::State;
                use crate::domain::model::event::{Event, EventAction};
                use crate::domain::model::temporal::timestamp::Timestamp;
                let mk_state =
                    |s: &str| State::new(s).unwrap_or_else(|_| State::new("open").unwrap());
                let action = match action.as_str() {
                    "created" => EventAction::Created {
                        state: mk_state(from.as_deref().unwrap_or("open")),
                    },
                    "status_changed" => EventAction::StatusChanged {
                        from: mk_state(from.as_deref().unwrap_or("open")),
                        to: mk_state(to.as_deref().unwrap_or("open")),
                    },
                    _ => EventAction::Created {
                        state: mk_state(from.as_deref().unwrap_or("open")),
                    },
                };
                Event {
                    timestamp: Timestamp::new(timestamp).unwrap_or_else(|_| {
                        panic!("IssueFixture: invalid timestamp {timestamp:?}")
                    }),
                    action,
                }
            })
            .collect();
        let date = self
            .date
            .as_deref()
            .map(|d| {
                crate::domain::model::temporal::iso_date::IsoDate::new(d)
                    .unwrap_or_else(|_| panic!("IssueFixture: invalid date {d:?}"))
            })
            .unwrap_or_else(|| {
                crate::domain::model::temporal::iso_date::IsoDate::new("2026-01-01").unwrap()
            });
        Issue {
            id,
            title: Title::new(&self.title)
                .unwrap_or_else(|_| panic!("IssueFixture: invalid title {:?}", self.title)),
            description: None,
            status,
            date,
            tags: tag_list,
            aliases: Vec::new(),
            content,
            events: event_log,
            links: link_list,
            relates: self
                .relates_targets
                .iter()
                .map(|s| {
                    crate::domain::model::entity_ref::EntityRef::new(s)
                        .unwrap_or_else(|_| panic!("IssueFixture: invalid relates target {s:?}"))
                })
                .collect(),
            assignee: None,
            due_date: None,
            tracker: crate::domain::model::issue::Tracker::local("ISSUE-0000"),
            origin: EntryOrigin::Local,
            location: EntryLocator::default(),
        }
    }
}

// ── Link builder ─────────────────────────────────────────────────────────────

pub fn issue_link(target: &str, relationship: &str) -> crate::domain::model::issue::IssueLink {
    use crate::domain::model::issue::{IssueLink, IssueRelationship};
    IssueLink {
        target: IssueRef::new(target)
            .unwrap_or_else(|_| panic!("issue_link: invalid target {target:?}")),
        relationship: relationship
            .parse::<IssueRelationship>()
            .unwrap_or_else(|_| panic!("issue_link: unknown relationship {relationship:?}")),
    }
}

// ── Event builders ────────────────────────────────────────────────────────────

pub fn status_changed_event(from: &str, to: &str) -> crate::domain::model::event::Event {
    use crate::domain::model::event::{Event, EventAction, State};
    use crate::domain::model::temporal::timestamp::Timestamp;
    Event {
        timestamp: Timestamp::new("2026-01-01T00:00:00Z").unwrap(),
        action: EventAction::StatusChanged {
            from: State::new(from).unwrap(),
            to: State::new(to).unwrap(),
        },
    }
}

pub fn other_event() -> crate::domain::model::event::Event {
    use crate::domain::model::event::{Event, EventAction, State};
    use crate::domain::model::temporal::timestamp::Timestamp;
    Event {
        timestamp: Timestamp::new("2026-01-01T00:00:00Z").unwrap(),
        action: EventAction::Created {
            state: State::new("open").unwrap(),
        },
    }
}

// ── Enrichment helper ─────────────────────────────────────────────────────────

pub fn enrich_issue(issue: &mut Issue, statuses: &crate::domain::model::status::StatusesConfig) {
    if let Ok(resolved) = statuses.resolve(&issue.status.name.clone()) {
        issue.status = resolved;
    }
    // Events carry State (raw string names), not Status — no per-event projection.
}

// ── Filter DSL ────────────────────────────────────────────────────────────────

/// Fluent builder for `list_issues` / `compute_issue_stats` filters.
///
/// `kind` becomes a `flow:<value>` tag filter on resolution.
#[derive(Default)]
pub struct Filter {
    pub(crate) status: Option<String>,
    pub(crate) tags: Vec<String>,
}

impl Filter {
    pub fn none() -> Self {
        Self::default()
    }

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

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

    /// Add a `flow:<value>` tag filter (legacy DSL convenience).
    pub fn kind(mut self, kind: &str) -> Self {
        self.tags.push(format!("flow:{kind}"));
        self
    }

    pub fn tags(mut self, tags: &str) -> Self {
        for t in tags.split(',').map(|t| t.trim()).filter(|t| !t.is_empty()) {
            self.tags.push(t.to_string());
        }
        self
    }

    pub fn resolved_status(&self) -> Option<Status> {
        self.status
            .as_deref()
            .map(|s| Status::new(s).unwrap_or_else(|_| panic!("Filter: invalid status {s:?}")))
    }

    pub fn resolved_tag_filters(&self) -> Vec<crate::domain::model::tag_filter::TagFilter> {
        use crate::domain::model::tag_filter::TagFilter;
        self.tags
            .iter()
            .map(|t| TagFilter::parse(t).unwrap_or_else(|_| panic!("Filter: invalid tag {t:?}")))
            .collect()
    }
}