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::entry_locator::EntryLocator;
use crate::domain::model::entry_origin::EntryOrigin;
use crate::domain::model::event::{Event, EventAction};
use crate::domain::model::issue::{Issue, IssueLinks};
use crate::domain::model::status::Status;
use crate::domain::model::tag::Tag;
use crate::domain::model::tag_list::TagList;
use crate::domain::model::temporal::timestamp::Timestamp;
use crate::domain::model::title::Title;
use crate::domain::usecases::issue::{IssueIdGenerator, IssueRepository};

/// Create a new issue with the next available ID.
///
/// Structured tags (`flow:<value>`, `priority:<value>`, …) flow through
/// `extra_tags`; the use case does not single any one of them out.
/// Initial outgoing links flow through `links` so the persisted issue
/// is built and saved in one pass — no post-creation mutation.
#[allow(clippy::too_many_arguments)] // each parameter is a distinct domain concept
pub fn create_issue(
    repo: &dyn IssueRepository,
    id_gen: &dyn IssueIdGenerator,
    title: Title,
    body: Body,
    now: Timestamp,
    initial_status: Status,
    extra_tags: &[Tag],
    links: IssueLinks,
) -> anyhow::Result<Issue> {
    let id = id_gen.next_id()?;
    let tracker = crate::domain::model::issue::Tracker::local(&id.to_string());
    let tags: TagList = extra_tags.iter().cloned().collect();
    let issue = Issue {
        id,
        title,
        description: None,
        status: initial_status.clone(),
        date: now.to_iso_date(),
        tags,
        aliases: Vec::new(),
        content: body,
        events: [Event {
            timestamp: now.clone(),
            action: EventAction::Created {
                state: crate::domain::model::event::State::new(initial_status.as_str())
                    .expect("resolved status names are valid State"),
            },
        }]
        .into_iter()
        .collect(),
        links,
        relates: crate::domain::model::relates::Relates::default(),
        assignee: None,
        due_date: None,
        tracker,
        origin: EntryOrigin::Local,
        location: EntryLocator::default(),
    };
    repo.save(&issue)?;
    Ok(issue)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::usecases::issue::tests::{
        defect, feature, ir, FakeIssueRepository, IssueFixture, SequentialIssueIdGenerator,
    };

    #[test]
    fn create_sets_open_status_and_records_created_event() {
        scenario()
            .when_create(feature("Add login"), "2026-03-11")
            .then_status("open")
            .then_flow("feature")
            .then_date("2026-03-11")
            .then_has_created_event();
    }

    #[test]
    fn create_assigns_id_as_max_existing_plus_one() {
        scenario()
            .given(defect("Story A"))
            .given(defect("Bug C").with_id("ISSUE-0003"))
            .when_create(feature("New feature"), "2026-03-11")
            .then_id("ISSUE-0004");
    }

    #[test]
    fn create_rejects_empty_title() {
        scenario()
            .when_create_raw("  ", "2026-03-11")
            .then_rejected();
    }

    struct Scenario {
        repo: FakeIssueRepository,
    }

    fn scenario() -> Scenario {
        Scenario {
            repo: FakeIssueRepository::new(),
        }
    }

    impl Scenario {
        fn given(mut self, fixture: IssueFixture) -> Self {
            let id = self.repo.list().unwrap().len() as u64 + 1;
            self.repo.push_issue(fixture.build(ir(id)));
            self
        }

        fn id_gen_after(&self) -> SequentialIssueIdGenerator {
            let max = self
                .repo
                .list()
                .unwrap()
                .iter()
                .map(|i| i.id.numeric_id())
                .max()
                .unwrap_or(0);
            SequentialIssueIdGenerator::starting_at(max + 1)
        }

        fn when_create(self, fixture: IssueFixture, date: &str) -> CreateOutcome {
            let ts = format!("{date}T00:00:00Z");
            let now = Timestamp::new(&ts).unwrap_or_else(|_| panic!("invalid timestamp {ts:?}"));
            let title = Title::new(&fixture.title)
                .unwrap_or_else(|_| panic!("invalid title {:?}", fixture.title));
            let initial_status = Status::unresolved(
                crate::domain::model::status::StatusesConfig::default_issue().initial(),
            );
            let extra: Vec<Tag> = if fixture.kind.is_empty() {
                vec![]
            } else {
                vec![Tag::new(&format!("flow:{}", fixture.kind)).unwrap()]
            };
            let id_gen = self.id_gen_after();
            let result = create_issue(
                &self.repo,
                &id_gen,
                title,
                Body::default(),
                now,
                initial_status,
                &extra,
                crate::domain::model::issue::IssueLinks::new(),
            );
            CreateOutcome {
                result: result.map(Some).unwrap_or(None),
                error: None,
            }
        }

        fn when_create_raw(self, title: &str, date: &str) -> CreateOutcome {
            let ts = format!("{date}T00:00:00Z");
            let now = Timestamp::new(&ts).unwrap_or_else(|_| panic!("invalid timestamp {ts:?}"));
            match Title::new(title) {
                Err(e) => CreateOutcome {
                    result: None,
                    error: Some(e.to_string()),
                },
                Ok(t) => {
                    let initial_status = Status::unresolved(
                        crate::domain::model::status::StatusesConfig::default_issue().initial(),
                    );
                    let id_gen = self.id_gen_after();
                    let result = create_issue(
                        &self.repo,
                        &id_gen,
                        t,
                        Body::default(),
                        now,
                        initial_status,
                        &[],
                        crate::domain::model::issue::IssueLinks::new(),
                    );
                    CreateOutcome {
                        result: result.map(Some).unwrap_or(None),
                        error: None,
                    }
                }
            }
        }
    }

    struct CreateOutcome {
        result: Option<Issue>,
        error: Option<String>,
    }

    impl CreateOutcome {
        fn then_status(self, expected: &str) -> Self {
            let issue = self.result.as_ref().expect("expected issue to be created");
            assert_eq!(issue.status.as_str(), expected);
            self
        }

        fn then_flow(self, expected: &str) -> Self {
            let issue = self.result.as_ref().expect("expected issue to be created");
            let want = format!("flow:{expected}");
            assert!(
                issue.tags.iter().any(|t| t.as_str() == want),
                "expected tag {want:?}, got {:?}",
                issue.tags.iter().map(|t| t.as_str()).collect::<Vec<_>>()
            );
            self
        }

        fn then_date(self, expected: &str) -> Self {
            let issue = self.result.as_ref().expect("expected issue to be created");
            assert_eq!(issue.date.as_str(), expected);
            self
        }

        fn then_id(self, expected: &str) -> Self {
            let issue = self.result.as_ref().expect("expected issue to be created");
            assert_eq!(issue.id.as_str(), expected);
            self
        }

        fn then_has_created_event(self) -> Self {
            let issue = self.result.as_ref().expect("expected issue to be created");
            assert_eq!(issue.events.len(), 1);
            assert!(issue.events[0].action.is_created());
            self
        }

        fn then_rejected(self) -> Self {
            assert!(self.error.is_some() || self.result.is_none());
            self
        }
    }
}