use crate::{CliError, CliResult};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum WorkEventPayload {
Think {
text: String,
},
Tool {
name: String,
arg: String,
output: String,
ok: bool,
},
Diff {
file: String,
added: u32,
removed: u32,
hunks: Vec<DiffHunk>,
},
Plan {
title: String,
checklist: Vec<String>,
},
Artifact {
files: Vec<ArtifactFile>,
},
Usage {
agent: String,
tokens: u64,
cost_cents: Option<u64>,
},
System {
text: String,
},
Gate {
title: String,
summary: String,
proposer: String,
actions: Vec<String>,
},
Conflict {
topic: String,
positions: Vec<ConflictPosition>,
recommended: String,
options: Vec<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiffHunk {
pub op: String,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArtifactFile {
pub path: String,
pub add: u32,
pub rem: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConflictPosition {
pub from: String,
pub stance: String,
pub text: String,
pub tradeoff: String,
}
impl WorkEventPayload {
pub fn kind(&self) -> &'static str {
match self {
WorkEventPayload::Think { .. } => "think",
WorkEventPayload::Tool { .. } => "tool",
WorkEventPayload::Diff { .. } => "diff",
WorkEventPayload::Plan { .. } => "plan",
WorkEventPayload::Artifact { .. } => "artifact",
WorkEventPayload::Usage { .. } => "usage",
WorkEventPayload::System { .. } => "system",
WorkEventPayload::Gate { .. } => "gate",
WorkEventPayload::Conflict { .. } => "conflict",
}
}
pub fn validate(&self) -> CliResult<()> {
let nonempty = |s: &str, f: &str| -> CliResult<()> {
if s.trim().is_empty() {
Err(CliError::usage(format!(
"work-event field {f} must be non-empty"
)))
} else {
Ok(())
}
};
match self {
WorkEventPayload::Think { text } | WorkEventPayload::System { text } => {
nonempty(text, "text")
}
WorkEventPayload::Tool { name, .. } => nonempty(name, "name"),
WorkEventPayload::Diff { file, hunks, .. } => {
nonempty(file, "file")?;
if hunks.is_empty() {
return Err(CliError::usage("diff requires ≥1 hunk"));
}
for h in hunks {
if !matches!(h.op.as_str(), "add" | "rem" | "meta") {
return Err(CliError::usage(format!(
"diff hunk op must be one of add/rem/meta (got {:?})",
h.op
)));
}
}
Ok(())
}
WorkEventPayload::Plan { title, checklist } => {
nonempty(title, "title")?;
if checklist.is_empty() {
return Err(CliError::usage("plan requires ≥1 checklist item"));
}
Ok(())
}
WorkEventPayload::Artifact { files } => {
if files.is_empty() {
return Err(CliError::usage("artifact requires ≥1 file"));
}
Ok(())
}
WorkEventPayload::Usage { agent, .. } => nonempty(agent, "agent"),
WorkEventPayload::Gate { title, actions, .. } => {
nonempty(title, "title")?;
if actions.is_empty() {
return Err(CliError::usage("gate requires ≥1 action"));
}
Ok(())
}
WorkEventPayload::Conflict {
topic, positions, ..
} => {
nonempty(topic, "topic")?;
if positions.is_empty() {
return Err(CliError::usage("conflict requires ≥1 position"));
}
Ok(())
}
}
}
pub fn to_storage(&self) -> CliResult<String> {
serde_norway::to_string(self)
.map_err(|e| CliError::failure(format!("failed to serialize work event: {e}")))
}
pub fn from_storage(text: &str) -> CliResult<Self> {
serde_norway::from_str(text)
.map_err(|e| CliError::failure(format!("failed to read work event: {e}")))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_rejects_empty_and_accepts_well_formed() {
assert!(WorkEventPayload::Think { text: " ".into() }
.validate()
.is_err());
assert!(WorkEventPayload::Think {
text: "weighing options".into()
}
.validate()
.is_ok());
assert!(WorkEventPayload::Plan {
title: "t".into(),
checklist: vec![]
}
.validate()
.is_err());
assert!(WorkEventPayload::Tool {
name: "run_tests".into(),
arg: "cargo test".into(),
output: "ok".into(),
ok: true
}
.validate()
.is_ok());
}
#[test]
fn storage_round_trips_and_keeps_kind() {
let p = WorkEventPayload::Diff {
file: "src/x.rs".into(),
added: 3,
removed: 1,
hunks: vec![DiffHunk {
op: "add".into(),
text: "fn x() {}".into(),
}],
};
let s = p.to_storage().unwrap();
let back = WorkEventPayload::from_storage(&s).unwrap();
assert_eq!(p, back);
assert_eq!(back.kind(), "diff");
}
}