use crate::domain::{
Dependency, DependencyType, Issue, IssueFilter, IssueId, IssueUpdate, NewIssue, SortPolicy,
};
use crate::error::Result;
use async_trait::async_trait;
use std::path::{Path, PathBuf};
pub mod in_memory;
#[async_trait]
pub trait IssueStorage: Send + Sync {
async fn create(&mut self, issue: NewIssue) -> Result<Issue>;
async fn get(&self, id: &IssueId) -> Result<Option<Issue>>;
async fn update(&mut self, id: &IssueId, updates: IssueUpdate) -> Result<Issue>;
async fn delete(&mut self, id: &IssueId) -> Result<()>;
async fn add_dependency(
&mut self,
from: &IssueId,
to: &IssueId,
dep_type: DependencyType,
) -> Result<()>;
async fn remove_dependency(&mut self, from: &IssueId, to: &IssueId) -> Result<()>;
async fn get_dependencies(&self, id: &IssueId) -> Result<Vec<Dependency>>;
async fn get_dependents(&self, id: &IssueId) -> Result<Vec<Dependency>>;
async fn has_cycle(&self, from: &IssueId, to: &IssueId) -> Result<bool>;
async fn get_dependency_tree(
&self,
id: &IssueId,
max_depth: Option<usize>,
) -> Result<Vec<(Dependency, usize)>>;
async fn list(&self, filter: &IssueFilter) -> Result<Vec<Issue>>;
async fn ready_to_work(
&self,
filter: Option<&IssueFilter>,
sort_policy: Option<SortPolicy>,
) -> Result<Vec<Issue>>;
async fn blocked_issues(&self) -> Result<Vec<(Issue, Vec<Issue>)>>;
async fn add_label(&mut self, id: &IssueId, label: &str) -> Result<Issue>;
async fn remove_label(&mut self, id: &IssueId, label: &str) -> Result<Issue>;
async fn import_issues(&mut self, issues: Vec<Issue>) -> Result<()>;
async fn export_all(&self) -> Result<Vec<Issue>>;
async fn save(&self) -> Result<()>;
async fn reload(&mut self) -> Result<()>;
}
#[derive(Debug, Clone)]
pub enum StorageBackend {
InMemory,
Jsonl(PathBuf),
#[allow(dead_code)]
PostgreSQL(String),
}
impl StorageBackend {
pub fn data_path(&self) -> Option<&Path> {
match self {
StorageBackend::Jsonl(path) => Some(path),
StorageBackend::InMemory | StorageBackend::PostgreSQL(_) => None,
}
}
}
struct JsonlBackedStorage {
inner: Box<dyn IssueStorage>,
path: PathBuf,
prefix: String,
}
impl JsonlBackedStorage {
#[allow(dead_code)]
pub(crate) fn inner(&self) -> &dyn IssueStorage {
self.inner.as_ref()
}
}
#[async_trait]
impl IssueStorage for JsonlBackedStorage {
async fn create(&mut self, issue: NewIssue) -> Result<Issue> {
self.inner.create(issue).await
}
async fn get(&self, id: &IssueId) -> Result<Option<Issue>> {
self.inner.get(id).await
}
async fn update(&mut self, id: &IssueId, updates: IssueUpdate) -> Result<Issue> {
self.inner.update(id, updates).await
}
async fn delete(&mut self, id: &IssueId) -> Result<()> {
self.inner.delete(id).await
}
async fn add_dependency(
&mut self,
from: &IssueId,
to: &IssueId,
dep_type: DependencyType,
) -> Result<()> {
self.inner.add_dependency(from, to, dep_type).await
}
async fn remove_dependency(&mut self, from: &IssueId, to: &IssueId) -> Result<()> {
self.inner.remove_dependency(from, to).await
}
async fn get_dependencies(&self, id: &IssueId) -> Result<Vec<Dependency>> {
self.inner.get_dependencies(id).await
}
async fn get_dependents(&self, id: &IssueId) -> Result<Vec<Dependency>> {
self.inner.get_dependents(id).await
}
async fn has_cycle(&self, from: &IssueId, to: &IssueId) -> Result<bool> {
self.inner.has_cycle(from, to).await
}
async fn get_dependency_tree(
&self,
id: &IssueId,
max_depth: Option<usize>,
) -> Result<Vec<(Dependency, usize)>> {
self.inner.get_dependency_tree(id, max_depth).await
}
async fn list(&self, filter: &IssueFilter) -> Result<Vec<Issue>> {
self.inner.list(filter).await
}
async fn ready_to_work(
&self,
filter: Option<&IssueFilter>,
sort_policy: Option<SortPolicy>,
) -> Result<Vec<Issue>> {
self.inner.ready_to_work(filter, sort_policy).await
}
async fn blocked_issues(&self) -> Result<Vec<(Issue, Vec<Issue>)>> {
self.inner.blocked_issues().await
}
async fn add_label(&mut self, id: &IssueId, label: &str) -> Result<Issue> {
self.inner.add_label(id, label).await
}
async fn remove_label(&mut self, id: &IssueId, label: &str) -> Result<Issue> {
self.inner.remove_label(id, label).await
}
async fn import_issues(&mut self, issues: Vec<Issue>) -> Result<()> {
self.inner.import_issues(issues).await
}
async fn export_all(&self) -> Result<Vec<Issue>> {
self.inner.export_all().await
}
async fn save(&self) -> Result<()> {
in_memory::save_to_jsonl(self.inner.as_ref(), &self.path).await
}
async fn reload(&mut self) -> Result<()> {
if self.path.exists() {
let (new_storage, warnings) =
in_memory::load_from_jsonl(&self.path, self.prefix.clone()).await?;
if !warnings.is_empty() {
for warning in &warnings {
tracing::warn!(warning = ?warning, "JSONL reload warning");
}
}
self.inner = new_storage;
} else {
self.inner = in_memory::new_in_memory_storage(self.prefix.clone());
}
Ok(())
}
}
pub async fn create_storage(
backend: StorageBackend,
prefix: String,
) -> Result<Box<dyn IssueStorage>> {
match backend {
StorageBackend::InMemory => Ok(in_memory::new_in_memory_storage(prefix)),
StorageBackend::Jsonl(path) => {
let inner = if path.exists() {
let (storage, warnings) = in_memory::load_from_jsonl(&path, prefix.clone()).await?;
if !warnings.is_empty() {
for warning in &warnings {
tracing::warn!(warning = ?warning, "JSONL load warning");
}
}
storage
} else {
in_memory::new_in_memory_storage(prefix.clone())
};
Ok(Box::new(JsonlBackedStorage {
inner,
path,
prefix,
}))
}
StorageBackend::PostgreSQL(_conn_str) => {
Err(crate::error::Error::Storage(
"PostgreSQL storage backend not yet implemented".to_string(),
))
}
}
}
#[cfg(any(test, feature = "test-util"))]
pub const MOCK_ISSUE_ID: &str = "test-1";
#[cfg(any(test, feature = "test-util"))]
#[derive(Clone, Copy)]
#[non_exhaustive]
pub struct MockStorage;
#[cfg(any(test, feature = "test-util"))]
impl MockStorage {
pub fn new() -> Self {
Self
}
pub fn create_test_issue(id: IssueId) -> Issue {
use crate::domain::{IssueStatus, IssueType};
use chrono::Utc;
Issue {
id,
title: "Test Issue".to_string(),
description: "Test description".to_string(),
status: IssueStatus::Open,
priority: 1,
issue_type: IssueType::Task,
assignee: None,
labels: vec![],
design: None,
acceptance_criteria: None,
notes: None,
external_ref: None,
dependencies: vec![],
created_at: Utc::now(),
updated_at: Utc::now(),
closed_at: None,
}
}
}
#[cfg(any(test, feature = "test-util"))]
impl Default for MockStorage {
fn default() -> Self {
Self::new()
}
}
#[cfg(any(test, feature = "test-util"))]
#[async_trait]
impl IssueStorage for MockStorage {
async fn create(&mut self, _issue: NewIssue) -> Result<Issue> {
Ok(Self::create_test_issue(IssueId::new(MOCK_ISSUE_ID)))
}
async fn get(&self, id: &IssueId) -> Result<Option<Issue>> {
if id.as_str() == MOCK_ISSUE_ID {
Ok(Some(Self::create_test_issue(id.clone())))
} else {
Ok(None)
}
}
async fn update(&mut self, _id: &IssueId, _updates: IssueUpdate) -> Result<Issue> {
unimplemented!(
"MockStorage::update() is not implemented. Use in_memory::new_in_memory_storage() for full CRUD."
)
}
async fn delete(&mut self, _id: &IssueId) -> Result<()> {
unimplemented!(
"MockStorage::delete() is not implemented. Use in_memory::new_in_memory_storage() for full CRUD."
)
}
async fn add_dependency(
&mut self,
_from: &IssueId,
_to: &IssueId,
_dep_type: DependencyType,
) -> Result<()> {
unimplemented!(
"MockStorage::add_dependency() is not implemented. Use in_memory::new_in_memory_storage() for full CRUD."
)
}
async fn remove_dependency(&mut self, _from: &IssueId, _to: &IssueId) -> Result<()> {
unimplemented!(
"MockStorage::remove_dependency() is not implemented. Use in_memory::new_in_memory_storage() for full CRUD."
)
}
async fn get_dependencies(&self, _id: &IssueId) -> Result<Vec<Dependency>> {
Ok(vec![])
}
async fn get_dependents(&self, _id: &IssueId) -> Result<Vec<Dependency>> {
Ok(vec![])
}
async fn has_cycle(&self, _from: &IssueId, _to: &IssueId) -> Result<bool> {
Ok(false)
}
async fn get_dependency_tree(
&self,
_id: &IssueId,
_max_depth: Option<usize>,
) -> Result<Vec<(Dependency, usize)>> {
Ok(vec![])
}
async fn list(&self, _filter: &IssueFilter) -> Result<Vec<Issue>> {
Ok(vec![])
}
async fn ready_to_work(
&self,
_filter: Option<&IssueFilter>,
_sort_policy: Option<SortPolicy>,
) -> Result<Vec<Issue>> {
Ok(vec![])
}
async fn blocked_issues(&self) -> Result<Vec<(Issue, Vec<Issue>)>> {
Ok(vec![])
}
async fn add_label(&mut self, _id: &IssueId, _label: &str) -> Result<Issue> {
unimplemented!(
"MockStorage::add_label() is not implemented. Use in_memory::new_in_memory_storage() for full CRUD."
)
}
async fn remove_label(&mut self, _id: &IssueId, _label: &str) -> Result<Issue> {
unimplemented!(
"MockStorage::remove_label() is not implemented. Use in_memory::new_in_memory_storage() for full CRUD."
)
}
async fn import_issues(&mut self, _issues: Vec<Issue>) -> Result<()> {
Ok(())
}
async fn export_all(&self) -> Result<Vec<Issue>> {
Ok(vec![])
}
async fn save(&self) -> Result<()> {
Ok(())
}
async fn reload(&mut self) -> Result<()> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::IssueType;
#[tokio::test]
async fn test_trait_object_usage() {
let mut storage: Box<dyn IssueStorage> = Box::new(MockStorage::new());
let new_issue = NewIssue {
title: "Test".to_string(),
description: "Test".to_string(),
priority: 1,
issue_type: IssueType::Task,
assignee: None,
labels: vec![],
design: None,
acceptance_criteria: None,
notes: None,
external_ref: None,
dependencies: vec![],
};
let issue = storage.create(new_issue).await.unwrap();
assert_eq!(issue.id.as_str(), MOCK_ISSUE_ID);
assert_eq!(issue.title, "Test Issue");
}
#[tokio::test]
async fn test_get_issue() {
let storage: Box<dyn IssueStorage> = Box::new(MockStorage::new());
let result = storage.get(&IssueId::new(MOCK_ISSUE_ID)).await.unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap().id.as_str(), MOCK_ISSUE_ID);
let result = storage.get(&IssueId::new("test-99")).await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn test_empty_queries() {
let storage: Box<dyn IssueStorage> = Box::new(MockStorage::new());
let filter = IssueFilter::default();
assert!(storage.list(&filter).await.unwrap().is_empty());
assert!(storage.ready_to_work(None, None).await.unwrap().is_empty());
assert!(storage.blocked_issues().await.unwrap().is_empty());
}
#[tokio::test]
async fn test_dependencies() {
let storage: Box<dyn IssueStorage> = Box::new(MockStorage::new());
let id = IssueId::new(MOCK_ISSUE_ID);
assert!(storage.get_dependencies(&id).await.unwrap().is_empty());
assert!(storage.get_dependents(&id).await.unwrap().is_empty());
assert!(!storage
.has_cycle(&id, &IssueId::new("test-2"))
.await
.unwrap());
}
#[tokio::test]
async fn test_mock_storage_copy_semantics() {
let mock = MockStorage::new();
let _copy1 = mock;
let _copy2 = mock; let _: Box<dyn IssueStorage> = Box::new(mock);
}
#[tokio::test]
async fn test_jsonl_reload_restores_disk_state() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let jsonl_path = temp_dir.path().join("issues.jsonl");
let mut storage = create_storage(StorageBackend::Jsonl(jsonl_path.clone()), "test".into())
.await
.unwrap();
let new_issue = NewIssue {
title: "Original Title".to_string(),
description: "Original description".to_string(),
priority: 2,
issue_type: IssueType::Task,
assignee: None,
labels: vec![],
design: None,
acceptance_criteria: None,
notes: None,
external_ref: None,
dependencies: vec![],
};
let created = storage.create(new_issue).await.unwrap();
let issue_id = created.id.clone();
storage.save().await.unwrap();
let update = IssueUpdate {
title: Some("Modified Title".to_string()),
..Default::default()
};
let modified = storage.update(&issue_id, update).await.unwrap();
assert_eq!(modified.title, "Modified Title");
let before_reload = storage.get(&issue_id).await.unwrap().unwrap();
assert_eq!(before_reload.title, "Modified Title");
storage.reload().await.unwrap();
let after_reload = storage.get(&issue_id).await.unwrap().unwrap();
assert_eq!(after_reload.title, "Original Title");
}
#[tokio::test]
async fn test_jsonl_reload_empty_file() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let jsonl_path = temp_dir.path().join("issues.jsonl");
let mut storage = create_storage(StorageBackend::Jsonl(jsonl_path.clone()), "test".into())
.await
.unwrap();
let new_issue = NewIssue {
title: "Test Issue".to_string(),
description: "".to_string(),
priority: 2,
issue_type: IssueType::Task,
assignee: None,
labels: vec![],
design: None,
acceptance_criteria: None,
notes: None,
external_ref: None,
dependencies: vec![],
};
let created = storage.create(new_issue).await.unwrap();
let issue_id = created.id.clone();
storage.save().await.unwrap();
std::fs::remove_file(&jsonl_path).unwrap();
storage.reload().await.unwrap();
let result = storage.get(&issue_id).await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn test_in_memory_reload_is_noop() {
let mut storage = create_storage(StorageBackend::InMemory, "test".into())
.await
.unwrap();
let new_issue = NewIssue {
title: "Test Issue".to_string(),
description: "".to_string(),
priority: 2,
issue_type: IssueType::Task,
assignee: None,
labels: vec![],
design: None,
acceptance_criteria: None,
notes: None,
external_ref: None,
dependencies: vec![],
};
let created = storage.create(new_issue).await.unwrap();
let issue_id = created.id.clone();
storage.reload().await.unwrap();
let result = storage.get(&issue_id).await.unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap().title, "Test Issue");
}
}