use std::path::PathBuf;
use anyhow::Context;
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::companion::{
canonical::{DESIGN_DECISION_FILENAME, IMPLEMENTATION_NOTES_FILENAME, PLAN_FILENAME},
CompanionContent, CompanionIdentifier, CompanionKind, IssueCompanion, IssueCompanions,
};
use crate::domain::model::issue::{Issue, IssueCollection};
use crate::domain::model::record_ref::IssueRef;
use crate::domain::model::status::{Status, StatusesConfig};
use crate::domain::model::tag::Tag;
use crate::domain::model::tag_list::TagList;
use crate::domain::model::title::Title;
use crate::domain::usecases::issue::content_reader::{IssueContentReader, IssueContentSet};
use crate::domain::usecases::issue::IssueRepository;
use crate::infra::driven::fs::frontmatter::parse_description;
use crate::infra::driven::fs::repository_pipeline::{self, FileSystemRecord};
use crate::infra::driven::fs::schema_parsing::{
parse_entity_ref_for_schema, parse_issue_ref_for_schema,
};
pub struct FsIssueRepository {
pub dir: PathBuf,
pub union: Vec<PathBuf>,
pub id_prefix: Option<String>,
pub statuses: StatusesConfig,
pub schema_version: u32,
}
impl FileSystemRecord for Issue {
type Raw = crate::infra::serde_support::RawIssueFrontmatter;
type ParseCtx = ();
type EnrichCtx = crate::domain::model::status::StatusesConfig;
fn from_raw(
raw: Self::Raw,
body: String,
path: &std::path::Path,
schema_version: u32,
_ctx: &Self::ParseCtx,
) -> anyhow::Result<(Self, Vec<crate::infra::serde_support::RawEvent>)> {
let raw_id = raw.id.as_deref().context("missing 'id'")?;
let id = parse_issue_ref_for_schema(raw_id, schema_version).context("bad id")?;
let title = Title::new(raw.title.as_deref().context("missing 'title'")?)
.map_err(|e| anyhow::anyhow!("invalid title: {e}"))?;
let status = Status::unresolved(raw.status.as_deref().context("missing 'status'")?);
let date = raw
.date
.as_deref()
.context("missing 'date'")?
.parse()
.context("bad date")?;
let tags: TagList = raw
.tags
.iter()
.map(|raw_tag| {
Tag::new(raw_tag).map_err(|_| {
anyhow::anyhow!("invalid tag '{raw_tag}': must match [a-z0-9][a-z0-9-]*")
})
})
.collect::<anyhow::Result<_>>()?;
let mut links = crate::domain::model::issue::IssueLinks::new();
for entry in raw.links {
let target = parse_issue_ref_for_schema(&entry.id, schema_version)
.with_context(|| format!("bad link target '{}'", entry.id))?;
let relationship = entry
.relationship
.parse()
.context("bad link relationship")?;
links.push(crate::domain::model::issue::IssueLink {
target,
relationship,
});
}
let assignee = raw
.assignee
.as_deref()
.and_then(|v| crate::domain::model::issue::Assignee::new(v).ok());
let due_date = raw.due_date.as_deref().and_then(|v| v.parse().ok());
let tracker = raw
.tracker
.as_deref()
.and_then(|v| crate::domain::model::issue::Tracker::new(v).ok())
.unwrap_or_else(|| {
crate::domain::model::issue::Tracker::local(id.to_string().as_str())
});
let description = parse_description(raw.description.as_deref(), path)?;
let relates: crate::domain::model::relates::Relates = raw
.relates
.iter()
.map(|s| {
parse_entity_ref_for_schema(s, schema_version)
.with_context(|| format!("bad relates target '{s}'"))
})
.collect::<anyhow::Result<_>>()?;
let issue = Issue {
id,
title,
description,
status,
date,
tags,
aliases: raw.aliases.clone(),
content: Body::new(&body),
events: crate::domain::model::issue::EventLog::new(),
links,
relates,
assignee,
due_date,
tracker,
origin: EntryOrigin::Local,
location: EntryLocator::new(format!("file://{}", path.display())),
};
Ok((issue, raw.events))
}
fn enrich(
&mut self,
raw_events: Vec<crate::infra::serde_support::RawEvent>,
statuses: &crate::domain::model::status::StatusesConfig,
) -> Vec<super::enrich::EnrichWarning> {
super::enrich::enrich_record(&mut self.status, &mut self.events, raw_events, statuses)
}
}
use super::fs_utils::find_subdir;
impl FsIssueRepository {
pub fn issue_dir(&self, id: &IssueRef) -> Option<std::path::PathBuf> {
find_subdir(&self.dir, id.suffix())
}
}
impl IssueRepository for FsIssueRepository {
fn save(&self, issue: &Issue) -> anyhow::Result<()> {
let content = super::canonical::issue_canonical(issue)?;
let record_dir = repository_pipeline::save_index(
&self.dir,
issue.id.suffix(),
issue.title.as_str(),
&content,
)?;
super::events_jsonl::write_events_jsonl_to(&record_dir, &issue.events)
}
fn list(&self) -> anyhow::Result<IssueCollection> {
let mut out = repository_pipeline::list::<Issue>(
&self.dir,
self.schema_version,
&self.statuses,
&(),
)?;
for source in &self.union {
let source_label = source.display().to_string();
let mut union_issues = repository_pipeline::list::<Issue>(
source,
self.schema_version,
&self.statuses,
&(),
)?;
for issue in &mut union_issues {
issue.origin = EntryOrigin::Union {
name: source_label.clone(),
};
}
out.extend(union_issues);
}
self.validate_union_prefix(out.iter())?;
Ok(IssueCollection::new(out))
}
fn find_by_id(&self, id: &IssueRef) -> anyhow::Result<Option<Issue>> {
self.validate_union_prefix(self.list()?.iter())?;
if let Some(found) = repository_pipeline::find_by_id::<Issue>(
&self.dir,
id.suffix(),
self.schema_version,
&self.statuses,
&(),
)? {
return Ok(Some(found));
}
for source in &self.union {
if let Some(mut found) = repository_pipeline::find_by_id::<Issue>(
source,
id.suffix(),
self.schema_version,
&self.statuses,
&(),
)? {
found.origin = EntryOrigin::Union {
name: source.display().to_string(),
};
return Ok(Some(found));
}
}
Ok(None)
}
fn issue_companions(&self, id: &IssueRef) -> anyhow::Result<IssueCompanions> {
let Some(dir) = self.issue_dir(id) else {
return Ok(IssueCompanions::new());
};
let entries = match std::fs::read_dir(&dir) {
Ok(it) => it,
Err(_) => return Ok(IssueCompanions::new()),
};
let mut out: Vec<IssueCompanion> = Vec::new();
for entry in entries.flatten() {
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
if name == "index.md" || name == "events.jsonl" {
continue;
}
let Ok(identifier) = CompanionIdentifier::new(&name) else {
continue;
};
out.push(IssueCompanion::new(identifier, classify(&name)));
}
out.sort_by(|a, b| a.identifier.as_str().cmp(b.identifier.as_str()));
Ok(out.into_iter().collect())
}
fn read_companion(
&self,
id: &IssueRef,
identifier: &CompanionIdentifier,
) -> anyhow::Result<Option<CompanionContent>> {
let Some(dir) = self.issue_dir(id) else {
return Ok(None);
};
let path = dir.join(identifier.as_str());
if !path.is_file() {
return Ok(None);
}
let content = match classify(identifier.as_str()) {
CompanionKind::Plan
| CompanionKind::ImplementationNotes
| CompanionKind::DesignDecision => {
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("reading companion {}", path.display()))?;
CompanionContent::Text(Body::new(raw))
}
CompanionKind::Other => {
let bytes = std::fs::read(&path)
.with_context(|| format!("reading companion {}", path.display()))?;
CompanionContent::Binary(bytes)
}
};
Ok(Some(content))
}
fn configured_id_prefix(&self) -> Option<&str> {
self.id_prefix.as_deref()
}
}
impl FsIssueRepository {
fn validate_union_prefix<'a, I: IntoIterator<Item = &'a Issue>>(
&self,
issues: I,
) -> anyhow::Result<()> {
let Some(prefix) = self.id_prefix.as_deref() else {
return Ok(());
};
let mismatches: Vec<String> = issues
.into_iter()
.filter(|i| i.origin.is_union())
.filter(|i| !i.id.as_str().starts_with(prefix))
.map(|i| {
let name = match &i.origin {
EntryOrigin::Union { name } => name.as_str(),
_ => "",
};
format!("{} (from {name})", i.id)
})
.collect();
if mismatches.is_empty() {
Ok(())
} else {
anyhow::bail!(
"union source for issues contains record(s) whose id does not match id_prefix '{}': {}",
prefix,
mismatches.join(", ")
)
}
}
}
fn classify(name: &str) -> CompanionKind {
match name {
PLAN_FILENAME => CompanionKind::Plan,
IMPLEMENTATION_NOTES_FILENAME => CompanionKind::ImplementationNotes,
DESIGN_DECISION_FILENAME => CompanionKind::DesignDecision,
_ => CompanionKind::Other,
}
}
impl IssueContentReader for FsIssueRepository {
fn content_of(&self, id: &IssueRef) -> anyhow::Result<Option<IssueContentSet>> {
let Some(dir) = self.issue_dir(id) else {
return Ok(None);
};
let mut parts = Vec::new();
let mut references = Vec::new();
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
if !entry.file_type()?.is_file() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
parts.push(name.clone());
if name.ends_with(".md") {
let content = std::fs::read_to_string(entry.path())?;
let targets = super::markdown_links::extract_relative_targets(&content);
references.push((name, targets));
}
}
Ok(Some(IssueContentSet { parts, references }))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn ir(n: u32) -> IssueRef {
IssueRef::new(format!("ISSUE-{n:04}")).unwrap()
}
fn parse_enrich(path: &std::path::Path) -> anyhow::Result<Issue> {
use crate::infra::driven::fs::enrich::enrich_record;
use crate::infra::driven::fs::repository_pipeline::parse_one;
let (mut issue, raw_events) = parse_one::<Issue>(path, 3, &())?;
let statuses = crate::domain::model::status::StatusesConfig::default_issue();
enrich_record(&mut issue.status, &mut issue.events, raw_events, &statuses);
Ok(issue)
}
fn write_issue(dir: &std::path::Path, slug: &str, id: &str, title: &str, status: &str) {
let issue_dir = dir.join(slug);
fs::create_dir_all(&issue_dir).unwrap();
let content = format!(
"---\nid: {id}\ntitle: {title}\nstatus: {status}\ndate: 2026-01-01\ntags:\n - flow:feature\n---\n\nBody.\n"
);
fs::write(issue_dir.join("index.md"), content).unwrap();
}
#[test]
fn list_returns_empty_when_dir_does_not_exist() {
let repo = FsIssueRepository {
dir: PathBuf::from("/tmp/nonexistent-cartulary-issues-xyz"),
union: vec![],
id_prefix: None,
statuses: crate::domain::model::status::StatusesConfig::default_issue(),
schema_version: 3,
};
assert!(repo.list().unwrap().is_empty());
}
#[test]
fn list_returns_empty_when_dir_is_empty() {
let tmp = TempDir::new().unwrap();
let repo = FsIssueRepository {
dir: tmp.path().to_path_buf(),
union: vec![],
id_prefix: None,
statuses: crate::domain::model::status::StatusesConfig::default_issue(),
schema_version: 3,
};
assert!(repo.list().unwrap().is_empty());
}
#[test]
fn list_parses_all_issue_subdirs() {
let tmp = TempDir::new().unwrap();
write_issue(
tmp.path(),
"0001-add-feature",
"ISSUE-0001",
"Add feature",
"open",
);
write_issue(
tmp.path(),
"0002-fix-bug",
"ISSUE-0002",
"Fix bug",
"closed",
);
let repo = FsIssueRepository {
dir: tmp.path().to_path_buf(),
union: vec![],
id_prefix: Some("ISSUE-".to_string()),
statuses: crate::domain::model::status::StatusesConfig::default_issue(),
schema_version: 3,
};
let mut result = repo.list().unwrap().into_vec();
result.sort_by(|a, b| a.id.cmp(&b.id));
assert_eq!(result.len(), 2);
assert_eq!(result[0].id, ir(1));
assert_eq!(result[0].title, Title::new("Add feature").unwrap());
assert_eq!(result[0].status, Status::new("open").unwrap());
assert_eq!(result[1].status, Status::new("closed").unwrap());
}
#[test]
fn list_silently_skips_invalid_index_files() {
let tmp = TempDir::new().unwrap();
write_issue(
tmp.path(),
"0001-add-feature",
"ISSUE-0001",
"Add feature",
"open",
);
let broken_dir = tmp.path().join("0002-broken");
fs::create_dir_all(&broken_dir).unwrap();
fs::write(broken_dir.join("index.md"), "# No frontmatter\n").unwrap();
let repo = FsIssueRepository {
dir: tmp.path().to_path_buf(),
union: vec![],
id_prefix: None,
statuses: crate::domain::model::status::StatusesConfig::default_issue(),
schema_version: 3,
};
let result = repo.list().unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn list_ignores_subdirs_without_index_md() {
let tmp = TempDir::new().unwrap();
write_issue(
tmp.path(),
"0001-add-feature",
"ISSUE-0001",
"Add feature",
"open",
);
fs::create_dir_all(tmp.path().join("attachments")).unwrap();
let repo = FsIssueRepository {
dir: tmp.path().to_path_buf(),
union: vec![],
id_prefix: None,
statuses: crate::domain::model::status::StatusesConfig::default_issue(),
schema_version: 3,
};
let result = repo.list().unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn parse_issue_file_strips_yaml_quotes_from_title() {
let tmp = TempDir::new().unwrap();
let issue_dir = tmp.path().join("0001-test");
fs::create_dir_all(&issue_dir).unwrap();
let path = issue_dir.join("index.md");
fs::write(
&path,
"---\nid: ISSUE-0001\ntitle: \"Quoted title\"\nstatus: open\ndate: 2026-01-01\n---\n",
)
.unwrap();
let issue = parse_enrich(&path).unwrap();
assert_eq!(issue.title, Title::new("Quoted title").unwrap());
}
#[test]
fn parse_issue_file_ignores_legacy_type_priority_size_scalars() {
let tmp = TempDir::new().unwrap();
let issue_dir = tmp.path().join("0001-legacy");
fs::create_dir_all(&issue_dir).unwrap();
let path = issue_dir.join("index.md");
fs::write(
&path,
"---\nid: ISSUE-0001\ntitle: T\ntype: feature\nstatus: open\npriority: High\nsize: M\ndate: 2026-01-01\n---\n",
)
.unwrap();
let issue = parse_enrich(&path).unwrap();
assert_eq!(issue.title, Title::new("T").unwrap());
assert!(issue.tags.is_empty());
}
#[test]
fn parse_issue_file_reads_description_when_present() {
let tmp = TempDir::new().unwrap();
let issue_dir = tmp.path().join("0001-with-desc");
fs::create_dir_all(&issue_dir).unwrap();
let path = issue_dir.join("index.md");
fs::write(
&path,
"---\nid: ISSUE-0001\ntitle: T\ndescription: \"a one-line summary\"\nstatus: open\ndate: 2026-01-01\n---\n",
)
.unwrap();
let issue = parse_enrich(&path).unwrap();
assert_eq!(
issue.description.as_ref().map(|d| d.as_str()),
Some("a one-line summary")
);
}
#[test]
fn parse_issue_file_treats_missing_description_as_none() {
let tmp = TempDir::new().unwrap();
let issue_dir = tmp.path().join("0001-no-desc");
fs::create_dir_all(&issue_dir).unwrap();
let path = issue_dir.join("index.md");
fs::write(
&path,
"---\nid: ISSUE-0001\ntitle: T\nstatus: open\ndate: 2026-01-01\n---\n",
)
.unwrap();
let issue = parse_enrich(&path).unwrap();
assert_eq!(issue.description, None);
}
#[test]
fn parse_issue_file_accepts_in_progress_status() {
let tmp = TempDir::new().unwrap();
let issue_dir = tmp.path().join("0001-wip");
fs::create_dir_all(&issue_dir).unwrap();
let path = issue_dir.join("index.md");
fs::write(
&path,
"---\nid: ISSUE-0001\ntitle: WIP\nstatus: in-progress\ndate: 2026-01-01\n---\n",
)
.unwrap();
let issue = parse_enrich(&path).unwrap();
assert_eq!(issue.status, Status::new("in-progress").unwrap());
}
#[test]
fn parse_issue_file_with_events_v2_format() {
let tmp = TempDir::new().unwrap();
let issue_dir = tmp.path().join("0001-with-events");
fs::create_dir_all(&issue_dir).unwrap();
let path = issue_dir.join("index.md");
fs::write(
&path,
indoc::indoc! {"
---
id: ISSUE-0001
title: Issue with events
status: open
date: 2026-01-01
events:
- timestamp: \"2026-03-09T14:30:45Z\"
action:
name: created
status: open
- timestamp: \"2026-03-10T09:15:22Z\"
action:
name: status_changed
from: open
to: in-progress
---
"},
)
.unwrap();
let issue = parse_enrich(&path).unwrap();
assert_eq!(issue.title, Title::new("Issue with events").unwrap());
assert_eq!(issue.events.len(), 2);
assert!(issue.events[0].action.is_created());
assert!(issue.events[1].action.is_status_changed());
}
#[test]
fn parse_issue_file_without_events() {
let tmp = TempDir::new().unwrap();
let issue_dir = tmp.path().join("0001-no-events");
fs::create_dir_all(&issue_dir).unwrap();
let path = issue_dir.join("index.md");
fs::write(
&path,
"---\nid: ISSUE-0001\ntitle: Issue without events\nstatus: closed\ndate: 2026-01-01\n---\n",
)
.unwrap();
let issue = parse_enrich(&path).unwrap();
assert!(issue.events.is_empty());
}
#[test]
fn ulid_generator_returns_ulid_shape() {
use crate::domain::usecases::issue::IssueIdGenerator;
let gen = crate::infra::driven::fs::id_generator::UlidIssueIdGenerator {
id_prefix: "ISSUE".to_string(),
};
let id = gen.next_id().unwrap();
assert_eq!(id.prefix(), "ISSUE");
assert_eq!(id.suffix().len(), 26);
}
#[test]
fn save_creates_dir_and_file_with_correct_content() {
use crate::domain::usecases::issue::IssueIdGenerator;
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("docs").join("issues");
let repo = FsIssueRepository {
dir: dir.clone(),
union: vec![],
id_prefix: Some("ISSUE-".to_string()),
statuses: crate::domain::model::status::StatusesConfig::default_issue(),
schema_version: 4,
};
let id = crate::infra::driven::fs::id_generator::UlidIssueIdGenerator {
id_prefix: "ISSUE".to_string(),
}
.next_id()
.unwrap();
let id_str = id.to_string();
let suffix = id.suffix().to_string();
let issue = Issue {
id,
title: Title::new("Add login").unwrap(),
description: None,
status: Status::new("open").unwrap(),
date: crate::domain::model::temporal::iso_date::IsoDate::new("2026-03-11").unwrap(),
tags: TagList::new(),
aliases: Vec::new(),
content: Body::new("As a user I want to log in."),
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(),
};
repo.save(&issue).unwrap();
let index = dir.join(format!("{suffix}-add-login")).join("index.md");
assert!(index.exists(), "missing {}", index.display());
let content = fs::read_to_string(&index).unwrap();
assert!(content.contains("title: \"Add login\""));
assert!(content.contains("status: open"));
assert!(content.contains("As a user I want to log in."));
assert!(content.contains(&format!("id: {id_str}")));
assert!(!content.contains("type:"), "should not emit legacy type:");
}
#[test]
fn find_by_id_returns_matching_issue() {
let tmp = TempDir::new().unwrap();
write_issue(
tmp.path(),
"0001-add-feature",
"ISSUE-0001",
"Add feature",
"open",
);
write_issue(
tmp.path(),
"0002-fix-bug",
"ISSUE-0002",
"Fix bug",
"closed",
);
let repo = FsIssueRepository {
dir: tmp.path().to_path_buf(),
union: vec![],
id_prefix: Some("ISSUE-".to_string()),
statuses: crate::domain::model::status::StatusesConfig::default_issue(),
schema_version: 3,
};
let result = repo.find_by_id(&ir(1)).unwrap();
assert!(result.is_some());
let issue = result.unwrap();
assert_eq!(issue.title, Title::new("Add feature").unwrap());
assert_eq!(issue.status, Status::new("open").unwrap());
}
#[test]
fn find_by_id_returns_none_for_unknown_id() {
let tmp = TempDir::new().unwrap();
write_issue(
tmp.path(),
"0001-add-feature",
"ISSUE-0001",
"Add feature",
"open",
);
let repo = FsIssueRepository {
dir: tmp.path().to_path_buf(),
union: vec![],
id_prefix: Some("ISSUE-".to_string()),
statuses: crate::domain::model::status::StatusesConfig::default_issue(),
schema_version: 3,
};
let result = repo.find_by_id(&ir(99)).unwrap();
assert!(result.is_none());
}
#[test]
fn fs_issue_repository_satisfies_the_contract() {
use crate::domain::usecases::issue::test_support::contract;
fn fresh() -> (TempDir, FsIssueRepository) {
let tmp = TempDir::new().unwrap();
let repo = FsIssueRepository {
dir: tmp.path().to_path_buf(),
union: vec![],
id_prefix: None,
statuses: crate::domain::model::status::StatusesConfig::default_issue(),
schema_version: 3,
};
(tmp, repo)
}
let (_t, r) = fresh();
contract::empty_list_yields_no_issues(r);
let (_t, r) = fresh();
contract::find_by_id_unknown_returns_none(r);
let (_t, r) = fresh();
contract::saved_issue_is_findable_by_id(r);
let (_t, r) = fresh();
contract::saved_issue_appears_in_list(r);
let (_t, r) = fresh();
contract::saving_twice_keeps_latest(r);
let (_t, r) = fresh();
contract::configured_id_prefix_defaults_to_none(r);
}
#[test]
fn list_merges_dir_and_union_with_correct_origin() {
use crate::domain::model::entry_origin::EntryOrigin;
let tmp = TempDir::new().unwrap();
let local = tmp.path().join("docs/issues");
let shared = tmp.path().join("shared/issues");
write_issue(&local, "0001-local", "ISSUE-0001", "Local", "open");
write_issue(&shared, "0002-shared", "ISSUE-0002", "Shared", "open");
let repo = FsIssueRepository {
dir: local,
union: vec![shared.clone()],
id_prefix: Some("ISSUE-".to_string()),
statuses: crate::domain::model::status::StatusesConfig::default_issue(),
schema_version: 3,
};
let issues = repo.list().unwrap();
assert_eq!(issues.len(), 2);
let local_issue = issues.iter().find(|i| i.id == ir(1)).unwrap();
assert_eq!(local_issue.origin, EntryOrigin::Local);
let union_issue = issues.iter().find(|i| i.id == ir(2)).unwrap();
match &union_issue.origin {
EntryOrigin::Union { name } => assert_eq!(name, &shared.display().to_string()),
other => panic!("expected Union, got {other:?}"),
}
}
#[test]
fn union_record_with_mismatched_prefix_aborts_list() {
let tmp = TempDir::new().unwrap();
let local = tmp.path().join("docs/issues");
let shared = tmp.path().join("shared/issues");
write_issue(&local, "0001-local", "ISSUE-0001", "Local", "open");
write_issue(&shared, "0002-strange", "TASK-0002", "Strange", "open");
let repo = FsIssueRepository {
dir: local,
union: vec![shared],
id_prefix: Some("ISSUE-".to_string()),
statuses: crate::domain::model::status::StatusesConfig::default_issue(),
schema_version: 3,
};
let err = repo.list().unwrap_err().to_string();
assert!(err.contains("TASK-0002"), "got: {err}");
assert!(err.contains("ISSUE-"), "got: {err}");
}
}