use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::{Map, Value};
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GoalTaskDeliveryStatus {
Planned,
InProgress,
Blocked,
ReadyForReview,
Delivered,
Merged,
Other(String),
}
impl GoalTaskDeliveryStatus {
pub fn as_str(&self) -> &str {
match self {
GoalTaskDeliveryStatus::Planned => "planned",
GoalTaskDeliveryStatus::InProgress => "in_progress",
GoalTaskDeliveryStatus::Blocked => "blocked",
GoalTaskDeliveryStatus::ReadyForReview => "ready_for_review",
GoalTaskDeliveryStatus::Delivered => "delivered",
GoalTaskDeliveryStatus::Merged => "merged",
GoalTaskDeliveryStatus::Other(status) => status.as_str(),
}
}
}
impl From<String> for GoalTaskDeliveryStatus {
fn from(value: String) -> Self {
match value.as_str() {
"planned" => GoalTaskDeliveryStatus::Planned,
"in_progress" => GoalTaskDeliveryStatus::InProgress,
"blocked" => GoalTaskDeliveryStatus::Blocked,
"ready_for_review" => GoalTaskDeliveryStatus::ReadyForReview,
"delivered" => GoalTaskDeliveryStatus::Delivered,
"merged" => GoalTaskDeliveryStatus::Merged,
_ => GoalTaskDeliveryStatus::Other(value),
}
}
}
impl Serialize for GoalTaskDeliveryStatus {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for GoalTaskDeliveryStatus {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
Ok(String::deserialize(deserializer)?.into())
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct GoalTaskDeliveryMetadata {
pub slice_id: Option<String>,
pub owner: Option<String>,
pub read_scope: Vec<String>,
pub write_scope: Vec<String>,
pub dependencies: Vec<String>,
pub branch: Option<String>,
pub worktree_name: Option<String>,
pub worktree_path: Option<PathBuf>,
pub gates: Vec<String>,
pub review_needs: Vec<String>,
pub pr_url: Option<String>,
pub commit_sha: Option<String>,
pub verification_summary: Option<String>,
pub status: Option<GoalTaskDeliveryStatus>,
pub conflict_evidence_path: Option<PathBuf>,
pub conflict_blocking_reason: Option<String>,
pub slice_lease_id: Option<String>,
pub extra: Map<String, Value>,
}
impl GoalTaskDeliveryMetadata {
pub fn from_value(value: &Value) -> Self {
let Some(object) = value.as_object() else {
return Self::default();
};
let mut extra = object.clone();
let slice_id = take_string(&mut extra, "slice_id");
let owner = take_string(&mut extra, "owner");
let read_scope = take_string_array(&mut extra, "read_scope").unwrap_or_default();
let write_scope = take_string_array(&mut extra, "write_scope").unwrap_or_default();
let dependencies = take_string_array(&mut extra, "dependencies").unwrap_or_default();
let branch = take_string(&mut extra, "branch");
let worktree_name = take_string(&mut extra, "worktree_name");
let worktree_path = take_string(&mut extra, "worktree_path").map(PathBuf::from);
let gates = take_string_array(&mut extra, "gates").unwrap_or_default();
let review_needs = take_string_array(&mut extra, "review_needs").unwrap_or_default();
let pr_url = take_string(&mut extra, "pr_url");
let commit_sha = take_string(&mut extra, "commit_sha");
let verification_summary = take_string(&mut extra, "verification_summary");
let status = take_string(&mut extra, "status").map(GoalTaskDeliveryStatus::from);
let conflict_evidence_path =
take_string(&mut extra, "conflict_evidence_path").map(PathBuf::from);
let conflict_blocking_reason = take_string(&mut extra, "conflict_blocking_reason");
let slice_lease_id = take_string(&mut extra, "slice_lease_id");
Self {
slice_id,
owner,
read_scope,
write_scope,
dependencies,
branch,
worktree_name,
worktree_path,
gates,
review_needs,
pr_url,
commit_sha,
verification_summary,
status,
conflict_evidence_path,
conflict_blocking_reason,
slice_lease_id,
extra,
}
}
pub fn to_value(&self) -> Value {
let mut object = self.extra.clone();
insert_string(&mut object, "slice_id", self.slice_id.as_deref());
insert_string(&mut object, "owner", self.owner.as_deref());
insert_string_array(&mut object, "read_scope", &self.read_scope);
insert_string_array(&mut object, "write_scope", &self.write_scope);
insert_string_array(&mut object, "dependencies", &self.dependencies);
insert_string(&mut object, "branch", self.branch.as_deref());
insert_string(&mut object, "worktree_name", self.worktree_name.as_deref());
let worktree_path = self
.worktree_path
.as_ref()
.map(|path| path.display().to_string());
insert_string(&mut object, "worktree_path", worktree_path.as_deref());
insert_string_array(&mut object, "gates", &self.gates);
insert_string_array(&mut object, "review_needs", &self.review_needs);
insert_string(&mut object, "pr_url", self.pr_url.as_deref());
insert_string(&mut object, "commit_sha", self.commit_sha.as_deref());
insert_string(
&mut object,
"verification_summary",
self.verification_summary.as_deref(),
);
insert_string(
&mut object,
"status",
self.status.as_ref().map(GoalTaskDeliveryStatus::as_str),
);
let conflict_evidence_path = self
.conflict_evidence_path
.as_ref()
.map(|path| path.display().to_string());
insert_string(
&mut object,
"conflict_evidence_path",
conflict_evidence_path.as_deref(),
);
insert_string(
&mut object,
"conflict_blocking_reason",
self.conflict_blocking_reason.as_deref(),
);
insert_string(
&mut object,
"slice_lease_id",
self.slice_lease_id.as_deref(),
);
Value::Object(object)
}
pub fn merge_update(&mut self, update: GoalTaskDeliveryMetadataUpdate) {
replace_if_some(&mut self.slice_id, update.slice_id);
replace_if_some(&mut self.owner, update.owner);
if let Some(read_scope) = update.read_scope {
self.read_scope = read_scope;
}
if let Some(write_scope) = update.write_scope {
self.write_scope = write_scope;
}
if let Some(dependencies) = update.dependencies {
self.dependencies = dependencies;
}
replace_if_some(&mut self.branch, update.branch);
replace_if_some(&mut self.worktree_name, update.worktree_name);
replace_if_some(&mut self.worktree_path, update.worktree_path);
if let Some(gates) = update.gates {
self.gates = gates;
}
if let Some(review_needs) = update.review_needs {
self.review_needs = review_needs;
}
replace_if_some(&mut self.pr_url, update.pr_url);
replace_if_some(&mut self.commit_sha, update.commit_sha);
replace_if_some(&mut self.verification_summary, update.verification_summary);
replace_if_some(&mut self.status, update.status);
replace_if_some(
&mut self.conflict_evidence_path,
update.conflict_evidence_path,
);
replace_if_some(
&mut self.conflict_blocking_reason,
update.conflict_blocking_reason,
);
replace_if_some(&mut self.slice_lease_id, update.slice_lease_id);
self.extra.extend(update.extra);
}
pub fn is_empty(&self) -> bool {
matches!(self.to_value(), Value::Object(object) if object.is_empty())
}
}
impl Serialize for GoalTaskDeliveryMetadata {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
self.to_value().serialize(serializer)
}
}
impl<'de> Deserialize<'de> for GoalTaskDeliveryMetadata {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
if !value.is_object() {
return Err(D::Error::custom("delivery metadata must be a JSON object"));
}
Ok(Self::from_value(&value))
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct GoalTaskDeliveryMetadataUpdate {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slice_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub read_scope: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub write_scope: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dependencies: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worktree_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worktree_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gates: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review_needs: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pr_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commit_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verification_summary: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<GoalTaskDeliveryStatus>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conflict_evidence_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conflict_blocking_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slice_lease_id: Option<String>,
#[serde(default, flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct GoalTaskDeliveryRecord {
pub task_id: String,
#[serde(flatten)]
pub metadata: GoalTaskDeliveryMetadata,
}
impl Serialize for GoalTaskDeliveryRecord {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut value = self.metadata.to_value();
if let Some(object) = value.as_object_mut() {
object.insert("task_id".to_string(), Value::String(self.task_id.clone()));
}
value.serialize(serializer)
}
}
fn take_string(object: &mut Map<String, Value>, key: &str) -> Option<String> {
let value = object.get(key)?.as_str()?.to_string();
object.remove(key);
Some(value)
}
fn take_string_array(object: &mut Map<String, Value>, key: &str) -> Option<Vec<String>> {
let values = serde_json::from_value(object.get(key)?.clone()).ok()?;
object.remove(key);
Some(values)
}
fn insert_string(object: &mut Map<String, Value>, key: &str, value: Option<&str>) {
if let Some(value) = value {
object.insert(key.to_string(), Value::String(value.to_string()));
}
}
fn insert_string_array(object: &mut Map<String, Value>, key: &str, value: &[String]) {
if !value.is_empty() {
object.insert(key.to_string(), serde_json::json!(value));
}
}
fn replace_if_some<T>(target: &mut Option<T>, value: Option<T>) {
if let Some(value) = value {
*target = Some(value);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn metadata_roundtrip_with_none_extra_fields() {
let original = GoalTaskDeliveryMetadata {
slice_id: Some("slice-1".to_string()),
owner: Some("agent-a".to_string()),
read_scope: vec!["src/lib.rs".to_string()],
write_scope: vec!["src/main.rs".to_string()],
dependencies: vec!["dep-1".to_string()],
branch: Some("feature/foo".to_string()),
worktree_name: Some("wt-1".to_string()),
worktree_path: Some(PathBuf::from("/tmp/wt1")),
gates: vec!["test".to_string()],
review_needs: vec!["code".to_string()],
pr_url: Some("https://github.com/org/repo/pull/1".to_string()),
commit_sha: Some("abc123".to_string()),
verification_summary: Some("ok".to_string()),
status: Some(GoalTaskDeliveryStatus::InProgress),
conflict_evidence_path: None,
conflict_blocking_reason: None,
slice_lease_id: None,
extra: Map::new(),
};
let json = serde_json::to_value(&original).unwrap();
let deserialized: GoalTaskDeliveryMetadata = serde_json::from_value(json).unwrap();
assert_eq!(original, deserialized);
}
}