yog 0.0.31

yog: the standalone server for litany loops — the world, the balls and the conversations, behind one wire
Documentation
//! The start-family halves of the [`codec`](super): the §3.4 [`Payload`] rung,
//! its [`BallSpec`], the composer's [`Prepared`], and the token spellings of
//! [`JoinState`] / [`Origin`] they carry. Split from the top-level codec per
//! §12's line budget; every encoder here is matched by a decoder and every
//! variant round-trips (the §8.5 parity tests).

use crate::opslog::Origin;
use crate::projects::join::JoinState;
use crate::start::{BallSpec, Payload, Prepared};
use serde_json::{Map, Value, json};

use super::{opt_path_of, opt_str_of, path_of, str_of, strings_of};

/// Encode the §3.4 payload rung. `rung` is the discriminant; each rung carries
/// exactly its own inputs.
pub(super) fn encode_payload(payload: &Payload) -> Value {
    match payload {
        Payload::Bare => json!({ "rung": "bare" }),
        Payload::Path { dir } => json!({ "rung": "path", "dir": dir.to_string_lossy() }),
        Payload::Ball { project, ball } => json!({
            "rung": "ball",
            "project": project,
            "ball": encode_ball(ball),
        }),
    }
}

/// Decode the §3.4 payload rung — strict: an unknown rung or a missing input
/// is a refusal, never a default (a guessed gesture is worse than none).
pub(super) fn decode_payload(v: &Value) -> Result<Payload, String> {
    let obj = v.as_object().ok_or("payload: not an object")?;
    match str_of(obj, "rung")?.as_str() {
        "bare" => Ok(Payload::Bare),
        "path" => Ok(Payload::Path {
            dir: path_of(obj, "dir")?,
        }),
        "ball" => Ok(Payload::Ball {
            project: str_of(obj, "project")?,
            ball: decode_ball(obj.get("ball").ok_or("payload: missing ball")?)?,
        }),
        other => Err(format!("payload: unknown rung {other:?}")),
    }
}

/// Encode the ball rung's spec: `id` present ⇒ existing (with its §3.5 join
/// state), absent ⇒ new (`bl create` mints the id).
fn encode_ball(ball: &BallSpec) -> Value {
    match ball {
        BallSpec::Existing {
            id,
            title,
            body,
            join,
            tags,
        } => json!({ "id": id, "title": title, "body": body,
                     "join": join_token(*join), "tags": tags }),
        BallSpec::New { title, body } => json!({ "title": title, "body": body }),
    }
}

fn decode_ball(v: &Value) -> Result<BallSpec, String> {
    let obj = v.as_object().ok_or("ball: not an object")?;
    let title = str_of(obj, "title")?;
    let body = str_of(obj, "body")?;
    match obj.get("id") {
        Some(id) => Ok(BallSpec::Existing {
            id: id.as_str().ok_or("ball: id not a string")?.to_owned(),
            title,
            body,
            join: parse_join(&str_of(obj, "join")?)?,
            // Required, like every other input of a rung: an absent array is a
            // gesture whose §8.7 birth policy is unknown, and guessing the
            // untagged answer would silently birth on the default lineage.
            tags: strings_of(obj, "tags")?,
        }),
        None => Ok(BallSpec::New { title, body }),
    }
}

/// Encode the composer's fire-time parameters — the [`Action::Prompt`]
/// (crate::boundary::Action::Prompt) carrier and the `prepare` reply's body:
/// one spelling, so a reply deposits back verbatim as the next gesture.
pub(crate) fn encode_prepared(p: &Prepared) -> Value {
    json!({
        "workspace": p.workspace,
        // The §3.3 typed binding (bl-6654). `null` is the bare rung's "bind
        // nothing" — a real value of the field, not an omission, so a reply
        // deposits back as the same gesture it came from.
        "binding": p.binding.as_ref().map(|b| b.to_string_lossy()),
        // The §8.7 birth policy, same shape and same reason as `binding`: a
        // real `null` for "the default lineage", so a reply deposits back as
        // the gesture it came from.
        "lineage": p.lineage,
        "goal": p.goal,
        "origin": origin_token(p.origin),
    })
}

/// **The §8.1 start family's two envelopes** — the staging gesture and the
/// deferred fire — beside the prepared body they share, rather than spelled in
/// the action table: they are the one pair whose payloads are each other's,
/// and the table stays one row per gesture. `seed` is the firing seat's own
/// §3.3 prediction (bl-1747), `null` for a caller that predicted no name.
///
/// Anything but those two is unreachable — the table's arm names them — and
/// answers `null`, which decode refuses as an envelope with no `op`.
pub(crate) fn encode_start(action: &crate::boundary::Action) -> Value {
    use crate::boundary::Action;
    match action {
        Action::Prepare { workspace, payload } => {
            json!({ "op": "prepare", "workspace": workspace,
                    "payload": encode_payload(payload) })
        }
        Action::Prompt {
            prepared,
            goal,
            seed,
        } => json!({ "op": "prompt", "prepared": encode_prepared(prepared),
                     "goal": goal, "seed": seed }),
        _ => Value::Null,
    }
}

pub(crate) fn decode_prepared(v: &Value) -> Result<Prepared, String> {
    let obj = v.as_object().ok_or("prepared: not an object")?;
    Ok(Prepared {
        workspace: str_of(obj, "workspace")?,
        binding: opt_path_of(obj, "binding")?,
        lineage: opt_str_of(obj, "lineage")?,
        goal: str_of(obj, "goal")?,
        origin: parse_origin(&str_of(obj, "origin")?)?,
    })
}

/// The §3.5 join-state tokens — the boundary spelling of the roster's cells.
pub(crate) fn join_token(state: JoinState) -> &'static str {
    match state {
        JoinState::ReadyStartable => "ready",
        JoinState::Blocked => "blocked",
        JoinState::Bound => "bound",
        JoinState::ClaimedElsewhere => "claimed-elsewhere",
        JoinState::Delivered => "delivered",
        JoinState::UnassignedWorkspace => "unassigned-workspace",
        JoinState::OrphanedProject => "orphaned-project",
    }
}

pub(crate) fn parse_join(token: &str) -> Result<JoinState, String> {
    match token {
        "ready" => Ok(JoinState::ReadyStartable),
        "blocked" => Ok(JoinState::Blocked),
        "bound" => Ok(JoinState::Bound),
        "claimed-elsewhere" => Ok(JoinState::ClaimedElsewhere),
        "delivered" => Ok(JoinState::Delivered),
        "unassigned-workspace" => Ok(JoinState::UnassignedWorkspace),
        "orphaned-project" => Ok(JoinState::OrphanedProject),
        other => Err(format!("unknown join state {other:?}")),
    }
}

/// The §7.3 origin tokens — the same three the ops line records (§4.2).
pub(crate) fn origin_token(origin: Origin) -> &'static str {
    match origin {
        Origin::Balls => "balls",
        Origin::Conversation => "conversation",
        Origin::World => "world",
    }
}

pub(crate) fn parse_origin(token: &str) -> Result<Origin, String> {
    match token {
        "balls" => Ok(Origin::Balls),
        "conversation" => Ok(Origin::Conversation),
        "world" => Ok(Origin::World),
        other => Err(format!("unknown origin {other:?}")),
    }
}

/// An optional string field on the way OUT: present encodes, absent stays
/// absent — `None` and `""` are different facts (`--body ""` is an explicit
/// empty body). Its reader is [`fields::opt_str_of`](super::fields::opt_str_of).
pub(super) fn opt_field(map: &mut Map<String, Value>, key: &str, v: Option<&String>) {
    if let Some(s) = v {
        map.insert(key.to_owned(), Value::String(s.clone()));
    }
}