cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
use crate::domain::model::event::{Event, EventAction};
use crate::domain::model::issue::IssueEdit;
use crate::domain::model::record_ref::IssueRef;
use crate::domain::model::status::Status;
use crate::domain::usecases::edit::issue::{commit_issue_edits, lower_status_transition};
use crate::domain::usecases::issue::IssueRepository;

/// The result of an `update_issue` call.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpdateIssueOutcome {
    /// Status was changed from `from` to `to`.
    StatusChanged { from: Status, to: Status },
    /// The requested status is already the current status — no event appended.
    NoOp,
    /// An event was appended (non-status events are not further classified).
    EventAppended,
}

/// Append a `StatusChanged` event to an existing issue.
///
/// Returns `NoOp` when a `StatusChanged` event targets the current status.
/// Returns `Err` if the issue is not found.
pub fn update_issue(
    repo: &dyn IssueRepository,
    id: &IssueRef,
    event: Event,
) -> anyhow::Result<UpdateIssueOutcome> {
    let issue = repo
        .find_by_id(id)?
        .ok_or_else(|| anyhow::anyhow!("issue {id} not found"))?;

    if let EventAction::StatusChanged { to, .. } = &event.action {
        if issue.status.as_str() == to.as_str() {
            return Ok(UpdateIssueOutcome::NoOp);
        }
        let from = issue.status.clone();
        let to_status = Status::unresolved(to.as_str());
        let edits = lower_status_transition(&issue, to_status.clone(), event.timestamp.clone());
        commit_issue_edits(repo, edits)?;
        return Ok(UpdateIssueOutcome::StatusChanged {
            from,
            to: to_status,
        });
    }

    commit_issue_edits(
        repo,
        vec![IssueEdit::AppendEvent {
            issue: issue.id.clone(),
            event,
        }],
    )?;
    Ok(UpdateIssueOutcome::EventAppended)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::model::record_ref::IssueRef;
    use crate::domain::usecases::issue::tests::{
        feature, other_event, status_changed_event, FakeIssueRepository, IssueFixture,
    };

    #[test]
    fn status_change_transitions_the_issue_status() {
        scenario()
            .given(feature("Add login").with_id("ISSUE-0001").status("open"))
            .when_status_changed("ISSUE-0001", "open", "in-progress")
            .then_saved_status("in-progress");
    }

    #[test]
    fn status_change_to_current_status_is_a_noop() {
        scenario()
            .given(feature("Add login").with_id("ISSUE-0001").status("open"))
            .when_status_changed("ISSUE-0001", "open", "open")
            .then_noop();
    }

    #[test]
    fn updating_an_unknown_issue_returns_an_error() {
        scenario()
            .when_status_changed("ISSUE-0099", "open", "closed")
            .then_err_contains("not found");
    }

    #[test]
    fn created_event_is_appended_without_changing_status() {
        scenario()
            .given(feature("Add login").with_id("ISSUE-0001").status("open"))
            .when_event("ISSUE-0001", other_event())
            .then_saved_has_event("created")
            .then_status_unchanged("open");
    }

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

    struct Scenario {
        repo: FakeIssueRepository,
    }

    impl Scenario {
        fn given(mut self, fixture: IssueFixture) -> Self {
            let raw = fixture
                .id
                .as_deref()
                .expect("given() requires an explicit id")
                .to_string();
            let numeric = IssueRef::new(&raw).unwrap();
            self.repo.push_issue(fixture.build(numeric));
            self
        }

        fn when_status_changed(self, id: &str, from: &str, to: &str) -> UpdateOutcome {
            let issue_ref = IssueRef::new(id).unwrap();
            let result = update_issue(&self.repo, &issue_ref, status_changed_event(from, to));
            UpdateOutcome {
                repo: self.repo,
                result,
            }
        }

        fn when_event(self, id: &str, event: Event) -> UpdateOutcome {
            let issue_ref = IssueRef::new(id).unwrap();
            let result = update_issue(&self.repo, &issue_ref, event);
            UpdateOutcome {
                repo: self.repo,
                result,
            }
        }
    }

    struct UpdateOutcome {
        repo: FakeIssueRepository,
        result: anyhow::Result<UpdateIssueOutcome>,
    }

    impl UpdateOutcome {
        fn then_saved_status(self, expected: &str) -> Self {
            let outcome = self.result.as_ref().expect("expected Ok");
            assert!(
                matches!(outcome, UpdateIssueOutcome::StatusChanged { to, .. } if to.as_str() == expected),
                "expected StatusChanged to {expected:?}, got {outcome:?}"
            );
            let saved = self.repo.last_saved().expect("expected a save");
            assert_eq!(saved.status.as_str(), expected);
            self
        }

        fn then_saved_has_event(self, action: &str) -> Self {
            assert_eq!(
                self.result.as_ref().expect("expected Ok"),
                &UpdateIssueOutcome::EventAppended
            );
            let saved = self.repo.last_saved().expect("expected a save");
            assert!(
                saved.events.iter().any(|e| e.action.as_str() == action),
                "expected event {action:?}"
            );
            self
        }

        fn then_status_unchanged(self, expected: &str) -> Self {
            let saved = self.repo.last_saved().expect("expected a save");
            assert_eq!(saved.status.as_str(), expected);
            self
        }

        fn then_noop(self) -> Self {
            assert_eq!(
                self.result.as_ref().expect("expected Ok"),
                &UpdateIssueOutcome::NoOp
            );
            assert!(self.repo.last_saved().is_none());
            self
        }

        fn then_err_contains(self, substring: &str) {
            let msg = self.result.expect_err("expected Err").to_string();
            assert!(msg.contains(substring), "expected {substring:?} in {msg:?}");
        }
    }
}