yog 0.0.76

yog: the standalone server for litany loops — the world, the balls and the conversations, behind one wire
//! Round-trip and refusal tables for the §9 config family's envelope (bl-3f46):
//! every destination, every lineage mode and every marks mode re-enters as
//! itself, and every malformed target refuses by name.

use crate::boundary::codec::{decode, encode};
use crate::boundary::config::ConfigFile;
use crate::boundary::config::Read;
use crate::boundary::config::Write;
use crate::boundary::{Action, Gesture, Query};
use crate::config_edit::branch::edit::EditOrigin;
use crate::model_pick::{Effort, Tuning};
use crate::proposals::{Settle, Verdict};
use serde_json::json;

fn rt(gesture: Gesture) {
    let encoded = encode(&gesture);
    assert_eq!(decode(&encoded), Ok(gesture.clone()), "via {encoded}");
}

fn applying(file: ConfigFile) -> Gesture {
    Gesture::Act(Action::Config(Write::Apply {
        file,
        // Newlines and indentation ride through untouched — a config file's
        // whitespace is the file.
        text: "models:\n  gpt-5.4:\n    provider: codex\n".to_owned(),
    }))
}

/// The brazen destination and the provider table, each naming their sphere
/// (bl-fcd5) — a wall-scoped gesture has no spelling without one.
fn brazen() -> ConfigFile {
    ConfigFile::Brazen {
        workspace: "ws".to_owned(),
    }
}

fn providers() -> Query {
    Query::Config(Read::Providers {
        workspace: "ws".to_owned(),
    })
}

fn branch(origin: EditOrigin) -> ConfigFile {
    ConfigFile::Branch {
        workspace: "ws".to_owned(),
        lineage: "default".to_owned(),
        origin,
        path: "providers.yaml".to_owned(),
    }
}

/// The §9 config family's values: every destination a write names, the marks
/// amendment, the model pick, and the same destinations read — a read is the
/// write minus the field that makes it one (bl-0164), so both directions are
/// entries here.
pub(crate) fn surface() -> Vec<Gesture> {
    let mut out = Vec::new();
    // The §9.4 tuning pair (bl-23bd), one entry per arm as the surface's own
    // rule demands: every level AND the off that is not one, both sides of the
    // checkbox. A table that only ever spells the easy case proves only that.
    for level in [
        Some(Effort::Low),
        Some(Effort::Medium),
        Some(Effort::High),
        None,
    ] {
        out.push(Gesture::Act(Action::Config(Write::Tune(Tuning::Effort {
            workspace: "ws".to_owned(),
            role: "worker".to_owned(),
            level,
        }))));
    }
    for on in [true, false] {
        out.push(Gesture::Act(Action::Config(Write::Tune(
            Tuning::Priority {
                workspace: "ws".to_owned(),
                role: "compactor".to_owned(),
                on,
            },
        ))));
    }
    for file in [
        brazen(),
        ConfigFile::LitanyModels,
        ConfigFile::Cadence,
        ConfigFile::LitanyWorkflow {
            name: "review".to_owned(),
        },
        branch(EditOrigin::Advance),
        branch(EditOrigin::Fork {
            source: "base".to_owned(),
        }),
        branch(EditOrigin::Orphan),
    ] {
        out.push(applying(file));
    }
    for branch in ["balls/tasks", "balls/agents/corp"] {
        out.push(Gesture::Act(Action::Config(Write::Marks {
            workspace: "ws".to_owned(),
            branch: branch.to_owned(),
        })));
    }
    out.push(Gesture::Act(Action::Config(Write::Pick {
        workspace: "ws".to_owned(),
        role: "worker".to_owned(),
        provider: "codex".to_owned(),
        model: "gpt-5.4".to_owned(),
    })));
    for file in [
        brazen(),
        ConfigFile::LitanyModels,
        ConfigFile::Cadence,
        ConfigFile::LitanyWorkflow {
            name: "review".to_owned(),
        },
    ] {
        out.push(Gesture::Ask(Query::Config(Read::File { file })));
    }
    out.push(Gesture::Ask(Query::Config(Read::Marks {
        workspace: "ws".to_owned(),
    })));
    out.push(Gesture::Ask(providers()));
    out.push(Gesture::Ask(Query::Config(Read::Roles {
        workspace: "ws".to_owned(),
    })));
    // The §9.6 pair (bl-dd88): both settlings, because the verdict is the whole
    // of what one envelope says and a table that only ever spelled accept would
    // prove only that; and both depths of the read, because naming an id and
    // naming none are two questions and the id is absent rather than null in
    // the second.
    for verdict in [Verdict::Accept, Verdict::Reject] {
        out.push(Gesture::Act(Action::Config(Write::Proposal(Settle {
            workspace: "ws".to_owned(),
            id: "20260906T090000Z-r001".to_owned(),
            verdict,
        }))));
    }
    for id in [None, Some("20260906T090000Z-r001".to_owned())] {
        out.push(Gesture::Ask(Query::Config(Read::Proposals {
            workspace: "ws".to_owned(),
            id,
        })));
    }
    out
}

#[test]
fn every_config_gesture_round_trips() {
    for gesture in surface() {
        rt(gesture);
    }
}

#[test]
fn the_config_envelope_names_its_target_and_carries_the_text_whole() {
    let text = "[providers.codex]\nauth = \"none\"\n";
    let encoded = encode(&Gesture::Act(Action::Config(Write::Apply {
        file: brazen(),
        text: text.to_owned(),
    })));
    assert_eq!(encoded["op"], "config");
    assert_eq!(encoded["target"]["file"], "brazen");
    assert_eq!(encoded["text"], text);
}

/// bl-fcd5 — the envelope carries the sphere, and strictly: a wall-scoped op
/// with no `workspace` is refused by name rather than decoded into a gesture
/// the executor would have to guess a wall for.
#[test]
fn a_wall_scoped_envelope_without_its_workspace_is_refused() {
    for value in [
        json!({ "op": "providers" }),
        json!({ "op": "config", "target": { "file": "brazen" } }),
        json!({ "op": "config", "target": { "file": "brazen" }, "text": "x" }),
    ] {
        let err = decode(&value).unwrap_err();
        assert!(err.contains("workspace"), "{value}: {err}");
    }
}

/// The config family's reads (§8.5, bl-0164): the same op as their write,
/// minus the field that makes it one — `text` for a config destination,
/// `mode` for the knob.
#[test]
fn a_field_left_out_reads_instead_of_writing() {
    let read = encode(&Gesture::Ask(Query::Config(Read::File { file: brazen() })));
    assert_eq!(read["op"], "config");
    assert!(read.get("text").is_none(), "{read}");

    let marks = encode(&Gesture::Ask(Query::Config(Read::Marks {
        workspace: "ws".to_owned(),
    })));
    assert_eq!(marks["op"], "marks");
    assert!(marks.get("branch").is_none(), "{marks}");
}

#[test]
fn a_marks_envelope_names_the_workspace_and_the_branch() {
    let encoded = encode(&Gesture::Act(Action::Config(Write::Marks {
        workspace: "ws".to_owned(),
        branch: "balls/agents/corp".to_owned(),
    })));
    assert_eq!(encoded["op"], "marks");
    assert_eq!(encoded["workspace"], "ws");
    assert_eq!(encoded["branch"], "balls/agents/corp");
}

#[test]
fn malformed_config_envelopes_refuse_with_a_reason() {
    let cases = [
        (json!({"op": "config", "text": "x"}), "missing target"),
        (
            json!({"op": "config", "target": "brazen", "text": "x"}),
            "not an object",
        ),
        (
            json!({"op": "config", "target": {"file": "enhance"}, "text": "x"}),
            "unknown target file",
        ),
        (
            json!({"op": "config", "target": {"file": "litany-workflow"}, "text": "x"}),
            "\"name\"",
        ),
        (
            json!({"op": "config", "target": {"file": "branch", "workspace": "/ws",
                   "lineage": "d", "path": "p", "origin": "rebase"}, "text": "x"}),
            "unknown origin",
        ),
        (
            json!({"op": "config", "target": {"file": "branch", "workspace": "/ws",
                   "lineage": "d", "path": "p", "origin": "fork"}, "text": "x"}),
            "\"source\"",
        ),
        // The space's own lawfulness rule refuses these, not a second check
        // here: a branch with whitespace, and balls' own landing branch.
        (
            json!({"op": "marks", "workspace": "/ws", "branch": "two words"}),
            "one word",
        ),
        (
            json!({"op": "marks", "workspace": "/ws", "branch": "balls/config"}),
            "landing branch",
        ),
        (
            json!({"op": "model", "workspace": "/ws", "role": "worker", "provider": "codex"}),
            "\"model\"",
        ),
    ];
    for (envelope, needle) in cases {
        let err = decode(&envelope).unwrap_err();
        assert!(err.contains(needle), "{envelope} refused with {err:?}");
    }
}