use anyhow::Context;
use chrono::{DateTime, Utc};
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use super::DaemonClient;
use super::error::response_or_body_error;
use crate::deliverable::{
Deliverable, DeliverableKind, DeliverableStatus, EstimationTier, Milestone,
};
#[derive(Debug, Clone, Serialize)]
pub struct CreateDeliverableArgs {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub kind: DeliverableKind,
pub estimated_effort: EstimationTier,
#[serde(skip_serializing_if = "Option::is_none")]
pub ticket_ref: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub spec_ref: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CreateMilestoneArgs {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub target_date: DateTime<Utc>,
}
#[derive(Debug, Clone, Deserialize)]
struct TransitionRejectionBody {
#[serde(default)]
from: String,
#[serde(default)]
to: String,
#[serde(default)]
allowed_next: Vec<String>,
}
#[derive(Debug)]
pub enum SetStatusError {
Rejected {
from: String,
to: String,
allowed_next: Vec<String>,
},
Other(anyhow::Error),
}
impl std::fmt::Display for SetStatusError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SetStatusError::Rejected {
from,
to,
allowed_next,
} => {
let allowed = if allowed_next.is_empty() {
"none (terminal state)".to_string()
} else {
allowed_next.join(", ")
};
write!(
f,
"illegal status transition {from} -> {to}; legal next states from {from}: [{allowed}]"
)
}
SetStatusError::Other(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for SetStatusError {}
#[derive(Debug, Deserialize)]
struct DeliverablesListWire {
#[serde(default)]
deliverables: Vec<Deliverable>,
}
#[derive(Debug, Deserialize)]
struct MilestonesListWire {
#[serde(default)]
milestones: Vec<Milestone>,
}
impl DaemonClient {
pub async fn list_deliverables(
&self,
project: &str,
status: Option<DeliverableStatus>,
) -> anyhow::Result<Vec<Deliverable>> {
let url = format!("/api/v1/projects/{project}/deliverables");
let mut req = self.get(&url);
if let Some(status) = status {
req = req.query(&[("status", status.as_str())]);
}
let resp = req.send().await?;
let body: DeliverablesListWire = response_or_body_error(resp)
.await?
.json()
.await
.context("deserialize deliverable list")?;
Ok(body.deliverables)
}
pub async fn create_deliverable(
&self,
project: &str,
args: &CreateDeliverableArgs,
) -> anyhow::Result<Deliverable> {
let url = format!("/api/v1/projects/{project}/deliverables");
let resp = self.post(&url).json(args).send().await?;
let created: Deliverable = response_or_body_error(resp)
.await?
.json()
.await
.context("deserialize created deliverable")?;
Ok(created)
}
pub async fn get_deliverable(&self, project: &str, id: &str) -> anyhow::Result<Deliverable> {
let url = format!("/api/v1/projects/{project}/deliverables/{id}");
let resp = self.get(&url).send().await?;
let d: Deliverable = response_or_body_error(resp)
.await?
.json()
.await
.context("deserialize deliverable")?;
Ok(d)
}
pub async fn set_deliverable_status(
&self,
project: &str,
id: &str,
status: DeliverableStatus,
) -> Result<Deliverable, SetStatusError> {
let url = format!("/api/v1/projects/{project}/deliverables/{id}");
let resp = self
.patch(&url)
.json(&serde_json::json!({ "status": status }))
.send()
.await
.map_err(|e| SetStatusError::Other(e.into()))?;
if resp.status() == StatusCode::CONFLICT {
let body: TransitionRejectionBody = resp.json().await.map_err(SetStatusError::Other)?;
return Err(SetStatusError::Rejected {
from: body.from,
to: body.to,
allowed_next: body.allowed_next,
});
}
let resp = response_or_body_error(resp)
.await
.map_err(SetStatusError::Other)?;
resp.json()
.await
.context("deserialize updated deliverable")
.map_err(SetStatusError::Other)
}
pub async fn list_milestones(&self, project: &str) -> anyhow::Result<Vec<Milestone>> {
let url = format!("/api/v1/projects/{project}/milestones");
let resp = self.get(&url).send().await?;
let body: MilestonesListWire = response_or_body_error(resp)
.await?
.json()
.await
.context("deserialize milestone list")?;
Ok(body.milestones)
}
pub async fn create_milestone(
&self,
project: &str,
args: &CreateMilestoneArgs,
) -> anyhow::Result<Milestone> {
let url = format!("/api/v1/projects/{project}/milestones");
let resp = self.post(&url).json(args).send().await?;
let created: Milestone = response_or_body_error(resp)
.await?
.json()
.await
.context("deserialize created milestone")?;
Ok(created)
}
pub async fn get_milestone(&self, project: &str, id: &str) -> anyhow::Result<Milestone> {
let url = format!("/api/v1/projects/{project}/milestones/{id}");
let resp = self.get(&url).send().await?;
let m: Milestone = response_or_body_error(resp)
.await?
.json()
.await
.context("deserialize milestone")?;
Ok(m)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_deliverable_args_serializes() {
let args = CreateDeliverableArgs {
name: "OAuth2 flow".into(),
description: None,
kind: DeliverableKind::Feature,
estimated_effort: EstimationTier::L,
ticket_ref: Some("#2117".into()),
spec_ref: None,
};
let v = serde_json::to_value(&args).unwrap();
assert_eq!(v["name"], "OAuth2 flow");
assert_eq!(v["kind"], "feature");
assert_eq!(v["estimated_effort"], "L");
assert_eq!(v["ticket_ref"], "#2117");
assert!(v.get("description").is_none());
assert!(v.get("spec_ref").is_none());
}
#[test]
fn create_milestone_args_serializes() {
let args = CreateMilestoneArgs {
name: "v1.0 Alpha".into(),
description: Some("first alpha".into()),
target_date: DateTime::parse_from_rfc3339("2026-09-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
};
let v = serde_json::to_value(&args).unwrap();
assert_eq!(v["name"], "v1.0 Alpha");
assert_eq!(v["description"], "first alpha");
assert!(v["target_date"].is_string());
}
#[test]
fn transition_rejection_body_deserializes() {
let json = serde_json::json!({
"error": "invalid status transition proposed → complete; ...",
"from": "proposed",
"to": "complete",
"allowed_next": ["in-progress"]
});
let body: TransitionRejectionBody = serde_json::from_value(json).unwrap();
assert_eq!(body.from, "proposed");
assert_eq!(body.to, "complete");
assert_eq!(body.allowed_next, vec!["in-progress".to_string()]);
}
#[test]
fn set_status_rejected_display_lists_allowed() {
let e = SetStatusError::Rejected {
from: "proposed".into(),
to: "complete".into(),
allowed_next: vec!["in-progress".into()],
};
let msg = e.to_string();
assert!(msg.contains("proposed"), "{msg}");
assert!(msg.contains("complete"), "{msg}");
assert!(msg.contains("in-progress"), "{msg}");
}
#[test]
fn deliverables_list_deserializes() {
let json = serde_json::json!({
"deliverables": [{
"id": "00000000-0000-0000-0000-000000000001",
"project_name": "widget",
"name": "OAuth2 flow",
"kind": "feature",
"status": "proposed",
"estimated_effort": "L",
"created_at": "2026-07-10T12:00:00Z"
}]
});
let body: DeliverablesListWire = serde_json::from_value(json).unwrap();
assert_eq!(body.deliverables.len(), 1);
assert_eq!(body.deliverables[0].name, "OAuth2 flow");
assert_eq!(body.deliverables[0].status, DeliverableStatus::Proposed);
}
#[test]
fn milestones_list_deserializes() {
let json = serde_json::json!({
"milestones": [{
"id": "00000000-0000-0000-0000-000000000002",
"project_name": "widget",
"name": "v1.0 Alpha",
"target_date": "2026-09-01T00:00:00Z",
"status": "proposed",
"deliverables": [],
"created_at": "2026-07-10T12:00:00Z"
}]
});
let body: MilestonesListWire = serde_json::from_value(json).unwrap();
assert_eq!(body.milestones.len(), 1);
assert_eq!(body.milestones[0].name, "v1.0 Alpha");
}
}