use std::path::{Path, PathBuf};
use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::earned_value::{BaselineTaken, PlanBaseline};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct PlanId(pub String);
impl PlanId {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for PlanId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct CallerId(pub String);
impl CallerId {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for CallerId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanGraph {
pub deliverables: Vec<Deliverable>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_chained_dispatch: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PrerequisiteKind {
Artifact,
Interface,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged, deny_unknown_fields)]
pub enum Prerequisite {
Id(String),
Edge {
id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
consumes: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
kind: Option<PrerequisiteKind>,
#[serde(default, skip_serializing_if = "Option::is_none")]
lag_hours: Option<f32>,
},
}
impl Prerequisite {
pub fn id(&self) -> &str {
match self {
Self::Id(id) | Self::Edge { id, .. } => id,
}
}
pub fn consumes(&self) -> Option<&str> {
match self {
Self::Id(_) => None,
Self::Edge { consumes, .. } => consumes.as_deref(),
}
}
pub fn kind(&self) -> Option<PrerequisiteKind> {
match self {
Self::Id(_) => None,
Self::Edge { kind, .. } => *kind,
}
}
pub fn lag_hours(&self) -> f32 {
match self {
Self::Id(_) => 0.0,
Self::Edge { lag_hours, .. } => lag_hours.unwrap_or(0.0),
}
}
}
impl From<&str> for Prerequisite {
fn from(id: &str) -> Self {
Self::Id(id.to_string())
}
}
impl From<String> for Prerequisite {
fn from(id: String) -> Self {
Self::Id(id)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum FileMode {
#[default]
Exclusive,
Append,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(untagged, from = "OwnedFileWire")]
pub enum OwnedFile {
Path(PathBuf),
Claim {
path: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
mode: Option<FileMode>,
},
}
#[derive(Deserialize)]
#[serde(untagged)]
enum OwnedFileWire {
Path(PathBuf),
Claim(OwnedClaimWire),
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct OwnedClaimWire {
path: PathBuf,
#[serde(default)]
mode: Option<FileMode>,
}
impl From<OwnedFileWire> for OwnedFile {
fn from(w: OwnedFileWire) -> Self {
match w {
OwnedFileWire::Path(p) => Self::Path(p),
OwnedFileWire::Claim(c) => Self::Claim {
path: c.path,
mode: c.mode,
},
}
}
}
impl OwnedFile {
pub fn path(&self) -> &Path {
match self {
Self::Path(p) | Self::Claim { path: p, .. } => p,
}
}
pub fn mode(&self) -> FileMode {
match self {
Self::Path(_) => FileMode::Exclusive,
Self::Claim { mode, .. } => mode.unwrap_or_default(),
}
}
}
impl From<&str> for OwnedFile {
fn from(s: &str) -> Self {
Self::Path(PathBuf::from(s))
}
}
impl From<PathBuf> for OwnedFile {
fn from(p: PathBuf) -> Self {
Self::Path(p)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Estimate {
pub optimistic: f32,
pub likely: f32,
pub pessimistic: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EarningRule {
#[default]
ZeroHundred,
FiftyFifty,
Weighted,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Deliverable {
pub id: String,
pub owned_files: Vec<OwnedFile>,
pub prerequisites: Vec<Prerequisite>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub estimated_effort_hours: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_hours: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub estimate: Option<Estimate>,
#[serde(default)]
pub metadata: serde_json::Value,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub milestone: bool,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_earning_rule"
)]
pub earning_rule: Option<EarningRule>,
}
fn deserialize_earning_rule<'de, D>(d: D) -> Result<Option<EarningRule>, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Option::<EarningRule>::deserialize(d)?.filter(|r| *r != EarningRule::ZeroHundred))
}
impl Deliverable {
pub fn is_milestone(&self) -> bool {
self.milestone
|| self
.metadata
.get("milestone")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum DeliverableStatus {
Pending,
Ready,
InProgress,
Complete,
Failed {
reason: String,
},
}
#[derive(Debug, Clone)]
pub struct AcquireRequest {
pub plan_id: PlanId,
pub caller_id: CallerId,
pub max_count: usize,
pub ids: Option<Vec<String>>,
pub metadata_filter: Option<serde_json::Map<String, serde_json::Value>>,
pub ttl: Option<Duration>,
}
impl AcquireRequest {
pub fn new(plan_id: PlanId, caller_id: CallerId, max_count: usize) -> Self {
Self {
plan_id,
caller_id,
max_count,
ids: None,
metadata_filter: None,
ttl: None,
}
}
pub fn with_ids(mut self, ids: Vec<String>) -> Self {
self.ids = Some(ids);
self
}
pub fn with_metadata_filter(
mut self,
filter: serde_json::Map<String, serde_json::Value>,
) -> Self {
self.metadata_filter = Some(filter);
self
}
pub fn with_ttl(mut self, ttl: Duration) -> Self {
self.ttl = Some(ttl);
self
}
}
#[derive(Debug, Clone)]
pub struct MarkStatusRequest {
pub plan_id: PlanId,
pub deliverable_id: String,
pub caller_id: CallerId,
pub status: DeliverableStatus,
pub earned_pct: Option<u8>,
pub actual_effort_hours: Option<f32>,
pub evidence: Option<String>,
}
impl MarkStatusRequest {
pub fn new(
plan_id: PlanId,
deliverable_id: impl Into<String>,
caller_id: CallerId,
status: DeliverableStatus,
) -> Self {
Self {
plan_id,
deliverable_id: deliverable_id.into(),
caller_id,
status,
earned_pct: None,
actual_effort_hours: None,
evidence: None,
}
}
pub fn with_earned_pct(mut self, pct: u8) -> Self {
self.earned_pct = Some(pct);
self
}
pub fn with_actual_effort_hours(mut self, hours: f32) -> Self {
self.actual_effort_hours = Some(hours);
self
}
pub fn with_evidence(mut self, evidence: impl Into<String>) -> Self {
self.evidence = Some(evidence.into());
self
}
}
#[derive(Debug, Clone)]
pub struct HeartbeatRequest {
pub plan_id: PlanId,
pub deliverable_id: String,
pub caller_id: CallerId,
pub ttl: Option<Duration>,
}
impl HeartbeatRequest {
pub fn new(plan_id: PlanId, deliverable_id: impl Into<String>, caller_id: CallerId) -> Self {
Self {
plan_id,
deliverable_id: deliverable_id.into(),
caller_id,
ttl: None,
}
}
pub fn with_ttl(mut self, ttl: Duration) -> Self {
self.ttl = Some(ttl);
self
}
}
#[derive(Debug, Clone)]
pub struct ForceReleaseRequest {
pub plan_id: PlanId,
pub deliverable_id: String,
pub reason: String,
pub reset_counters: bool,
}
impl ForceReleaseRequest {
pub fn new(
plan_id: PlanId,
deliverable_id: impl Into<String>,
reason: impl Into<String>,
) -> Self {
Self {
plan_id,
deliverable_id: deliverable_id.into(),
reason: reason.into(),
reset_counters: false,
}
}
pub fn reset_counters(mut self, yes: bool) -> Self {
self.reset_counters = yes;
self
}
}
#[derive(Debug, Clone)]
pub struct AcceptRequest {
pub plan_id: PlanId,
pub deliverable_id: String,
pub accepted_by: String,
pub evidence: String,
pub override_lock: bool,
}
impl AcceptRequest {
pub fn new(
plan_id: PlanId,
deliverable_id: impl Into<String>,
accepted_by: impl Into<String>,
evidence: impl Into<String>,
) -> Self {
Self {
plan_id,
deliverable_id: deliverable_id.into(),
accepted_by: accepted_by.into(),
evidence: evidence.into(),
override_lock: false,
}
}
pub fn override_lock(mut self, yes: bool) -> Self {
self.override_lock = yes;
self
}
}
#[derive(Debug, Clone)]
pub struct SyncRequest {
pub project: String,
pub name: String,
pub variant: String,
pub graph: PlanGraph,
pub source_path: Option<String>,
pub content_hash: Option<String>,
pub force: bool,
}
impl SyncRequest {
pub fn new(
project: impl Into<String>,
name: impl Into<String>,
variant: impl Into<String>,
graph: PlanGraph,
) -> Self {
Self {
project: project.into(),
name: name.into(),
variant: variant.into(),
graph,
source_path: None,
content_hash: None,
force: false,
}
}
pub fn with_source_path(mut self, path: impl Into<String>) -> Self {
self.source_path = Some(path.into());
self
}
pub fn with_content_hash(mut self, hash: impl Into<String>) -> Self {
self.content_hash = Some(hash.into());
self
}
pub fn force(mut self, yes: bool) -> Self {
self.force = yes;
self
}
}
#[derive(Debug, Clone)]
pub struct ReviseRequest {
pub plan_id: PlanId,
pub graph: PlanGraph,
pub force: bool,
}
impl ReviseRequest {
pub fn new(plan_id: PlanId, graph: PlanGraph) -> Self {
Self {
plan_id,
graph,
force: false,
}
}
pub fn force(mut self, yes: bool) -> Self {
self.force = yes;
self
}
}
#[derive(Debug, Clone)]
pub struct ForkRequest {
pub plan_id: PlanId,
pub variant: String,
pub edits: Vec<crate::edits::GraphEdit>,
pub project_root: Option<crate::project::ProjectRoot>,
}
impl ForkRequest {
pub fn new(plan_id: PlanId, variant: impl Into<String>) -> Self {
Self {
plan_id,
variant: variant.into(),
edits: Vec::new(),
project_root: None,
}
}
pub fn with_edits(mut self, edits: Vec<crate::edits::GraphEdit>) -> Self {
self.edits = edits;
self
}
pub fn with_project_root(mut self, root: crate::project::ProjectRoot) -> Self {
self.project_root = Some(root);
self
}
}
#[derive(Debug, Clone)]
pub struct ComparePlansRequest {
pub plan_ids: Option<Vec<PlanId>>,
pub plan: Option<(String, String)>,
pub request: crate::compare::CompareRequest,
}
impl ComparePlansRequest {
pub fn by_ids(plan_ids: Vec<PlanId>, request: crate::compare::CompareRequest) -> Self {
Self {
plan_ids: Some(plan_ids),
plan: None,
request,
}
}
pub fn by_plan(
project: impl Into<String>,
name: impl Into<String>,
request: crate::compare::CompareRequest,
) -> Self {
Self {
plan_ids: None,
plan: Some((project.into(), name.into())),
request,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct VariantSummary {
pub variant: String,
pub plan_id: PlanId,
pub selected: bool,
pub archived: bool,
pub head_revision: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_path: Option<String>,
pub complete: usize,
pub total: usize,
pub plan_complete: bool,
pub makespan: f32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PlanLineSummary {
pub project: String,
pub name: String,
pub selected_variant: Option<String>,
pub archived: bool,
pub variants: Vec<VariantSummary>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SyncOutcome {
pub plan_id: PlanId,
pub name: String,
pub variant: String,
pub revision: u32,
pub created: bool,
pub changed: bool,
#[serde(default)]
pub diff: Option<crate::revise::RevisionDiff>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SelectOutcome {
pub plan_id: PlanId,
pub project: String,
pub name: String,
pub variant: String,
pub previous: Option<String>,
pub changed: bool,
pub carried: Vec<String>,
pub released_locks: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub baseline_taken: Option<BaselineTaken>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockInfo {
pub plan_id: PlanId,
pub deliverable_id: String,
pub caller_id: CallerId,
pub acquired_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CohortRow {
pub deliverable: Deliverable,
pub lock: LockInfo,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(into = "FlatCohort", try_from = "FlatCohort")]
pub struct Cohort {
pub plan_id: PlanId,
pub rows: Vec<CohortRow>,
pub blocked: Vec<BlockedDeliverable>,
pub shared_paths: Vec<PathBuf>,
pub baseline_taken: Option<BaselineTaken>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BlockedDeliverable {
pub id: String,
pub code: String,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CohortDecodeError {
pub deliverables: usize,
pub locks: usize,
}
impl std::fmt::Display for CohortDecodeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"COHORT_LENGTH_MISMATCH: deliverables ({}) and locks ({}) arrays \
must be the same length; each deliverable is held under exactly one lock",
self.deliverables, self.locks
)
}
}
impl std::error::Error for CohortDecodeError {}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct FlatCohort {
plan_id: PlanId,
deliverables: Vec<Deliverable>,
locks: Vec<LockInfo>,
#[serde(default)]
blocked: Vec<BlockedDeliverable>,
#[serde(default)]
shared_paths: Vec<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
baseline_taken: Option<BaselineTaken>,
}
impl From<Cohort> for FlatCohort {
fn from(cohort: Cohort) -> Self {
let mut deliverables = Vec::with_capacity(cohort.rows.len());
let mut locks = Vec::with_capacity(cohort.rows.len());
for row in cohort.rows {
deliverables.push(row.deliverable);
locks.push(row.lock);
}
FlatCohort {
plan_id: cohort.plan_id,
deliverables,
locks,
blocked: cohort.blocked,
shared_paths: cohort.shared_paths,
baseline_taken: cohort.baseline_taken,
}
}
}
impl TryFrom<FlatCohort> for Cohort {
type Error = CohortDecodeError;
fn try_from(flat: FlatCohort) -> Result<Self, Self::Error> {
if flat.deliverables.len() != flat.locks.len() {
return Err(CohortDecodeError {
deliverables: flat.deliverables.len(),
locks: flat.locks.len(),
});
}
let rows = flat
.deliverables
.into_iter()
.zip(flat.locks)
.map(|(deliverable, lock)| CohortRow { deliverable, lock })
.collect();
Ok(Cohort {
plan_id: flat.plan_id,
rows,
blocked: flat.blocked,
shared_paths: flat.shared_paths,
baseline_taken: flat.baseline_taken,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanStatus {
pub plan_id: PlanId,
pub deliverables: Vec<(String, DeliverableStatus, u32, u32, u32)>,
pub critical_path: Vec<String>,
pub critical_path_hours: f32,
pub locks_held: Vec<LockInfo>,
#[serde(default)]
pub critical_ids: Vec<String>,
#[serde(default)]
pub schedule: Vec<ScheduleRow>,
#[serde(default)]
pub ready: Vec<String>,
#[serde(default)]
pub plan_complete: bool,
#[serde(default)]
pub milestones: Vec<MilestoneRow>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub variant: Option<String>,
#[serde(default)]
pub selected: Option<bool>,
#[serde(default)]
pub definition_drift: Option<bool>,
#[serde(default)]
pub baseline: Option<PlanBaseline>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MilestoneRow {
pub id: String,
pub critical_path: Vec<String>,
pub hours: f32,
pub complete: bool,
}
pub const MAX_DELIVERABLES: usize = 5000;
pub const MAX_HOURS: f32 = 1_000_000.0;
pub const MAX_EVIDENCE_CHARS: usize = 2048;
pub const MAX_EVIDENCE_ENTRIES: usize = 100;
pub const START_ID: &str = "__start__";
pub const FINISH_ID: &str = "__finish__";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanDefinition {
pub plan_id: PlanId,
pub graph: PlanGraph,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ScheduleRow {
pub id: String,
pub es: f32,
pub ef: f32,
pub ls: f32,
pub lf: f32,
pub float: f32,
pub critical: bool,
#[serde(default)]
pub synthetic: bool,
}
#[derive(Debug, Error)]
pub enum PlannerError {
#[error("LOCK_HELD: deliverable {deliverable_id} in plan {plan_id} is locked by {holder}")]
LockHeld {
plan_id: String,
deliverable_id: String,
holder: String,
},
#[error("LOCK_NOT_HELD: caller {caller_id} does not hold lock on {deliverable_id}")]
LockNotHeld {
caller_id: String,
deliverable_id: String,
},
#[error("LOCK_EXPIRED: lock on {deliverable_id} expired at {expired_at}")]
LockExpired {
deliverable_id: String,
expired_at: DateTime<Utc>,
},
#[error(
"OVERLAP_DETECTED: deliverable {deliverable_id} owns files {files:?} that overlap with \
currently locked files"
)]
OverlapDetected {
deliverable_id: String,
files: Vec<PathBuf>,
},
#[error(
"MISSING_PREREQUISITE: deliverable {deliverable_id} requires {prereq} which is not \
Complete"
)]
MissingPrerequisite {
deliverable_id: String,
prereq: String,
},
#[error("PLAN_NOT_FOUND: {plan_id}")]
PlanNotFound { plan_id: String },
#[error("DELIVERABLE_NOT_FOUND: {deliverable_id} in plan {plan_id}")]
DeliverableNotFound {
plan_id: String,
deliverable_id: String,
},
#[error(
"LAPSE_LIMIT: deliverable {deliverable_id} lost {lapse_count} leases to environmental \
lapses (TTL expiry with no terminal mark — killed/timed-out drivers, NOT implementation \
failures; bound {max_lapses}); fix the environment, then mark_status the deliverable to \
proceed; clear it with plan.force_release {{reset_counters: true}}"
)]
LapseLimit {
deliverable_id: String,
lapse_count: u32,
max_lapses: u32,
},
#[error(
"PREREQUISITES_INCOMPLETE: {deliverable_id} in plan {plan_id} has incomplete \
prerequisites [{}]",
missing.join(", ")
)]
PrerequisitesIncomplete {
plan_id: String,
deliverable_id: String,
missing: Vec<String>,
},
#[error("INVALID_GRAPH: {reason}")]
InvalidGraph { reason: String },
#[error("INVALID_PATH: {reason}")]
InvalidPath { reason: String },
#[error("INVALID_CAPACITIES: no capacity for resources [{}]", missing.join(", "))]
InvalidCapacities { missing: Vec<String> },
#[error(
"VARIANT_NOT_SELECTED: plan {plan_id} is variant '{variant}' of '{name}'; selected is \
'{selected}'"
)]
VariantNotSelected {
plan_id: String,
name: String,
variant: String,
selected: String,
},
#[error("ARCHIVE_REFUSED: {reason}")]
ArchiveRefused { reason: String },
#[error("INVALID_ACTUALS: {reason}")]
InvalidActuals { reason: String },
#[error("INVALID_VALUE: {reason}")]
InvalidValue { reason: String },
#[error("NOT_BASELINED: plan {plan_id} has no baseline; take one with plan.baseline")]
NotBaselined { plan_id: String },
#[error(
"BASELINE_REQUIRED: plan {plan_id} has no earned-value baseline and \
CPM_BASELINE_MODE=refuse; take one with plan.baseline"
)]
BaselineRequired { plan_id: String },
#[error("BACKEND_ERROR: {0}")]
BackendError(#[source] anyhow::Error),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plan_graph_serde_roundtrip() -> Result<(), serde_json::Error> {
let graph = PlanGraph {
deliverables: vec![Deliverable {
id: "d1".to_string(),
owned_files: vec!["src/foo.rs".into(), "src/bar.rs".into()],
prerequisites: vec!["d0".into()],
estimated_effort_hours: Some(1.5),
metadata: serde_json::json!({"description": "smoke test"}),
duration_hours: None,
estimate: None,
milestone: false,
earning_rule: None,
}],
max_chained_dispatch: Some(8),
};
let json = serde_json::to_string(&graph)?;
let back: PlanGraph = serde_json::from_str(&json)?;
assert_eq!(back.deliverables.len(), 1);
let d = &back.deliverables[0];
assert_eq!(d.id, "d1");
assert_eq!(
d.owned_files,
vec![OwnedFile::from("src/foo.rs"), OwnedFile::from("src/bar.rs")]
);
assert_eq!(d.prerequisites, vec![Prerequisite::from("d0")]);
assert_eq!(d.estimated_effort_hours, Some(1.5));
assert_eq!(d.metadata, serde_json::json!({"description": "smoke test"}));
assert_eq!(back.max_chained_dispatch, Some(8));
Ok(())
}
#[test]
fn explicit_default_earning_rule_deserializes_as_absent() {
let d: Deliverable = serde_json::from_value(serde_json::json!({
"id": "a",
"owned_files": [],
"prerequisites": [],
"earning_rule": "zero_hundred"
}))
.unwrap();
assert_eq!(d.earning_rule, None);
}
#[test]
fn deliverable_status_failed_carries_reason() -> Result<(), serde_json::Error> {
let status = DeliverableStatus::Failed {
reason: "tests failed".to_string(),
};
let json = serde_json::to_value(&status)?;
assert_eq!(json["status"], "failed");
assert_eq!(json["reason"], "tests failed");
let back: DeliverableStatus = serde_json::from_value(json)?;
assert_eq!(back, status);
Ok(())
}
#[test]
fn cohort_wire_shape_preserves_two_array_layout() -> Result<(), serde_json::Error> {
let plan_id = PlanId("plan_x".to_string());
let now = chrono::Utc::now();
let cohort = Cohort {
plan_id: plan_id.clone(),
blocked: vec![],
shared_paths: vec![],
rows: vec![
CohortRow {
deliverable: Deliverable {
id: "d1".to_string(),
owned_files: vec!["a.rs".into()],
prerequisites: vec![],
estimated_effort_hours: Some(1.0),
metadata: serde_json::Value::Null,
duration_hours: None,
estimate: None,
milestone: false,
earning_rule: None,
},
lock: LockInfo {
plan_id: plan_id.clone(),
deliverable_id: "d1".to_string(),
caller_id: CallerId("c1".to_string()),
acquired_at: now,
expires_at: now + chrono::Duration::seconds(60),
},
},
CohortRow {
deliverable: Deliverable {
id: "d2".to_string(),
owned_files: vec!["b.rs".into()],
prerequisites: vec![],
estimated_effort_hours: Some(2.0),
metadata: serde_json::Value::Null,
duration_hours: None,
estimate: None,
milestone: false,
earning_rule: None,
},
lock: LockInfo {
plan_id: plan_id.clone(),
deliverable_id: "d2".to_string(),
caller_id: CallerId("c1".to_string()),
acquired_at: now,
expires_at: now + chrono::Duration::seconds(60),
},
},
],
baseline_taken: None,
};
let json = serde_json::to_value(&cohort)?;
assert!(json.get("deliverables").is_some());
assert!(json.get("locks").is_some());
assert!(json.get("rows").is_none());
let deliverables = json["deliverables"].as_array().unwrap();
let locks = json["locks"].as_array().unwrap();
assert_eq!(deliverables.len(), 2);
assert_eq!(locks.len(), 2);
assert_eq!(deliverables[0]["id"], "d1");
assert_eq!(locks[0]["deliverable_id"], "d1");
let back: Cohort = serde_json::from_value(json)?;
assert_eq!(back.rows.len(), 2);
assert_eq!(back.rows[0].deliverable.id, "d1");
assert_eq!(back.rows[0].lock.deliverable_id, "d1");
assert_eq!(back.rows[1].deliverable.id, "d2");
assert_eq!(back.rows[1].lock.deliverable_id, "d2");
Ok(())
}
#[test]
fn cohort_rejects_mismatched_deliverables_and_locks_lengths() {
let now = chrono::Utc::now();
let wire = serde_json::json!({
"plan_id": "plan_x",
"deliverables": [
{
"id": "d1",
"owned_files": ["a.rs"],
"prerequisites": [],
"estimated_effort_hours": 1.0,
"metadata": null
},
{
"id": "d2",
"owned_files": ["b.rs"],
"prerequisites": [],
"estimated_effort_hours": 2.0,
"metadata": null
}
],
"locks": [
{
"plan_id": "plan_x",
"deliverable_id": "d1",
"caller_id": "c1",
"acquired_at": now,
"expires_at": now + chrono::Duration::seconds(60)
}
]
});
let err = serde_json::from_value::<Cohort>(wire).unwrap_err();
assert!(
err.to_string().contains("COHORT_LENGTH_MISMATCH"),
"expected COHORT_LENGTH_MISMATCH, got: {err}"
);
}
}