yog 0.0.4

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! The Steps tab's JSON shape (§8.5, bl-6233) — the headless serialization of
//! both tiers: the cheap per-step summary list and one step's drill-in. Beside
//! its type for the reason `workdiff::wire` gives — framings, wounds and
//! jsonview docs are this module's own vocabulary — and cut along the same
//! two-tier seam the module itself is cut along.

use serde_json::{Map, Value, json};

use super::{Doc, Orphan, StepDetail, StepSummary, StepsView, ToolIo, Wound};
use crate::budgets::BudgetSpend;
use crate::git_tree::Framing;

/// The decoders, beside the encoders they undo (bl-7067, REMOTE §9 step 2).
pub(crate) mod decode;

/// The `steps` reply body: one row per step, in sequence order, and the
/// view-level orphaned-mail state (bl-ace6) — the wound's key-pair shape,
/// at the top because it is not any one step's fact.
pub(crate) fn steps(view: &StepsView) -> Value {
    let mut map = Map::new();
    map.insert("ok".to_owned(), json!(true));
    map.insert("kind".to_owned(), json!("steps"));
    map.insert(
        "rows".to_owned(),
        Value::Array(view.steps.iter().map(step_row).collect()),
    );
    map.insert("orphaned".to_owned(), json!(view.orphan.orphaned()));
    if let Orphan::Spoke(reason) = &view.orphan {
        map.insert("orphan_reason".to_owned(), json!(reason));
    }
    Value::Object(map)
}

/// One step's summary. The timestamps and the read-state commit are absent
/// keys when `meta.json` did not carry them — the same absence the list paints,
/// never a zero or an empty string standing in for a fact nobody recorded.
fn step_row(step: &StepSummary) -> Value {
    let mut map = Map::new();
    map.insert("seq".to_owned(), json!(step.seq));
    map.insert("framing".to_owned(), json!(framing_token(step.framing)));
    map.insert("attempts".to_owned(), json!(step.attempts));
    map.insert("tokens".to_owned(), spend_value(&step.tokens));
    for (key, value) in [
        ("commit", step.commit.as_ref()),
        ("started_at", step.started_at.as_ref()),
        ("ended_at", step.ended_at.as_ref()),
    ] {
        if let Some(value) = value {
            map.insert(key.to_owned(), json!(value));
        }
    }
    // The §8.3 login affordance: offered at all, and the provider row it points
    // at when one was derivable. `auth_row` absent is `Unrouted` — the
    // affordance paints and there is nothing to pick for you.
    map.insert("auth_failed".to_owned(), json!(step.auth_failed.offered()));
    if let Some(row) = step.auth_failed.row() {
        map.insert("auth_row".to_owned(), json!(row));
    }
    // The §7.3 wound: whether this step is one, and the adapter's own reason
    // when it left words behind.
    map.insert("wounded".to_owned(), json!(step.wound.wounded()));
    if let Wound::Spoke(reason) = &step.wound {
        map.insert("wound_reason".to_owned(), json!(reason));
    }
    Value::Object(map)
}

/// The §4.4 terminal classification, in the three words the seat renders.
fn framing_token(framing: Framing) -> &'static str {
    match framing {
        Framing::Complete => "complete",
        Framing::Failed => "failed",
        Framing::Killed => "killed",
    }
}

/// The four ARCH §6 counters and their total — the total is derived, and it is
/// carried because every seat that reads a step reads it against a ceiling.
///
/// `pub(crate)` since bl-7067: the §3.5 board figure spells its token half in
/// exactly this shape, the way `files_view::wire::preview_value` already serves
/// the work diff's patch. One spelling of one thing, in one place.
pub(crate) fn spend_value(spend: &BudgetSpend) -> Value {
    json!({
        "input": spend.input_tokens, "output": spend.output_tokens,
        "cache_read": spend.cache_read_tokens, "cache_write": spend.cache_write_tokens,
        "total": spend.total_tokens(),
    })
}

/// The `step` reply body: one step's four record files, its `response.json`
/// events and every tool call's input and output.
pub(crate) fn detail(detail: &StepDetail) -> Value {
    json!({
        "ok": true, "kind": "step", "seq": detail.seq,
        "meta": doc_value(&detail.meta),
        "request": doc_value(&detail.request),
        "staging": doc_value(&detail.staging),
        "response": Value::Array(detail.response.iter().map(doc_value).collect()),
        "tools": Value::Array(detail.tools.iter().map(tool_value).collect()),
    })
}

/// One record file as data: parsed, absent, or bytes that are not JSON. The
/// three stay distinct on the wire exactly as they do on screen — rendered
/// bare, malformed content is indistinguishable from a file whose content
/// happens to be that text, which is why [`super::UNPARSED`] exists at all.
fn doc_value(doc: &Doc) -> Value {
    match doc {
        // `raw` rides beside the tree because a `serde_json::Value` is not a
        // lossless record of its source (key order, spacing and number spelling
        // all go), so the tree alone could never answer "what does the file
        // say" (S7-T1).
        Doc::Json { value, raw } => json!({
            "kind": "json", "value": value,
            "raw": String::from_utf8_lossy(raw),
        }),
        Doc::Absent => json!({ "kind": "absent" }),
        Doc::Unparsed(raw) => json!({
            "kind": "unparsed", "note": super::UNPARSED,
            "raw": String::from_utf8_lossy(raw),
        }),
    }
}

/// One tool call's records (ARCH §3.3), with lernie's own `is_error` reading.
fn tool_value(tool: &ToolIo) -> Value {
    json!({
        "tool_id": tool.tool_id, "is_error": tool.is_error,
        "input": doc_value(&tool.input), "output": doc_value(&tool.output),
    })
}