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::decision_record::{DecisionRecord, DrStatus};
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::record_kind::RecordKind;
use crate::domain::model::temporal::timestamp::Timestamp;
use crate::domain::model::title::Title;
use crate::domain::usecases::decision_record::{
    DecisionRecordIdGenerator, DecisionRecordRepository,
};

/// Create a new decision record with the next available ID.
///
/// `now` is the precise UTC timestamp — supplied by the caller so the domain
/// remains clock-free. The calendar date (`DecisionRecord::date`) is derived
/// from `now` via `Timestamp::to_iso_date()`.
pub fn create_decision_record(
    repo: &dyn DecisionRecordRepository,
    id_gen: &dyn DecisionRecordIdGenerator,
    kind: RecordKind,
    title: Title,
    body: Body,
    now: Timestamp,
    links: crate::domain::model::decision_record::RecordLinks,
) -> anyhow::Result<DecisionRecord> {
    let id = id_gen.next_id()?;
    let initial = DrStatus::INITIAL;
    let record = DecisionRecord {
        id,
        kind,
        title,
        description: None,
        status: initial,
        date: now.to_iso_date(),
        tags: crate::domain::model::tag_list::TagList::new(),
        aliases: Vec::new(),
        content: body,
        events: [Event {
            timestamp: now,
            action: EventAction::Created {
                state: crate::domain::model::event::State::new(initial.as_str())
                    .expect("DrStatus names are valid State"),
            },
        }]
        .into_iter()
        .collect(),
        links,
        relates: crate::domain::model::relates::Relates::default(),
        origin: EntryOrigin::Local,
        location: EntryLocator::default(),
    };
    repo.save(&record)?;
    Ok(record)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::usecases::decision_record::tests::{
        adr, FakeDecisionRecordRepository, RecordFixture, SequentialDecisionRecordIdGenerator,
    };

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

    #[test]
    fn creating_a_record_sets_proposed_status_and_a_created_event() {
        scenario()
            .when_create(adr("Use Rust"), "2026-03-11")
            .then_status("proposed")
            .then_kind("adr")
            .then_date("2026-03-11")
            .then_has_created_event();
    }

    #[test]
    fn creating_a_record_with_an_empty_title_is_rejected() {
        let result = Title::new("  ");
        assert!(result.is_err());
    }

    // ── Scenario ──────────────────────────────────────────────────────────────

    fn scenario() -> Scenario {
        Scenario {
            repo: FakeDecisionRecordRepository::with_records(vec![]),
        }
    }

    struct Scenario {
        repo: FakeDecisionRecordRepository,
    }

    impl Scenario {
        fn when_create(self, fixture: RecordFixture, date: &str) -> CreateOutcome {
            let ts = format!("{date}T00:00:00Z");
            let now = Timestamp::new(&ts).unwrap_or_else(|_| panic!("invalid timestamp {ts:?}"));
            let kind = RecordKind::new(&fixture.kind)
                .unwrap_or_else(|_| panic!("invalid kind {:?}", fixture.kind));
            let title = Title::new(&fixture.title)
                .unwrap_or_else(|_| panic!("invalid title {:?}", fixture.title));
            let body = fixture.body.as_deref().map(Body::new).unwrap_or_default();
            let id_gen = SequentialDecisionRecordIdGenerator::starting_at(
                self.repo.list().unwrap().len() as u32 + 1,
            );
            let result = create_decision_record(
                &self.repo,
                &id_gen,
                kind,
                title,
                body,
                now,
                crate::domain::model::decision_record::RecordLinks::new(),
            );
            CreateOutcome { result }
        }
    }

    struct CreateOutcome {
        result: anyhow::Result<DecisionRecord>,
    }

    impl CreateOutcome {
        fn then_status(self, expected: &str) -> Self {
            let record = self.result.as_ref().expect("expected Ok, got Err");
            assert_eq!(record.status.as_str(), expected);
            self
        }

        fn then_kind(self, expected: &str) -> Self {
            let record = self.result.as_ref().expect("expected Ok, got Err");
            assert_eq!(record.kind.as_str(), expected);
            self
        }

        fn then_date(self, expected: &str) -> Self {
            let record = self.result.as_ref().expect("expected Ok, got Err");
            assert_eq!(record.date.as_str(), expected);
            self
        }

        fn then_has_created_event(self) -> Self {
            let record = self.result.as_ref().expect("expected Ok, got Err");
            assert_eq!(record.events.len(), 1);
            assert!(
                record.events[0].action.is_created(),
                "expected Created event, got {:?}",
                record.events[0].action
            );
            self
        }
    }
}