zynk 1.0.1

Portable protocol and helper CLI for multi-agent collaboration.
use crate::{CliError, CliResult};
use serde::{Deserialize, Serialize};

/// ADR 033 D3: the nine work-event kinds (seven telemetry + the gate/conflict
/// event halves). The decisions on gate/conflict are M2 records, not here.
#[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,
} // op ∈ add|rem|meta

#[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",
        }
    }

    /// ADR 033 D3: validate the bound per-kind contract. Reject malformed BEFORE
    /// any write (ADR 029 discipline) — never store-bad / silently-drop.
    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"));
                }
                // ADR 033 D3: the hunk `op` is a bound enum (op ∈ add/rem/meta).
                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(())
            }
        }
    }

    /// Serialize to the storage form (serde_norway YAML — zynk's existing dep, no
    /// new deps). Round-trips back via `from_storage`.
    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");
    }
}