yog 0.0.51

yog: the standalone server for litany loops — the world, the balls and the conversations, behind one wire
Documentation
//! The grammar's generic field access — the prelude every rewrite shares, and
//! the plain read/replace the §9.5 pane is built on.

use super::TEMPLATE_PROVIDERS;
use crate::model_pick::grammar::{
    GrammarError, PROVIDERS_YAML, ROLES, entry_field, entry_names, set_field,
};

/// A block this grammar has no reader for any more (the `models:` table went
/// with bl-9c8a): the primitives are generic over the block key, and a foreign
/// block is the fixture that proves it.
const OTHER: &str = "models";
const OTHER_FILE: &str = "other.yaml";
const OTHER_TEXT: &str = "# header\n\nmodels:\n  gpt-5.4:\n    model_id: gpt-5.4\n";

#[test]
fn entry_names_lists_the_block_in_file_order_and_absence_is_empty() {
    assert_eq!(
        entry_names(TEMPLATE_PROVIDERS, ROLES),
        ["worker", "compactor"]
    );
    assert_eq!(entry_names(OTHER_TEXT, OTHER), ["gpt-5.4"]);
    // No such block, an inline one, and an empty file all declare nothing.
    assert!(entry_names(TEMPLATE_PROVIDERS, OTHER).is_empty());
    assert!(entry_names("roles: {}\n", ROLES).is_empty());
    assert!(entry_names("", ROLES).is_empty());
}

#[test]
fn entry_field_reads_one_value_and_folds_every_miss_to_none() {
    assert_eq!(
        entry_field(TEMPLATE_PROVIDERS, ROLES, "worker", "model").as_deref(),
        Some("gpt-5.4")
    );
    // No block, no entry, no field — three misses, one value.
    assert_eq!(
        entry_field(TEMPLATE_PROVIDERS, OTHER, "worker", "model"),
        None
    );
    assert_eq!(
        entry_field(TEMPLATE_PROVIDERS, ROLES, "nobody", "model"),
        None
    );
    assert_eq!(
        entry_field(TEMPLATE_PROVIDERS, ROLES, "compactor", "tools"),
        None
    );
}

#[test]
fn set_field_replaces_one_line_and_preserves_every_other_byte() {
    let out = set_field(
        PROVIDERS_YAML,
        TEMPLATE_PROVIDERS,
        ROLES,
        "compactor",
        "model",
        "haiku-9",
    )
    .unwrap();
    assert!(out.contains("    model: haiku-9\n"));
    assert!(out.contains("    tools: [bash, read_file, load_skill]\n"));
    assert!(out.contains("  worker:\n    provider: codex\n    model: gpt-5.4\n"));
}

/// One refusal from a `providers.yaml`-shaped rewrite of `entry.field`.
fn refusal(text: &str, entry: &str, field: &'static str) -> GrammarError {
    set_field(PROVIDERS_YAML, text, ROLES, entry, field, "x").unwrap_err()
}

#[test]
fn set_field_declines_loudly_on_every_shape_it_does_not_edit() {
    // An inline block key would need a YAML transform.
    assert!(
        matches!(refusal("roles: {}\n", "worker", "model"), GrammarError::Inline { key, .. } if key == ROLES)
    );
    // No block at all: there is no such entry to rewrite.
    assert!(
        matches!(refusal("models:\n  m:\n", "worker", "model"), GrammarError::NoEntry { entry, .. } if entry == "worker")
    );
    // The block is there; the entry is not.
    assert!(
        matches!(refusal(TEMPLATE_PROVIDERS, "nobody", "model"), GrammarError::NoEntry { entry, .. } if entry == "nobody")
    );
    // The entry is there; the field is not.
    assert!(
        matches!(refusal(TEMPLATE_PROVIDERS, "compactor", "tools"), GrammarError::NoField { field, .. } if field == "tools")
    );
}

#[test]
fn the_file_name_a_refusal_carries_is_the_callers() {
    let err = set_field(OTHER_FILE, "", OTHER, "m", "provider", "codex").unwrap_err();
    assert!(err.to_string().starts_with(OTHER_FILE));
}