crates/signaltty-server/src/params.rs

//! Typed method params: one decode per method at the dispatch seam.
//!
//! Handlers take structs, not raw `Value`: shape validation (missing
//! fields, mistyped values, bad enum variants) lives here, domain
//! validation (unknown ids, empty titles, foreign panes) stays in the
//! handler. Unknown fields are ignored (forward compat, docs/08);
//! present-but-mistyped fields are `BAD_PARAMS` instead of silently
//! defaulting, so client bugs surface at the seam.
//!
//! Every optional is `Option<T>`: missing and explicit null both mean
//! "absent", and handlers apply defaults in one line, as before.

use serde::{Deserialize, Deserializer};
use serde_json::Value;

use signaltty_core::model::{Contract, Layout, NotificationSeverity, Relationship, SplitDir};
use signaltty_core::state::{Attention, Lifecycle, TaskState};
use signaltty_proto::code;

pub type ParamError = (String, String);

pub fn bad_params(msg: impl Into<String>) -> ParamError {
    (code::BAD_PARAMS.to_string(), msg.into())
}

/// Decode method params into `T`. Serde errors become `BAD_PARAMS`.
pub fn decode<'de, T: Deserialize<'de>>(params: &'de Value) -> Result<T, ParamError> {
    T::deserialize(params).map_err(|e| bad_params(e.to_string()))
}

/// Defaulted non-`Option` fields: explicit null means "absent" too,
/// like every `Option<T>` field (docs/08), instead of `BAD_PARAMS`.
fn de_null_default<'de, D, T>(d: D) -> Result<T, D::Error>
where
    D: Deserializer<'de>,
    T: Deserialize<'de> + Default,
{
    Ok(Option::<T>::deserialize(d)?.unwrap_or_default())
}

// ---- shared shapes ----

/// Methods taking exactly one workspace id.
#[derive(Debug, Deserialize)]
pub struct WorkspaceId {
    pub workspace_id: String,
}

/// What a workspace diff compares against (spec 041).
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum DiffScope {
    /// The checkout against HEAD.
    #[default]
    Head,
    /// The checkout against its tree at the latest agent turn start.
    Turn,
}

#[derive(Debug, Deserialize)]
pub struct WorkspaceDiff {
    pub workspace_id: String,
    #[serde(default, deserialize_with = "de_null_default")]
    pub scope: DiffScope,
}

#[derive(Debug, Deserialize)]
pub struct WorkspaceFileDiff {
    pub workspace_id: String,
    pub path: String,
    #[serde(default, deserialize_with = "de_null_default")]
    pub scope: DiffScope,
}

#[derive(Debug, Deserialize)]
pub struct WorktreeCreate {
    pub workspace_id: String,
    pub path: String,
    pub branch: String,
    pub name: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct WorktreeOpen {
    pub workspace_id: String,
    pub path: String,
    pub name: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct WorktreeRemove {
    pub workspace_id: String,
    pub path: String,
}

#[derive(Debug, Deserialize)]
pub struct TabClose {
    pub tab_id: String,
    #[serde(default, deserialize_with = "de_opt_signal")]
    pub signal: Option<String>,
}

/// Methods taking exactly one pane id.
#[derive(Debug, Deserialize)]
pub struct PaneId {
    pub pane_id: String,
}

#[derive(Debug, Deserialize)]
pub struct ServerShutdown {
    pub force: Option<bool>,
}

#[derive(Debug, Deserialize)]
pub struct WorkspaceCreate {
    #[serde(default, deserialize_with = "de_null_default")]
    pub auto_name: bool,
    pub name: Option<String>,
    pub cwd: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct WorkspaceRename {
    pub workspace_id: String,
    pub name: String,
}

#[derive(Debug, Deserialize)]
pub struct WorkspaceClose {
    pub workspace_id: String,
    #[serde(default, deserialize_with = "de_opt_signal")]
    pub signal: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct TabCreate {
    pub workspace_id: String,
    pub title: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct TabSetLayout {
    pub tab_id: String,
    pub layout: Layout,
}

#[derive(Debug, Deserialize)]
pub struct LayoutExport {
    pub tab_id: String,
}

#[derive(Debug, Deserialize)]
pub struct LayoutApply {
    pub workspace_id: String,
    pub title: Option<String>,
    pub root: LayoutSpec,
}

/// A `layout.apply` tree: the `tab.set_layout` shape whose leaves say
/// what to start instead of naming live panes (`pane_id` is ignored, so
/// `layout.export` output applies as is).
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum LayoutSpec {
    Pane {
        cwd: Option<String>,
        argv: Option<Vec<String>>,
        name: Option<String>,
    },
    Split {
        dir: SplitDir,
        ratio: f32,
        first: Box<LayoutSpec>,
        second: Box<LayoutSpec>,
    },
}

#[derive(Debug, Deserialize)]
pub struct TabSetRatio {
    pub tab_id: String,
    pub path: SplitPath,
    pub ratio: Ratio,
}

/// 0 (first) / 1 (second) choices from the tab root.
#[derive(Debug, Deserialize)]
#[serde(transparent)]
pub struct SplitPath(#[serde(deserialize_with = "de_split_path")] pub Vec<bool>);

fn de_split_path<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<bool>, D::Error> {
    let raw = Vec::<Value>::deserialize(d)?;
    raw.iter()
        .map(|v| match v.as_u64() {
            Some(0) => Ok(false),
            Some(1) => Ok(true),
            _ => Err(serde::de::Error::custom("'path' must be an array of 0/1")),
        })
        .collect()
}

/// Divider ratio, clamped to [`Layout::MIN_RATIO`]`..=`[`Layout::MAX_RATIO`].
#[derive(Debug, Deserialize)]
#[serde(transparent)]
pub struct Ratio(#[serde(deserialize_with = "de_ratio")] pub f32);

/// Reject unknown names at decode: `close` must not drop a pane whose
/// child it could not signal.
fn de_signal<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
    let raw = String::deserialize(d)?;
    crate::pty::parse_signal(&raw).map_err(serde::de::Error::custom)?;
    Ok(raw)
}

fn de_opt_signal<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
    #[derive(Deserialize)]
    struct Signal(#[serde(deserialize_with = "de_signal")] String);
    Ok(Option::<Signal>::deserialize(d)?.map(|s| s.0))
}

fn de_ratio<'de, D: Deserializer<'de>>(d: D) -> Result<f32, D::Error> {
    let raw = f64::deserialize(d)?;
    if !raw.is_finite() {
        return Err(serde::de::Error::custom("missing or invalid 'ratio'"));
    }
    Ok(Layout::clamp_ratio(raw as f32))
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SplitDirection {
    Right,
    Down,
}

impl SplitDirection {
    pub fn split_dir(&self) -> SplitDir {
        match self {
            SplitDirection::Right => SplitDir::Right,
            SplitDirection::Down => SplitDir::Down,
        }
    }
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ReadMode {
    Screen,
    Tail,
    Rendered,
}

#[derive(Debug, Deserialize)]
pub struct PaneSpawn {
    pub workspace_id: String,
    pub tab_id: Option<String>,
    pub cwd: Option<String>,
    pub argv: Vec<String>,
    #[serde(default, deserialize_with = "de_null_default")]
    pub env: std::collections::HashMap<String, String>,
    pub cols: Option<u16>,
    pub rows: Option<u16>,
    pub agent_hint: Option<String>,
    pub parent_pane_id: Option<String>,
    pub label: Option<String>,
    pub relationship: Option<Relationship>,
    pub task_id: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct PaneSplit {
    pub pane_id: String,
    pub direction: Option<SplitDirection>,
    pub cwd: Option<String>,
    pub argv: Option<Vec<String>>,
}

#[derive(Debug, Deserialize)]
pub struct PaneRename {
    pub pane_id: String,
    /// `null` or absent clears the name.
    pub name: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct PaneSwap {
    pub pane_id: String,
    pub target_pane_id: String,
}

#[derive(Debug, Deserialize)]
pub struct PaneMove {
    pub pane_id: String,
    pub target_pane_id: String,
    pub direction: Option<SplitDirection>,
}

#[derive(Debug, Deserialize)]
pub struct PaneInput {
    pub pane_id: String,
    pub data_b64: String,
}

#[derive(Debug, Deserialize)]
pub struct PaneResize {
    pub pane_id: String,
    pub cols: u16,
    pub rows: u16,
}

#[derive(Debug, Deserialize)]
pub struct PaneSignal {
    pub pane_id: String,
    #[serde(deserialize_with = "de_signal")]
    pub signal: String,
    pub group: Option<bool>,
}

#[derive(Debug, Deserialize)]
pub struct PaneRead {
    pub pane_id: String,
    pub mode: Option<ReadMode>,
    pub strip_ansi: Option<bool>,
    pub lines: Option<u64>,
    pub after_seq: Option<u64>,
}

#[derive(Debug, Deserialize)]
pub struct PaneWaitForOutput {
    #[serde(flatten)]
    pub read: PaneRead,
    #[serde(rename = "match")]
    pub pattern: String,
    pub regex: Option<bool>,
    pub timeout_s: Option<u64>,
}

#[derive(Debug, Deserialize)]
pub struct PaneAttach {
    pub pane_id: String,
    pub cols: Option<u16>,
    pub rows: Option<u16>,
    pub mark_seen: Option<bool>,
}

#[derive(Debug, Deserialize)]
pub struct PaneClose {
    pub pane_id: String,
    #[serde(default, deserialize_with = "de_opt_signal")]
    pub signal: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct Notify {
    pub pane_id: String,
    pub title: String,
    pub body: Option<String>,
    pub severity: Option<String>,
}

/// One structured option inside a `hook-event` decision payload.
/// Unknown fields ignored (forward compat, docs/08).
#[derive(Debug, Deserialize)]
pub struct DecisionOptionPayload {
    pub id: String,
    pub label: String,
}

/// Structured decision request carried by `hook-event` (directive 2).
#[derive(Debug, Deserialize)]
pub struct DecisionPayload {
    pub id: String,
    pub prompt: String,
    pub options: Vec<DecisionOptionPayload>,
}

#[derive(Debug, Deserialize)]
pub struct HookEvent {
    #[serde(default, deserialize_with = "de_null_default")]
    pub wait_for_answer: bool,
    pub wait_timeout_s: Option<u64>,
    pub agent: String,
    #[serde(rename = "event")]
    pub hook: String,
    pub pane_id: Option<String>,
    pub client_pid: Option<u64>,
    #[serde(default)]
    pub payload: Value,
    pub message: Option<String>,
    pub body: Option<String>,
    pub severity: Option<String>,
    pub title: Option<String>,
    pub decision: Option<DecisionPayload>,
}

#[derive(Debug, Deserialize)]
pub struct DecisionAnswer {
    pub pane_id: String,
    pub decision_id: String,
    pub option_id: String,
}

#[derive(Debug, Deserialize)]
pub struct ReportSession {
    pub pane_id: String,
    pub agent_session_id: String,
    pub agent: Option<String>,
    pub resume_argv: Option<Vec<String>>,
}

#[derive(Debug, Deserialize)]
pub struct Subscribe {
    pub events: Option<Vec<String>>,
    pub from_seq: Option<u64>,
    pub task_ids: Option<Vec<String>>,
}

#[derive(Debug, Deserialize)]
pub struct Wait {
    pub pane_id: String,
    pub until: WaitUntil,
    pub timeout_s: Option<u64>,
    pub after: Option<crate::store::WaitBaseline>,
}

#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum WaitUntil {
    One(String),
    Any(Vec<String>),
}
impl WaitUntil {
    pub fn into_vec(self) -> Vec<String> {
        match self {
            Self::One(value) => vec![value],
            Self::Any(values) => values,
        }
    }
}

// ---- shared value parsing (used by more than one handler) ----

/// `notify` and `hook-event` share severity parsing; absent means Info.
pub fn parse_severity(severity: &Option<String>) -> Result<NotificationSeverity, ParamError> {
    severity
        .as_deref()
        .map(|s| NotificationSeverity::parse(s).ok_or_else(|| bad_params("bad severity")))
        .transpose()
        .map(|o| o.unwrap_or(NotificationSeverity::Info))
}

/// `wait` accepts a lifecycle, an attention, or a pseudo-state.
pub fn validate_until(until: &str) -> Result<(), ParamError> {
    if Attention::parse(until).is_none()
        && Lifecycle::parse(until).is_none()
        && until != "seen"
        && until != "attention_cleared"
        && until != "exited"
    {
        return Err(bad_params(format!("bad 'until': {until}")));
    }
    Ok(())
}

#[derive(Debug, Deserialize)]
pub struct PaneSubmit {
    pub pane_id: String,
    pub text: String,
    pub submit_delay_ms: Option<u64>,
    pub stall_timeout_s: Option<u64>,
}

#[derive(Debug, Deserialize)]
pub struct TaskStart {
    pub repo: String,
    pub contract: Contract,
    pub agent: Option<String>,
    pub argv: Option<Vec<String>>,
    pub label: Option<String>,
    pub parent_pane_id: Option<String>,
    pub context_id: Option<String>,
    pub client_request_id: Option<String>,
    pub relationship: Option<Relationship>,
    pub base_ref: Option<String>,
    pub fetch_first: Option<bool>,
    pub branch: Option<String>,
    pub path: Option<String>,
    pub ready_timeout_s: Option<u64>,
    pub stall_timeout_s: Option<u64>,
    /// Paste-to-Enter delay for the background first submit. Default 300,
    /// the same value as `pane.submit`.
    pub submit_delay_ms: Option<u64>,
}

#[derive(Debug, Deserialize)]
pub struct TaskGet {
    pub task_id: String,
}

#[derive(Debug, Deserialize)]
pub struct TaskList {
    pub context_id: Option<String>,
    pub state: Option<TaskState>,
    pub limit: Option<usize>,
}

#[derive(Debug, Deserialize)]
pub struct TaskCancel {
    pub task_id: String,
}

#[derive(Debug, Deserialize)]
pub struct TaskWait {
    pub task_id: Option<String>,
    pub context_id: Option<String>,
    pub until: Option<Value>,
    pub timeout_s: Option<u64>,
}

#[derive(Debug, Deserialize)]
pub struct TaskReport {
    pub task_id: Option<String>,
    pub pane_id: Option<String>,
    pub status: signaltty_core::model::TaskResultStatus,
    pub summary: String,
    pub artifacts: Option<Vec<signaltty_core::model::Artifact>>,
    pub evidence: Option<Value>,
}

#[derive(Debug, Deserialize)]
pub struct AttentionPending {
    pub limit: Option<usize>,
}

#[derive(Debug, Deserialize)]
pub struct TaskDiff {
    pub task_id: String,
}

#[derive(Debug, Deserialize)]
pub struct TaskFileDiff {
    pub task_id: String,
    pub path: String,
}

#[derive(Debug, Deserialize)]
pub struct TaskFinish {
    pub task_id: String,
    pub mode: String,
    pub target_ref: Option<String>,
    pub delete_branch: Option<bool>,
    pub ignore_dirty: Option<bool>,
}

#[derive(Debug, Deserialize)]
pub struct TaskPrOpen {
    pub task_id: String,
    pub title: Option<String>,
    pub body: Option<String>,
    pub draft: Option<bool>,
}

#[derive(Debug, Deserialize)]
pub struct TaskPrRefresh {
    pub task_id: Option<String>,
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn decode_ignores_unknown_fields() {
        let p: PaneId = decode(&json!({"pane_id": "p1", "future": 1})).unwrap();
        assert_eq!(p.pane_id, "p1");
    }

    #[test]
    fn decode_missing_required_is_bad_params() {
        let err = decode::<PaneId>(&json!({})).unwrap_err();
        assert_eq!(err.0, code::BAD_PARAMS);
    }

    #[test]
    fn decode_mistyped_optional_is_bad_params() {
        // Present-but-mistyped no longer defaults silently.
        assert!(decode::<WorkspaceCreate>(&json!({"cwd": 42})).is_err());
        assert!(decode::<ServerShutdown>(&json!({"force": "yes"})).is_err());
        assert!(decode::<PaneResize>(&json!({"pane_id": "p", "cols": 80, "rows": -1})).is_err());
        // Missing and explicit null still mean absent.
        let p: WorkspaceCreate = decode(&json!({"cwd": Value::Null})).unwrap();
        assert_eq!(p.cwd, None);
    }

    #[test]
    fn defaulted_fields_treat_null_as_absent() {
        let p: PaneSpawn =
            decode(&json!({"workspace_id": "w", "argv": ["sh"], "env": Value::Null})).unwrap();
        assert!(p.env.is_empty());
        let h: HookEvent = decode(&json!({
            "agent": "codex", "event": "Stop", "wait_for_answer": Value::Null,
        }))
        .unwrap();
        assert!(!h.wait_for_answer);
        // Present-but-mistyped is still BAD_PARAMS.
        assert!(decode::<PaneSpawn>(&json!({"workspace_id": "w", "argv": [], "env": 1})).is_err());
        assert!(decode::<HookEvent>(
            &json!({"agent": "codex", "event": "Stop", "wait_for_answer": "yes"})
        )
        .is_err());
    }

    #[test]
    fn split_path_accepts_only_0_and_1() {
        let p: TabSetRatio = decode(&json!({"tab_id": "t", "path": [1, 0], "ratio": 0.5})).unwrap();
        assert_eq!(p.path.0, vec![true, false]);
        assert!(decode::<TabSetRatio>(&json!({"tab_id": "t", "path": [2], "ratio": 0.5})).is_err());
        assert!(
            decode::<TabSetRatio>(&json!({"tab_id": "t", "path": ["x"], "ratio": 0.5})).is_err()
        );
    }

    #[test]
    fn ratio_clamps_to_stops() {
        let p: TabSetRatio = decode(&json!({"tab_id": "t", "path": [], "ratio": 0.99})).unwrap();
        assert_eq!(p.ratio.0, Layout::MAX_RATIO);
        let p: TabSetRatio = decode(&json!({"tab_id": "t", "path": [], "ratio": 0.0})).unwrap();
        assert_eq!(p.ratio.0, Layout::MIN_RATIO);
        assert!(decode::<TabSetRatio>(&json!({"tab_id": "t", "path": [], "ratio": "x"})).is_err());
    }

    #[test]
    fn enums_reject_bad_variants() {
        assert!(decode::<PaneSplit>(&json!({"pane_id": "p", "direction": "up"})).is_err());
        let p: PaneSplit = decode(&json!({"pane_id": "p", "direction": "down"})).unwrap();
        assert!(matches!(p.direction, Some(SplitDirection::Down)));
        assert_eq!(p.direction.unwrap().split_dir(), SplitDir::Down);
        assert!(decode::<PaneRead>(&json!({"pane_id": "p", "mode": "sideways"})).is_err());
    }

    #[test]
    fn decision_payload_decode() {
        // Full decision decodes; unknown fields ignored.
        let h: HookEvent = decode(&json!({
            "agent": "codex", "event": "PermissionRequest",
            "decision": {"id": "d1", "prompt": "Allow?",
                "options": [{"id": "once", "label": "Once", "future": 1}]},
        }))
        .unwrap();
        let d = h.decision.unwrap();
        assert_eq!((d.id.as_str(), d.options.len()), ("d1", 1));
        // Absent decision stays absent.
        let h: HookEvent = decode(&json!({"agent": "codex", "event": "Stop"})).unwrap();
        assert!(h.decision.is_none());
        // Present-but-mistyped is BAD_PARAMS, never silent.
        assert!(
            decode::<HookEvent>(&json!({"agent": "codex", "event": "PermissionRequest",
                "decision": {"id": 42, "prompt": "p", "options": []}}))
            .is_err()
        );
        // Answer ids are required.
        assert!(decode::<DecisionAnswer>(&json!({"pane_id": "p", "decision_id": "d"})).is_err());
        let a: DecisionAnswer =
            decode(&json!({"pane_id": "p", "decision_id": "d", "option_id": "o"})).unwrap();
        assert_eq!(a.option_id, "o");
    }

    #[test]
    fn severity_and_until_parse() {
        assert_eq!(parse_severity(&None).unwrap(), NotificationSeverity::Info);
        assert!(parse_severity(&Some("bogus".to_string())).is_err());
        for u in [
            "blocked",
            "done",
            "unread",
            "seen",
            "attention_cleared",
            "exited",
        ] {
            validate_until(u).unwrap();
        }
        assert!(validate_until("eventually").is_err());
    }
}