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};
#[allow(clippy::too_many_arguments)] 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
}
}
}