use crate::types::*;
use chrono::{NaiveDate, Utc};
use std::collections::{HashMap, HashSet};
use std::sync::Mutex;
use uuid::Uuid;
pub struct ProjectStore {
projects: Mutex<HashMap<String, Project>>,
tasks: Mutex<HashMap<String, Task>>,
sprints: Mutex<HashMap<String, Sprint>>,
milestones: Mutex<HashMap<String, Milestone>>,
seq: Mutex<u64>,
task_seq: Mutex<HashMap<String, u64>>, }
impl Default for ProjectStore {
fn default() -> Self {
Self::new()
}
}
impl ProjectStore {
pub fn new() -> Self {
let s = Self {
projects: Mutex::new(HashMap::new()),
tasks: Mutex::new(HashMap::new()),
sprints: Mutex::new(HashMap::new()),
milestones: Mutex::new(HashMap::new()),
seq: Mutex::new(1000),
task_seq: Mutex::new(HashMap::new()),
};
s.seed();
s
}
fn next(&self, prefix: &str) -> String {
let mut n = self.seq.lock().unwrap();
*n += 1;
format!("{prefix}-{}", *n)
}
fn next_task_key(&self, project_key: &str) -> String {
let mut m = self.task_seq.lock().unwrap();
let n = m.entry(project_key.to_string()).or_insert(0);
*n += 1;
format!("{project_key}-{}", *n)
}
#[allow(clippy::too_many_arguments)]
pub fn create_project(&self, key: String, name: String, description: String, lead: Option<String>, members: Vec<String>, start_date: Option<NaiveDate>, target_date: Option<NaiveDate>) -> Project {
let now = Utc::now();
let p = Project {
id: self.next("PRJ"),
key,
name,
description,
status: ProjectStatus::Planning,
lead,
members,
start_date,
target_date,
created_at: now,
updated_at: now,
};
self.projects.lock().unwrap().insert(p.id.clone(), p.clone());
p
}
pub fn get_project(&self, id: &str) -> Option<Project> {
self.projects.lock().unwrap().get(id).cloned()
}
pub fn list_projects(&self, status: Option<ProjectStatus>) -> Vec<Project> {
let mut v: Vec<Project> = self.projects.lock().unwrap().values().filter(|p| status.is_none_or(|s| p.status == s)).cloned().collect();
v.sort_by(|a, b| a.key.cmp(&b.key));
v
}
pub fn set_project_status(&self, id: &str, status: ProjectStatus) -> Result<Project, String> {
let mut ps = self.projects.lock().unwrap();
let p = ps.get_mut(id).ok_or_else(|| format!("Project not found: {id}"))?;
p.status = status;
p.updated_at = Utc::now();
Ok(p.clone())
}
pub fn add_member(&self, id: &str, member: &str) -> Result<Project, String> {
let mut ps = self.projects.lock().unwrap();
let p = ps.get_mut(id).ok_or_else(|| format!("Project not found: {id}"))?;
if !p.members.iter().any(|m| m == member) {
p.members.push(member.to_string());
p.updated_at = Utc::now();
}
Ok(p.clone())
}
fn project_key(&self, project_id: &str) -> Option<String> {
self.projects.lock().unwrap().get(project_id).map(|p| p.key.clone())
}
#[allow(clippy::too_many_arguments)]
pub fn create_task(
&self,
project_id: &str,
task_type: TaskType,
title: String,
description: String,
priority: Priority,
reporter: String,
assignee: Option<String>,
parent_id: Option<String>,
estimate: Option<f64>,
due_date: Option<NaiveDate>,
) -> Result<Task, String> {
let key = self.project_key(project_id).ok_or_else(|| format!("Project not found: {project_id}"))?;
if let Some(pid) = &parent_id {
if !self.tasks.lock().unwrap().contains_key(pid) {
return Err(format!("Parent task not found: {pid}"));
}
}
let now = Utc::now();
let t = Task {
id: self.next_task_key(&key),
project_id: project_id.to_string(),
task_type,
title,
description,
status: TaskStatus::Backlog,
priority,
assignee,
reporter,
parent_id,
sprint_id: None,
milestone_id: None,
labels: Vec::new(),
estimate,
dependencies: Vec::new(),
comments: Vec::new(),
time_logs: Vec::new(),
due_date,
created_at: now,
updated_at: now,
completed_at: None,
};
self.tasks.lock().unwrap().insert(t.id.clone(), t.clone());
Ok(t)
}
pub fn get_task(&self, id: &str) -> Option<Task> {
self.tasks.lock().unwrap().get(id).cloned()
}
#[allow(clippy::too_many_arguments)]
pub fn search_tasks(
&self,
project_id: Option<&str>,
query: Option<&str>,
status: Option<TaskStatus>,
assignee: Option<&str>,
task_type: Option<TaskType>,
sprint_id: Option<&str>,
label: Option<&str>,
) -> Vec<Task> {
let q = query.map(|s| s.to_lowercase());
let mut v: Vec<Task> = self
.tasks
.lock()
.unwrap()
.values()
.filter(|t| {
project_id.is_none_or(|p| t.project_id == p)
&& q.as_ref().is_none_or(|qq| t.title.to_lowercase().contains(qq) || t.description.to_lowercase().contains(qq))
&& status.is_none_or(|s| t.status == s)
&& assignee.is_none_or(|a| t.assignee.as_deref() == Some(a))
&& task_type.is_none_or(|tt| t.task_type == tt)
&& sprint_id.is_none_or(|s| t.sprint_id.as_deref() == Some(s))
&& label.is_none_or(|l| t.labels.iter().any(|x| x == l))
})
.cloned()
.collect();
v.sort_by(|a, b| a.id.cmp(&b.id));
v
}
#[allow(clippy::too_many_arguments)]
pub fn update_task(
&self,
id: &str,
title: Option<String>,
description: Option<String>,
priority: Option<Priority>,
assignee: Option<Option<String>>,
estimate: Option<f64>,
due_date: Option<NaiveDate>,
) -> Result<Task, String> {
let mut tasks = self.tasks.lock().unwrap();
let t = tasks.get_mut(id).ok_or_else(|| format!("Task not found: {id}"))?;
if let Some(v) = title { t.title = v; }
if let Some(v) = description { t.description = v; }
if let Some(v) = priority { t.priority = v; }
if let Some(v) = assignee { t.assignee = v; }
if let Some(v) = estimate { t.estimate = Some(v); }
if let Some(v) = due_date { t.due_date = Some(v); }
t.updated_at = Utc::now();
Ok(t.clone())
}
fn can_transition(from: TaskStatus, to: TaskStatus) -> bool {
use TaskStatus::*;
if from == to {
return false;
}
match from {
Backlog => matches!(to, Todo | Cancelled),
Todo => matches!(to, InProgress | Backlog | Cancelled),
InProgress => matches!(to, InReview | Blocked | Done | Cancelled),
InReview => matches!(to, InProgress | Done | Cancelled),
Blocked => matches!(to, InProgress | Cancelled),
Done => matches!(to, InProgress), Cancelled => matches!(to, Backlog),
}
}
pub fn transition_task(&self, id: &str, to: TaskStatus) -> Result<Task, String> {
let blockers = self.unfinished_blockers(id);
let mut tasks = self.tasks.lock().unwrap();
let t = tasks.get_mut(id).ok_or_else(|| format!("Task not found: {id}"))?;
if !Self::can_transition(t.status, to) {
return Err(format!("Invalid transition {:?} -> {:?}", t.status, to));
}
if matches!(to, TaskStatus::Done | TaskStatus::InProgress | TaskStatus::InReview) && !blockers.is_empty() {
return Err(format!("Blocked by unfinished task(s): {}", blockers.join(", ")));
}
t.status = to;
t.updated_at = Utc::now();
t.completed_at = if matches!(to, TaskStatus::Done) { Some(Utc::now()) } else { None };
Ok(t.clone())
}
fn unfinished_blockers(&self, id: &str) -> Vec<String> {
let tasks = self.tasks.lock().unwrap();
let Some(t) = tasks.get(id) else { return Vec::new() };
t.dependencies
.iter()
.filter(|d| d.dep_type == DependencyType::BlockedBy)
.filter(|d| tasks.get(&d.task_id).map(|bt| bt.status != TaskStatus::Done).unwrap_or(false))
.map(|d| d.task_id.clone())
.collect()
}
pub fn assign_task(&self, id: &str, assignee: Option<String>) -> Result<Task, String> {
let mut tasks = self.tasks.lock().unwrap();
let t = tasks.get_mut(id).ok_or_else(|| format!("Task not found: {id}"))?;
t.assignee = assignee;
t.updated_at = Utc::now();
Ok(t.clone())
}
pub fn set_labels(&self, id: &str, labels: Vec<String>) -> Result<Task, String> {
let mut tasks = self.tasks.lock().unwrap();
let t = tasks.get_mut(id).ok_or_else(|| format!("Task not found: {id}"))?;
t.labels = labels;
t.updated_at = Utc::now();
Ok(t.clone())
}
pub fn add_dependency(&self, task_id: &str, dep_type: DependencyType, other_id: &str) -> Result<Task, String> {
if task_id == other_id {
return Err("A task cannot depend on itself".into());
}
{
let tasks = self.tasks.lock().unwrap();
if !tasks.contains_key(task_id) {
return Err(format!("Task not found: {task_id}"));
}
if !tasks.contains_key(other_id) {
return Err(format!("Task not found: {other_id}"));
}
}
let (from, to) = match dep_type {
DependencyType::BlockedBy => (other_id.to_string(), task_id.to_string()),
DependencyType::Blocks => (task_id.to_string(), other_id.to_string()),
DependencyType::RelatesTo => (String::new(), String::new()),
};
if !from.is_empty() && self.creates_cycle(&from, &to) {
return Err("Dependency would create a cycle".into());
}
let mut tasks = self.tasks.lock().unwrap();
let t = tasks.get_mut(task_id).unwrap();
if !t.dependencies.iter().any(|d| d.dep_type == dep_type && d.task_id == other_id) {
t.dependencies.push(Dependency { dep_type, task_id: other_id.to_string() });
t.updated_at = Utc::now();
}
Ok(t.clone())
}
fn creates_cycle(&self, from: &str, to: &str) -> bool {
let tasks = self.tasks.lock().unwrap();
let mut stack = vec![to.to_string()];
let mut seen = HashSet::new();
while let Some(cur) = stack.pop() {
if cur == from {
return true;
}
if !seen.insert(cur.clone()) {
continue;
}
if let Some(t) = tasks.get(&cur) {
for d in &t.dependencies {
match d.dep_type {
DependencyType::Blocks => stack.push(d.task_id.clone()),
DependencyType::BlockedBy => { }
DependencyType::RelatesTo => {}
}
}
for (oid, ot) in tasks.iter() {
if ot.dependencies.iter().any(|d| d.dep_type == DependencyType::BlockedBy && d.task_id == cur) {
stack.push(oid.clone());
}
}
}
}
false
}
pub fn add_comment(&self, task_id: &str, author: &str, body: &str) -> Result<Comment, String> {
let mut tasks = self.tasks.lock().unwrap();
let t = tasks.get_mut(task_id).ok_or_else(|| format!("Task not found: {task_id}"))?;
let c = Comment { id: format!("C-{}", &Uuid::new_v4().simple().to_string()[..8]), author: author.to_string(), body: body.to_string(), created_at: Utc::now() };
t.comments.push(c.clone());
t.updated_at = Utc::now();
Ok(c)
}
pub fn log_time(&self, task_id: &str, user: &str, hours: f64, note: Option<String>) -> Result<TimeLog, String> {
if hours <= 0.0 {
return Err("hours must be positive".into());
}
let mut tasks = self.tasks.lock().unwrap();
let t = tasks.get_mut(task_id).ok_or_else(|| format!("Task not found: {task_id}"))?;
let l = TimeLog { id: format!("TL-{}", &Uuid::new_v4().simple().to_string()[..8]), user: user.to_string(), hours, note, logged_at: Utc::now() };
t.time_logs.push(l.clone());
t.updated_at = Utc::now();
Ok(l)
}
pub fn create_sprint(&self, project_id: &str, name: String, goal: Option<String>, start_date: Option<NaiveDate>, end_date: Option<NaiveDate>) -> Result<Sprint, String> {
if self.get_project(project_id).is_none() {
return Err(format!("Project not found: {project_id}"));
}
let s = Sprint {
id: self.next("SPR"),
project_id: project_id.to_string(),
name,
goal,
status: SprintStatus::Planned,
start_date,
end_date,
created_at: Utc::now(),
};
self.sprints.lock().unwrap().insert(s.id.clone(), s.clone());
Ok(s)
}
pub fn set_sprint_status(&self, id: &str, status: SprintStatus) -> Result<Sprint, String> {
let mut sp = self.sprints.lock().unwrap();
let s = sp.get_mut(id).ok_or_else(|| format!("Sprint not found: {id}"))?;
s.status = status;
Ok(s.clone())
}
pub fn assign_to_sprint(&self, task_id: &str, sprint_id: Option<String>) -> Result<Task, String> {
if let Some(sid) = &sprint_id {
if !self.sprints.lock().unwrap().contains_key(sid) {
return Err(format!("Sprint not found: {sid}"));
}
}
let mut tasks = self.tasks.lock().unwrap();
let t = tasks.get_mut(task_id).ok_or_else(|| format!("Task not found: {task_id}"))?;
t.sprint_id = sprint_id;
t.updated_at = Utc::now();
Ok(t.clone())
}
pub fn get_sprint(&self, id: &str) -> Option<Sprint> {
self.sprints.lock().unwrap().get(id).cloned()
}
pub fn list_sprints(&self, project_id: &str) -> Vec<Sprint> {
let mut v: Vec<Sprint> = self.sprints.lock().unwrap().values().filter(|s| s.project_id == project_id).cloned().collect();
v.sort_by(|a, b| a.id.cmp(&b.id));
v
}
pub fn create_milestone(&self, project_id: &str, name: String, description: String, due_date: Option<NaiveDate>) -> Result<Milestone, String> {
if self.get_project(project_id).is_none() {
return Err(format!("Project not found: {project_id}"));
}
let m = Milestone {
id: self.next("MS"),
project_id: project_id.to_string(),
name,
description,
status: MilestoneStatus::Open,
due_date,
created_at: Utc::now(),
};
self.milestones.lock().unwrap().insert(m.id.clone(), m.clone());
Ok(m)
}
pub fn set_milestone_status(&self, id: &str, status: MilestoneStatus) -> Result<Milestone, String> {
let mut ms = self.milestones.lock().unwrap();
let m = ms.get_mut(id).ok_or_else(|| format!("Milestone not found: {id}"))?;
m.status = status;
Ok(m.clone())
}
pub fn assign_to_milestone(&self, task_id: &str, milestone_id: Option<String>) -> Result<Task, String> {
if let Some(mid) = &milestone_id {
if !self.milestones.lock().unwrap().contains_key(mid) {
return Err(format!("Milestone not found: {mid}"));
}
}
let mut tasks = self.tasks.lock().unwrap();
let t = tasks.get_mut(task_id).ok_or_else(|| format!("Task not found: {task_id}"))?;
t.milestone_id = milestone_id;
t.updated_at = Utc::now();
Ok(t.clone())
}
pub fn list_milestones(&self, project_id: &str) -> Vec<Milestone> {
let mut v: Vec<Milestone> = self.milestones.lock().unwrap().values().filter(|m| m.project_id == project_id).cloned().collect();
v.sort_by(|a, b| a.id.cmp(&b.id));
v
}
pub fn project_progress(&self, project_id: &str) -> Option<serde_json::Value> {
let _ = self.get_project(project_id)?;
let tasks = self.search_tasks(Some(project_id), None, None, None, None, None, None);
let total = tasks.len();
let mut counts: HashMap<String, usize> = HashMap::new();
let mut points_total = 0.0;
let mut points_done = 0.0;
let mut hours = 0.0;
for t in &tasks {
*counts.entry(format!("{:?}", t.status).to_lowercase()).or_insert(0) += 1;
if let Some(e) = t.estimate {
points_total += e;
if t.status == TaskStatus::Done {
points_done += e;
}
}
hours += t.time_logs.iter().map(|l| l.hours).sum::<f64>();
}
let done = tasks.iter().filter(|t| t.status == TaskStatus::Done).count();
let pct = if total > 0 { (done as f64 / total as f64 * 100.0).round() } else { 0.0 };
Some(serde_json::json!({
"project_id": project_id,
"total_tasks": total,
"completed_tasks": done,
"completion_pct": pct,
"status_counts": counts,
"story_points": {"total": points_total, "done": points_done},
"hours_logged": hours,
}))
}
pub fn sprint_report(&self, sprint_id: &str) -> Option<serde_json::Value> {
let s = self.get_sprint(sprint_id)?;
let tasks = self.search_tasks(Some(&s.project_id), None, None, None, None, Some(sprint_id), None);
let total = tasks.len();
let done = tasks.iter().filter(|t| t.status == TaskStatus::Done).count();
let pts_total: f64 = tasks.iter().filter_map(|t| t.estimate).sum();
let pts_done: f64 = tasks.iter().filter(|t| t.status == TaskStatus::Done).filter_map(|t| t.estimate).sum();
Some(serde_json::json!({
"sprint_id": sprint_id,
"name": s.name,
"status": s.status,
"task_count": total,
"completed": done,
"points_total": pts_total,
"points_completed": pts_done,
"points_remaining": pts_total - pts_done,
}))
}
pub fn critical_path(&self, project_id: &str) -> serde_json::Value {
let tasks = self.search_tasks(Some(project_id), None, None, None, None, None, None);
let by_id: HashMap<String, &Task> = tasks.iter().map(|t| (t.id.clone(), t)).collect();
let mut memo: HashMap<String, (usize, Vec<String>)> = HashMap::new();
fn depth(id: &str, by_id: &HashMap<String, &Task>, memo: &mut HashMap<String, (usize, Vec<String>)>, stack: &mut HashSet<String>) -> (usize, Vec<String>) {
if let Some(v) = memo.get(id) {
return v.clone();
}
if !stack.insert(id.to_string()) {
return (0, vec![]); }
let mut best = (0usize, Vec::new());
if let Some(t) = by_id.get(id) {
for d in &t.dependencies {
if d.dep_type == DependencyType::BlockedBy {
let (dd, dpath) = depth(&d.task_id, by_id, memo, stack);
if dd > best.0 {
best = (dd, dpath);
}
}
}
}
stack.remove(id);
let mut path = best.1.clone();
path.push(id.to_string());
let res = (best.0 + 1, path);
memo.insert(id.to_string(), res.clone());
res
}
let mut longest = (0usize, Vec::new());
let mut stack = HashSet::new();
for t in &tasks {
let r = depth(&t.id, &by_id, &mut memo, &mut stack);
if r.0 > longest.0 {
longest = r;
}
}
serde_json::json!({
"project_id": project_id,
"length": longest.0,
"path": longest.1,
})
}
pub fn workload(&self, project_id: &str) -> serde_json::Value {
let tasks = self.search_tasks(Some(project_id), None, None, None, None, None, None);
let mut by_assignee: HashMap<String, serde_json::Value> = HashMap::new();
let mut counts: HashMap<String, (usize, usize, f64)> = HashMap::new(); for t in &tasks {
let who = t.assignee.clone().unwrap_or_else(|| "unassigned".into());
let e = counts.entry(who).or_insert((0, 0, 0.0));
if t.status == TaskStatus::Done {
e.1 += 1;
} else {
e.0 += 1;
}
e.2 += t.estimate.unwrap_or(0.0);
}
for (who, (open, done, pts)) in counts {
by_assignee.insert(who, serde_json::json!({"open": open, "done": done, "points": pts}));
}
serde_json::json!({"project_id": project_id, "by_assignee": by_assignee})
}
fn seed(&self) {
let p = self.create_project(
"APOLLO".into(),
"Apollo Platform".into(),
"Next-gen platform rebuild.".into(),
Some("alice".into()),
vec!["alice".into(), "bob".into(), "carol".into()],
NaiveDate::from_ymd_opt(2026, 1, 6),
NaiveDate::from_ymd_opt(2026, 6, 30),
);
let _ = self.set_project_status(&p.id, ProjectStatus::Active);
let sprint = self.create_sprint(&p.id, "Sprint 1".into(), Some("Auth + scaffolding".into()), NaiveDate::from_ymd_opt(2026, 1, 6), NaiveDate::from_ymd_opt(2026, 1, 20)).unwrap();
let _ = self.set_sprint_status(&sprint.id, SprintStatus::Active);
let ms = self.create_milestone(&p.id, "Beta".into(), "Feature-complete beta".into(), NaiveDate::from_ymd_opt(2026, 4, 1)).unwrap();
let t1 = self.create_task(&p.id, TaskType::Story, "User authentication".into(), "OAuth + sessions".into(), Priority::High, "alice".into(), Some("bob".into()), None, Some(8.0), None).unwrap();
let t2 = self.create_task(&p.id, TaskType::Task, "Session storage".into(), "Redis-backed sessions".into(), Priority::Medium, "alice".into(), Some("carol".into()), None, Some(5.0), None).unwrap();
let t3 = self.create_task(&p.id, TaskType::Bug, "Login redirect loop".into(), "Infinite redirect on expired token".into(), Priority::Critical, "bob".into(), Some("bob".into()), None, Some(3.0), None).unwrap();
let _ = self.assign_to_sprint(&t1.id, Some(sprint.id.clone()));
let _ = self.assign_to_sprint(&t3.id, Some(sprint.id.clone()));
let _ = self.assign_to_milestone(&t1.id, Some(ms.id.clone()));
let _ = self.add_dependency(&t1.id, DependencyType::BlockedBy, &t2.id);
let _ = self.transition_task(&t2.id, TaskStatus::Todo);
let _ = self.transition_task(&t3.id, TaskStatus::Todo);
let _ = self.transition_task(&t3.id, TaskStatus::InProgress);
let _ = self.log_time(&t3.id, "bob", 2.5, Some("Reproduced and patched".into()));
}
}