use std::path::Path;
use std::sync::Arc;
use thiserror::Error;
use tokio::sync::RwLock;
use super::milestone::Milestone;
use super::record::Deliverable;
use super::status::TransitionError;
use super::store::{DeliverableStore, MilestoneStore, StoreError};
#[derive(Debug, Error)]
pub enum UpdateError {
#[error(transparent)]
Store(#[from] StoreError),
#[error(transparent)]
Transition(#[from] TransitionError),
}
const DELIVERABLES_FILE: &str = "deliverables.json";
const MILESTONES_FILE: &str = "milestones.json";
#[derive(Debug, Clone)]
pub struct DeliverableManager {
deliverables: Arc<RwLock<DeliverableStore>>,
milestones: Arc<RwLock<MilestoneStore>>,
}
impl DeliverableManager {
pub async fn load(data_dir: &Path) -> Result<Self, StoreError> {
let deliverables = DeliverableStore::load(data_dir.join(DELIVERABLES_FILE)).await?;
let milestones = MilestoneStore::load(data_dir.join(MILESTONES_FILE)).await?;
Ok(Self {
deliverables: Arc::new(RwLock::new(deliverables)),
milestones: Arc::new(RwLock::new(milestones)),
})
}
pub async fn upsert_deliverable(&self, d: Deliverable) -> Result<(), StoreError> {
self.deliverables.write().await.upsert(d).await
}
pub async fn get_deliverable(&self, id: &str) -> Result<Deliverable, StoreError> {
self.deliverables.write().await.get(id).await
}
pub async fn deliverables_by_project(
&self,
project_name: &str,
) -> Result<Vec<Deliverable>, StoreError> {
self.deliverables
.write()
.await
.by_project(project_name)
.await
}
pub async fn all_deliverables(&self) -> Result<Vec<Deliverable>, StoreError> {
self.deliverables.write().await.all().await
}
pub async fn update_deliverable_with<F>(
&self,
id: &str,
project: &str,
f: F,
) -> Result<Deliverable, UpdateError>
where
F: FnOnce(Deliverable) -> Result<Deliverable, TransitionError>,
{
let mut guard = self.deliverables.write().await;
let current = guard.get(id).await?;
if current.project_name != project {
return Err(StoreError::NotFound(id.to_string()).into());
}
let updated = f(current)?;
guard.upsert(updated.clone()).await?;
Ok(updated)
}
pub async fn upsert_milestone(&self, m: Milestone) -> Result<(), StoreError> {
self.milestones.write().await.upsert(m).await
}
pub async fn get_milestone(&self, id: &str) -> Result<Milestone, StoreError> {
self.milestones.write().await.get(id).await
}
pub async fn milestones_by_project(
&self,
project_name: &str,
) -> Result<Vec<Milestone>, StoreError> {
self.milestones.write().await.by_project(project_name).await
}
pub async fn all_milestones(&self) -> Result<Vec<Milestone>, StoreError> {
self.milestones.write().await.all().await
}
pub async fn update_milestone_with<F>(
&self,
id: &str,
project: &str,
f: F,
) -> Result<Milestone, StoreError>
where
F: FnOnce(Milestone) -> Milestone,
{
let mut guard = self.milestones.write().await;
let current = guard.get(id).await?;
if current.project_name != project {
return Err(StoreError::NotFound(id.to_string()));
}
let updated = f(current);
guard.upsert(updated.clone()).await?;
Ok(updated)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::deliverable::milestone::{Milestone, MilestoneId, MilestoneStatus};
use crate::deliverable::record::{Deliverable, DeliverableId, DeliverableKind, EstimationTier};
use crate::deliverable::status::DeliverableStatus;
use chrono::Utc;
use tempfile::TempDir;
fn deliverable(project: &str, name: &str) -> Deliverable {
Deliverable {
id: DeliverableId::new(),
project_name: project.into(),
name: name.into(),
description: String::new(),
kind: DeliverableKind::Feature,
ticket_ref: None,
spec_ref: None,
status: DeliverableStatus::Proposed,
estimated_effort: EstimationTier::M,
created_at: Utc::now(),
target_date: None,
}
}
#[tokio::test]
async fn manager_create_get_deliverable() {
let dir = TempDir::new().unwrap();
let mgr = DeliverableManager::load(dir.path()).await.unwrap();
let d = deliverable("p", "one");
let key = d.id.to_string();
mgr.upsert_deliverable(d).await.unwrap();
assert_eq!(mgr.get_deliverable(&key).await.unwrap().name, "one");
let mgr2 = DeliverableManager::load(dir.path()).await.unwrap();
assert_eq!(mgr2.get_deliverable(&key).await.unwrap().name, "one");
}
#[tokio::test]
async fn manager_list_by_project() {
let dir = TempDir::new().unwrap();
let mgr = DeliverableManager::load(dir.path()).await.unwrap();
mgr.upsert_deliverable(deliverable("p1", "a"))
.await
.unwrap();
mgr.upsert_deliverable(deliverable("p1", "b"))
.await
.unwrap();
mgr.upsert_deliverable(deliverable("p2", "c"))
.await
.unwrap();
assert_eq!(mgr.deliverables_by_project("p1").await.unwrap().len(), 2);
assert_eq!(mgr.deliverables_by_project("p2").await.unwrap().len(), 1);
assert_eq!(mgr.all_deliverables().await.unwrap().len(), 3);
}
#[tokio::test]
async fn manager_replace_deliverable() {
let dir = TempDir::new().unwrap();
let mgr = DeliverableManager::load(dir.path()).await.unwrap();
let mut d = deliverable("p", "orig");
let key = d.id.to_string();
mgr.upsert_deliverable(d.clone()).await.unwrap();
d.status = DeliverableStatus::InProgress;
d.name = "renamed".into();
mgr.upsert_deliverable(d).await.unwrap();
let got = mgr.get_deliverable(&key).await.unwrap();
assert_eq!(got.name, "renamed");
assert_eq!(got.status, DeliverableStatus::InProgress);
assert_eq!(mgr.deliverables_by_project("p").await.unwrap().len(), 1);
}
#[tokio::test]
async fn manager_milestone_round_trip() {
let dir = TempDir::new().unwrap();
let mgr = DeliverableManager::load(dir.path()).await.unwrap();
let m = Milestone {
id: MilestoneId::new(),
project_name: "p".into(),
name: "v1".into(),
description: String::new(),
target_date: Utc::now(),
status: MilestoneStatus::Proposed,
deliverables: vec![],
created_at: Utc::now(),
};
let key = m.id.to_string();
mgr.upsert_milestone(m).await.unwrap();
assert_eq!(mgr.get_milestone(&key).await.unwrap().name, "v1");
assert_eq!(mgr.milestones_by_project("p").await.unwrap().len(), 1);
assert_eq!(mgr.all_milestones().await.unwrap().len(), 1);
}
#[tokio::test]
async fn update_deliverable_with_rejects_illegal_transition_without_mutating() {
let dir = TempDir::new().unwrap();
let mgr = DeliverableManager::load(dir.path()).await.unwrap();
let d = deliverable("p", "x");
let id = d.id.to_string();
mgr.upsert_deliverable(d).await.unwrap();
let err = mgr
.update_deliverable_with(&id, "p", |mut current| {
crate::deliverable::status::validate_transition(
current.status,
DeliverableStatus::Complete,
)?;
current.status = DeliverableStatus::Complete;
Ok(current)
})
.await
.expect_err("proposed -> complete must be rejected");
assert!(matches!(err, UpdateError::Transition(_)));
let unchanged = mgr.get_deliverable(&id).await.unwrap();
assert_eq!(unchanged.status, DeliverableStatus::Proposed);
}
#[tokio::test]
async fn read_then_upsert_pattern_reproduces_lost_update_pre_fix() {
let dir = TempDir::new().unwrap();
let mgr = Arc::new(DeliverableManager::load(dir.path()).await.unwrap());
let mut d = deliverable("p", "race");
d.status = DeliverableStatus::InProgress;
let id = d.id.to_string();
mgr.upsert_deliverable(d).await.unwrap();
let gate = Arc::new(tokio::sync::Notify::new());
let mgr_a = Arc::clone(&mgr);
let gate_a = Arc::clone(&gate);
let id_a = id.clone();
let a = tokio::spawn(async move {
let current = mgr_a.get_deliverable(&id_a).await.unwrap();
gate_a.notified().await;
let target = DeliverableStatus::Complete;
crate::deliverable::status::validate_transition(current.status, target)
.expect("A's own validation, against the stale read, succeeds");
let mut updated = current;
updated.status = target;
mgr_a.upsert_deliverable(updated).await.unwrap();
});
let mgr_b = Arc::clone(&mgr);
let gate_b = Arc::clone(&gate);
let id_b = id.clone();
let b = tokio::spawn(async move {
let current = mgr_b.get_deliverable(&id_b).await.unwrap();
let target = DeliverableStatus::Blocked;
crate::deliverable::status::validate_transition(current.status, target)
.expect("B's own validation, against the SAME stale read, ALSO succeeds");
let mut updated = current;
updated.status = target;
mgr_b.upsert_deliverable(updated).await.unwrap();
gate_b.notify_one();
});
b.await.unwrap();
a.await.unwrap();
let final_state = mgr.get_deliverable(&id).await.unwrap();
assert_eq!(
final_state.status,
DeliverableStatus::Complete,
"demonstrates the pre-fix hazard: B's validated, committed transition \
to `blocked` is lost — silently clobbered by A's later upsert against \
the same stale read, even though B's own call reported success"
);
}
#[tokio::test]
async fn update_deliverable_with_serializes_concurrent_transitions() {
let dir = TempDir::new().unwrap();
let mgr = Arc::new(DeliverableManager::load(dir.path()).await.unwrap());
let mut d = deliverable("p", "race");
d.status = DeliverableStatus::InProgress;
let id = d.id.to_string();
mgr.upsert_deliverable(d).await.unwrap();
fn transition_to(
target: DeliverableStatus,
) -> impl FnOnce(Deliverable) -> Result<Deliverable, crate::deliverable::status::TransitionError>
{
move |mut current| {
crate::deliverable::status::validate_transition(current.status, target)?;
current.status = target;
Ok(current)
}
}
let mgr_a = Arc::clone(&mgr);
let id_a = id.clone();
let a = tokio::spawn(async move {
mgr_a
.update_deliverable_with(&id_a, "p", transition_to(DeliverableStatus::Complete))
.await
});
let mgr_b = Arc::clone(&mgr);
let id_b = id.clone();
let b = tokio::spawn(async move {
mgr_b
.update_deliverable_with(&id_b, "p", transition_to(DeliverableStatus::Blocked))
.await
});
let ra = a.await.unwrap();
let rb = b.await.unwrap();
let ra_ok = ra.is_ok();
let rb_ok = rb.is_ok();
assert_eq!(
[ra_ok, rb_ok].iter().filter(|ok| **ok).count(),
1,
"exactly one racing transition must win: a_ok={ra_ok} b_ok={rb_ok}"
);
let (loser_err, winner_target) = if ra_ok {
(rb.unwrap_err(), DeliverableStatus::Complete)
} else {
(ra.unwrap_err(), DeliverableStatus::Blocked)
};
assert!(
matches!(loser_err, UpdateError::Transition(_)),
"the losing transition must be rejected, not silently dropped: {loser_err:?}"
);
let final_state = mgr.get_deliverable(&id).await.unwrap();
assert_eq!(final_state.status, winner_target);
}
#[tokio::test]
async fn update_milestone_with_round_trips() {
let dir = TempDir::new().unwrap();
let mgr = DeliverableManager::load(dir.path()).await.unwrap();
let m = Milestone {
id: MilestoneId::new(),
project_name: "p".into(),
name: "v1".into(),
description: String::new(),
target_date: Utc::now(),
status: MilestoneStatus::Proposed,
deliverables: vec![],
created_at: Utc::now(),
};
let id = m.id.to_string();
mgr.upsert_milestone(m).await.unwrap();
let updated = mgr
.update_milestone_with(&id, "p", |mut current| {
current.name = "v1.0".into();
current.status = MilestoneStatus::Shipped;
current
})
.await
.unwrap();
assert_eq!(updated.name, "v1.0");
assert_eq!(updated.status, MilestoneStatus::Shipped);
let err = mgr
.update_milestone_with(&id, "wrong-project", |m| m)
.await
.expect_err("wrong project scope must be rejected");
assert!(matches!(err, StoreError::NotFound(_)));
assert_eq!(mgr.get_milestone(&id).await.unwrap().name, "v1.0");
}
}