use super::graph::{find_blocked_issues, get_dependency_tree_impl, has_cycle_impl};
use super::sorting::sort_by_policy;
use super::InMemoryStorage;
use crate::domain::{
Dependency, DependencyType, Issue, IssueFilter, IssueId, IssueStatus, IssueUpdate, NewIssue,
SortPolicy, MAX_PRIORITY,
};
use crate::error::{Error, Result};
use crate::storage::IssueStorage;
use async_trait::async_trait;
use chrono::Utc;
use petgraph::visit::EdgeRef;
use petgraph::Direction;
fn matches_filter(issue: &Issue, filter: &IssueFilter) -> bool {
filter
.status
.as_ref()
.is_none_or(|status| &issue.status == status)
&& filter
.priority
.is_none_or(|priority| issue.priority == priority)
&& filter
.issue_type
.as_ref()
.is_none_or(|issue_type| &issue.issue_type == issue_type)
&& filter
.assignee
.as_ref()
.is_none_or(|assignee| issue.assignee.as_ref() == Some(assignee))
&& filter
.label
.as_ref()
.is_none_or(|label| issue.labels.contains(label))
}
#[async_trait]
impl IssueStorage for InMemoryStorage {
async fn create(&mut self, new_issue: NewIssue) -> Result<Issue> {
let mut inner = self.lock().await;
new_issue
.validate()
.map_err(|e| Error::Storage(format!("Validation failed: {}", e)))?;
for (depends_on_id, _dep_type) in &new_issue.dependencies {
if !inner.issues.contains_key(depends_on_id) {
return Err(Error::IssueNotFound(depends_on_id.clone()));
}
}
let id = inner.generate_id(&new_issue)?;
let temp_node = inner.graph.add_node(id.clone());
inner.node_map.insert(id.clone(), temp_node);
for (depends_on_id, _dep_type) in &new_issue.dependencies {
if has_cycle_impl(&inner.graph, &inner.node_map, &id, depends_on_id)? {
inner.graph.remove_node(temp_node);
inner.node_map.remove(&id);
return Err(Error::CircularDependency {
from: id,
to: depends_on_id.clone(),
});
}
}
let now = Utc::now();
let dependencies: Vec<Dependency> = new_issue
.dependencies
.iter()
.map(|(depends_on_id, dep_type)| Dependency {
depends_on_id: depends_on_id.clone(),
dep_type: *dep_type,
})
.collect();
let issue = Issue {
id: id.clone(),
title: new_issue.title,
description: new_issue.description,
status: IssueStatus::Open,
priority: new_issue.priority,
issue_type: new_issue.issue_type,
assignee: new_issue.assignee,
labels: new_issue.labels,
design: new_issue.design,
acceptance_criteria: new_issue.acceptance_criteria,
notes: new_issue.notes,
external_ref: new_issue.external_ref,
dependencies: dependencies.clone(),
created_at: now,
updated_at: now,
closed_at: None,
};
inner.issues.insert(id.clone(), issue.clone());
for (depends_on_id, dep_type) in new_issue.dependencies {
let from_node = inner.node_map[&id];
let to_node = inner.node_map[&depends_on_id];
inner.graph.add_edge(from_node, to_node, dep_type);
}
Ok(issue)
}
async fn get(&self, id: &IssueId) -> Result<Option<Issue>> {
let inner = self.lock().await;
Ok(inner.issues.get(id).cloned())
}
async fn update(&mut self, id: &IssueId, updates: IssueUpdate) -> Result<Issue> {
let mut inner = self.lock().await;
let issue = inner
.issues
.get_mut(id)
.ok_or_else(|| Error::IssueNotFound(id.clone()))?;
if let Some(title) = updates.title {
issue.title = title;
}
if let Some(description) = updates.description {
issue.description = description;
}
if let Some(status) = updates.status {
issue.status = status;
if status == IssueStatus::Closed && issue.closed_at.is_none() {
issue.closed_at = Some(Utc::now());
}
}
if let Some(priority) = updates.priority {
if priority > MAX_PRIORITY {
return Err(Error::InvalidPriority(priority));
}
issue.priority = priority;
}
if let Some(assignee_opt) = updates.assignee {
issue.assignee = assignee_opt;
}
if let Some(design) = updates.design {
issue.design = Some(design);
}
if let Some(acceptance_criteria) = updates.acceptance_criteria {
issue.acceptance_criteria = Some(acceptance_criteria);
}
if let Some(notes) = updates.notes {
issue.notes = Some(notes);
}
if let Some(external_ref) = updates.external_ref {
issue.external_ref = Some(external_ref);
}
if let Some(labels) = updates.labels {
issue.labels = labels;
}
issue
.validate()
.map_err(|e| Error::Storage(format!("Validation failed: {}", e)))?;
issue.updated_at = Utc::now();
Ok(issue.clone())
}
async fn delete(&mut self, id: &IssueId) -> Result<()> {
let mut inner = self.lock().await;
if !inner.issues.contains_key(id) {
return Err(Error::IssueNotFound(id.clone()));
}
let node = inner.node_map[id];
let dependents: Vec<_> = inner
.graph
.edges_directed(node, Direction::Incoming)
.map(|edge| inner.graph[edge.source()].clone())
.collect();
if !dependents.is_empty() {
return Err(Error::HasDependents {
issue_id: id.clone(),
dependent_count: dependents.len(),
dependents,
});
}
inner.graph.remove_node(node);
inner.node_map.remove(id);
inner.issues.remove(id);
Ok(())
}
async fn add_dependency(
&mut self,
from: &IssueId,
to: &IssueId,
dep_type: DependencyType,
) -> Result<()> {
let mut inner = self.lock().await;
if !inner.issues.contains_key(from) {
return Err(Error::IssueNotFound(from.clone()));
}
if !inner.issues.contains_key(to) {
return Err(Error::IssueNotFound(to.clone()));
}
let from_node = inner.node_map[from];
let to_node = inner.node_map[to];
if inner.graph.find_edge(from_node, to_node).is_some() {
return Err(Error::Storage(format!(
"Dependency already exists: {} -> {}",
from, to
)));
}
if has_cycle_impl(&inner.graph, &inner.node_map, from, to)? {
return Err(Error::CircularDependency {
from: from.clone(),
to: to.clone(),
});
}
inner.graph.add_edge(from_node, to_node, dep_type);
let issue = inner
.issues
.get_mut(from)
.ok_or_else(|| Error::IssueNotFound(from.clone()))?;
issue.dependencies.push(Dependency {
depends_on_id: to.clone(),
dep_type,
});
Ok(())
}
async fn remove_dependency(&mut self, from: &IssueId, to: &IssueId) -> Result<()> {
let mut inner = self.lock().await;
let from_node = inner
.node_map
.get(from)
.ok_or_else(|| Error::IssueNotFound(from.clone()))?;
let to_node = inner
.node_map
.get(to)
.ok_or_else(|| Error::IssueNotFound(to.clone()))?;
let edge = inner.graph.find_edge(*from_node, *to_node).ok_or_else(|| {
Error::DependencyNotFound {
from: from.clone(),
to: to.clone(),
}
})?;
inner.graph.remove_edge(edge);
let issue = inner
.issues
.get_mut(from)
.ok_or_else(|| Error::IssueNotFound(from.clone()))?;
issue.dependencies.retain(|dep| dep.depends_on_id != *to);
Ok(())
}
async fn get_dependencies(&self, id: &IssueId) -> Result<Vec<Dependency>> {
let inner = self.lock().await;
let node = inner
.node_map
.get(id)
.ok_or_else(|| Error::IssueNotFound(id.clone()))?;
let deps = inner
.graph
.edges(*node)
.map(|edge| Dependency {
depends_on_id: inner.graph[edge.target()].clone(),
dep_type: *edge.weight(),
})
.collect();
Ok(deps)
}
async fn get_dependents(&self, id: &IssueId) -> Result<Vec<Dependency>> {
let inner = self.lock().await;
let node = inner
.node_map
.get(id)
.ok_or_else(|| Error::IssueNotFound(id.clone()))?;
let deps = inner
.graph
.edges_directed(*node, Direction::Incoming)
.map(|edge| Dependency {
depends_on_id: inner.graph[edge.source()].clone(),
dep_type: *edge.weight(),
})
.collect();
Ok(deps)
}
async fn has_cycle(&self, from: &IssueId, to: &IssueId) -> Result<bool> {
let inner = self.lock().await;
has_cycle_impl(&inner.graph, &inner.node_map, from, to)
}
async fn get_dependency_tree(
&self,
id: &IssueId,
max_depth: Option<usize>,
) -> Result<Vec<(Dependency, usize)>> {
let inner = self.lock().await;
get_dependency_tree_impl(&inner.graph, &inner.node_map, id, max_depth)
}
async fn list(&self, filter: &IssueFilter) -> Result<Vec<Issue>> {
let inner = self.lock().await;
let mut issues: Vec<Issue> = inner
.issues
.values()
.filter(|issue| matches_filter(issue, filter))
.cloned()
.collect();
issues.sort_by(|a, b| b.created_at.cmp(&a.created_at));
if let Some(limit) = filter.limit {
issues.truncate(limit);
}
Ok(issues)
}
async fn ready_to_work(
&self,
filter: Option<&IssueFilter>,
sort_policy: Option<SortPolicy>,
) -> Result<Vec<Issue>> {
let inner = self.lock().await;
let blocked = find_blocked_issues(&inner.graph, &inner.node_map, &inner.issues);
let mut ready: Vec<Issue> = inner
.issues
.values()
.filter(|issue| issue.status != IssueStatus::Closed && !blocked.contains(&issue.id))
.cloned()
.collect();
if let Some(filter) = filter {
ready.retain(|issue| matches_filter(issue, filter));
}
let policy = sort_policy.unwrap_or_default();
sort_by_policy(&mut ready, policy);
if let Some(filter) = filter {
if let Some(limit) = filter.limit {
ready.truncate(limit);
}
}
Ok(ready)
}
async fn blocked_issues(&self) -> Result<Vec<(Issue, Vec<Issue>)>> {
let inner = self.lock().await;
let mut blocked_list = Vec::new();
for (id, issue) in &inner.issues {
if issue.status == IssueStatus::Closed {
continue;
}
let node = inner.node_map[id];
let mut blockers = Vec::new();
for edge in inner.graph.edges(node) {
if edge.weight() == &DependencyType::Blocks {
let blocker_id = &inner.graph[edge.target()];
if let Some(blocker) = inner.issues.get(blocker_id) {
if blocker.status != IssueStatus::Closed {
blockers.push(blocker.clone());
}
}
}
}
if !blockers.is_empty() {
blocked_list.push((issue.clone(), blockers));
}
}
Ok(blocked_list)
}
async fn add_label(&mut self, id: &IssueId, label: &str) -> Result<Issue> {
let mut inner = self.lock().await;
let issue = inner
.issues
.get_mut(id)
.ok_or_else(|| Error::IssueNotFound(id.clone()))?;
if !issue.labels.contains(&label.to_string()) {
issue.labels.push(label.to_string());
issue.updated_at = chrono::Utc::now();
}
Ok(issue.clone())
}
async fn remove_label(&mut self, id: &IssueId, label: &str) -> Result<Issue> {
let mut inner = self.lock().await;
let issue = inner
.issues
.get_mut(id)
.ok_or_else(|| Error::IssueNotFound(id.clone()))?;
let original_len = issue.labels.len();
issue.labels.retain(|l| l != label);
if issue.labels.len() != original_len {
issue.updated_at = chrono::Utc::now();
}
Ok(issue.clone())
}
async fn import_issues(&mut self, issues: Vec<Issue>) -> Result<()> {
let mut inner = self.lock().await;
for issue in &issues {
let node = inner.graph.add_node(issue.id.clone());
inner.node_map.insert(issue.id.clone(), node);
inner.issues.insert(issue.id.clone(), issue.clone());
inner
.id_generator
.register_id(issue.id.as_str().to_string());
}
for issue in &issues {
for dep in &issue.dependencies {
if !inner.node_map.contains_key(&dep.depends_on_id) {
continue;
}
let from_node = inner.node_map[&issue.id];
let to_node = inner.node_map[&dep.depends_on_id];
inner.graph.add_edge(from_node, to_node, dep.dep_type);
}
}
Ok(())
}
async fn export_all(&self) -> Result<Vec<Issue>> {
let inner = self.lock().await;
Ok(inner.issues.values().cloned().collect())
}
async fn save(&self) -> Result<()> {
Ok(())
}
async fn reload(&mut self) -> Result<()> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{IssueFilter, IssueStatus, IssueType};
use rstest::rstest;
fn create_test_issue() -> Issue {
Issue {
id: IssueId::new("test-123"),
title: "Test Issue".to_string(),
description: String::new(),
status: IssueStatus::Open,
priority: 2,
issue_type: IssueType::Task,
assignee: Some("alice".to_string()),
labels: vec!["bug".to_string(), "urgent".to_string()],
design: None,
acceptance_criteria: None,
notes: None,
external_ref: None,
dependencies: Vec::new(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
closed_at: None,
}
}
#[test]
fn test_matches_filter_empty_filter_matches_all() {
let issue = create_test_issue();
let filter = IssueFilter::default();
assert!(matches_filter(&issue, &filter));
}
#[rstest]
#[case::status_matches(Some(IssueStatus::Open), true)]
#[case::status_does_not_match(Some(IssueStatus::Closed), false)]
fn test_matches_filter_status(#[case] status: Option<IssueStatus>, #[case] expected: bool) {
let issue = create_test_issue();
let filter = IssueFilter {
status,
..Default::default()
};
assert_eq!(matches_filter(&issue, &filter), expected);
}
#[rstest]
#[case::priority_matches(Some(2), true)]
#[case::priority_does_not_match(Some(1), false)]
fn test_matches_filter_priority(#[case] priority: Option<u8>, #[case] expected: bool) {
let issue = create_test_issue();
let filter = IssueFilter {
priority,
..Default::default()
};
assert_eq!(matches_filter(&issue, &filter), expected);
}
#[rstest]
#[case::type_matches(Some(IssueType::Task), true)]
#[case::type_does_not_match(Some(IssueType::Bug), false)]
fn test_matches_filter_issue_type(
#[case] issue_type: Option<IssueType>,
#[case] expected: bool,
) {
let issue = create_test_issue();
let filter = IssueFilter {
issue_type,
..Default::default()
};
assert_eq!(matches_filter(&issue, &filter), expected);
}
#[rstest]
#[case::assignee_matches(Some("alice".to_string()), true)]
#[case::assignee_does_not_match(Some("bob".to_string()), false)]
fn test_matches_filter_assignee(#[case] assignee: Option<String>, #[case] expected: bool) {
let issue = create_test_issue();
let filter = IssueFilter {
assignee,
..Default::default()
};
assert_eq!(matches_filter(&issue, &filter), expected);
}
#[rstest]
#[case::label_matches(Some("bug".to_string()), true)]
#[case::label_does_not_match(Some("feature".to_string()), false)]
fn test_matches_filter_label(#[case] label: Option<String>, #[case] expected: bool) {
let issue = create_test_issue();
let filter = IssueFilter {
label,
..Default::default()
};
assert_eq!(matches_filter(&issue, &filter), expected);
}
#[test]
fn test_matches_filter_multiple_criteria() {
let issue = create_test_issue();
let filter = IssueFilter {
status: Some(IssueStatus::Open),
priority: Some(2),
issue_type: Some(IssueType::Task),
assignee: Some("alice".to_string()),
label: Some("bug".to_string()),
limit: None,
};
assert!(matches_filter(&issue, &filter));
let filter = IssueFilter {
status: Some(IssueStatus::Open),
priority: Some(1), ..Default::default()
};
assert!(!matches_filter(&issue, &filter));
}
}