lernie 0.1.63

lernie: the operator seat — the window and wire client for a yog server
//! **Reading the corpus** — the one walk over `corpus/`, shared by the two
//! replays that judge this seat's codec (`src/reply/tests/corpus.rs` for what
//! it reads, `src/verbs/tests/corpus.rs` for what it writes).
//!
//! It is here rather than beside either of them because the corpus is one
//! artifact and a second walk over it is a second opinion about what is in it.
//!
//! **Two provenances, told apart by the bytes and not by a list.** A file
//! either carries yog's own fixture envelope — `direction`, `shape`,
//! `protocol` and N `frames` — which makes it **vendored**, generated by the
//! boundary that is the protocol authority and copied in unmodified by
//! `scripts/refresh-corpus.sh`; or it is one bare frame this repository wrote,
//! which is how a frame captured off a live engine drops in and how the
//! malformed shapes upstream cannot generate are held. Neither form needs a
//! manifest to say which it is.
//!
//! **The protocol stamp is checked here, once, on every file read.** A shape
//! stamped past what [`PROTOCOL`] speaks is a corpus this build cannot be
//! judged by, and the refusal names both numbers — the same sentence the
//! version preface owes an operator, for the same reason.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use serde_json::Value;

use crate::channel::hello::PROTOCOL;

/// **Projecting a frame back to an older edition, and mutating a word in it**
/// — the two edits the grows-only contract is judged by.
pub(crate) mod editions;

/// The four assertion directories. `corpus/README.md` is the contract they
/// carry.
///
/// Three of them are the three outcomes a reply frame can have. The fourth,
/// `unpainted/`, is a *reading* of the third rather than a fourth outcome: a
/// perfectly good frame of a kind no pane here renders, refused by name on
/// rung 2 (`crate::reply::read::unpainted`). It was split out of `unreadable/`
/// because the two say opposite things about this seat — one is a defect on
/// the wire or in a decoder, the other is a pane nobody has built, which is a
/// parity fact with a reason and never an unreadability.
pub(crate) const CLASSES: [&str; 4] = ["answers", "refusals", "unpainted", "unreadable"];

/// The upstream envelope's discriminant: present exactly on a vendored file.
const DIRECTION: &str = "direction";

/// The corpus root, resolved off the manifest rather than the working
/// directory: `cargo test` runs from the crate root today and a harness that
/// changed that would silently empty the corpus.
pub(crate) fn root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("corpus")
}

/// Every `*.json` file in one corpus directory, by name.
///
/// **A directory that enumerates nothing fails here**, which is the second
/// direction every gate in this repo holds: a broken walk must not pass as a
/// clean corpus.
pub(crate) fn files(dir: &str) -> Vec<PathBuf> {
    let mut found: Vec<PathBuf> = std::fs::read_dir(root().join(dir))
        .unwrap_or_else(|e| panic!("corpus/{dir}: {e}"))
        .flatten()
        .map(|entry| entry.path())
        .filter(|path| path.extension().is_some_and(|ext| ext == "json"))
        .collect();
    found.sort();
    assert!(
        !found.is_empty(),
        "corpus/{dir} holds no file — the walk is broken, not the corpus"
    );
    found
}

/// One corpus file: where it came from, and the frames it carries.
pub(crate) struct Fixture {
    /// The file's own path, for a failure that has to name it.
    pub(crate) path: PathBuf,
    /// The upstream shape this file is the fixture of, or `None` for a frame
    /// this repository wrote. The file stem equals the shape when it is one,
    /// which is what lets `scripts/refresh-corpus.sh` find it by name.
    pub(crate) shape: Option<String>,
    /// The frames themselves — one for a bare file, N for a vendored one.
    pub(crate) frames: Vec<Value>,
}

/// Read one file into the frames it carries, checking its stamp on the way.
pub(crate) fn fixture(path: &Path) -> Fixture {
    let text = std::fs::read_to_string(path).expect("a corpus file");
    let value: Value = serde_json::from_str(&text)
        .unwrap_or_else(|e| panic!("{}: not JSON — {e}", path.display()));
    let Some(obj) = value.as_object().filter(|o| o.contains_key(DIRECTION)) else {
        return Fixture {
            path: path.to_owned(),
            shape: None,
            frames: vec![value],
        };
    };
    let stamp = obj["protocol"].as_u64().expect("a stamped fixture");
    assert!(
        stamp <= u64::from(PROTOCOL),
        "{} is stamped protocol {stamp} and this seat speaks {PROTOCOL} — \
         the corpus is ahead of this build; upgrade the seat",
        path.display()
    );
    let frames = obj["frames"].as_array().expect("frames").clone();
    assert!(
        !frames.is_empty(),
        "{} carries no frame — a fixture that replays nothing passes forever",
        path.display()
    );
    Fixture {
        path: path.to_owned(),
        shape: Some(obj["shape"].as_str().expect("a named shape").to_owned()),
        frames,
    }
}

/// **The standing shape record** — `corpus/shapes.json`, vendored whole: every
/// shape upstream has, with every field path in it stamped with the EDITION it
/// appeared at.
///
/// The signature used to be a bare list of paths and is now a map from path to
/// stamp (yog's `docs/REMOTE.md` §3.2): additions no longer move `PROTOCOL`, so
/// the record has to say *when* each path arrived or no client could tell a
/// field an older engine cannot spell from one it left out.
pub(crate) struct Record {
    /// The version the corpus as a whole is for.
    pub(crate) protocol: u64,
    /// The edition the current major was cut at: every path stamped at or below
    /// it is required on every engine of this major.
    pub(crate) floor: u64,
    /// Each `<direction>/<shape>` key, and under it every `<path>:<type>`
    /// upstream spells with the edition it appeared at.
    pub(crate) shapes: BTreeMap<String, Signature>,
    /// What upstream has announced it will remove: a whole shape, or one field
    /// path inside one spelled `<shape><path>` with no type suffix.
    pub(crate) deprecated: Vec<String>,
}

/// One shape's field paths, each stamped with the edition it appeared at.
pub(crate) type Signature = BTreeMap<String, u64>;

/// Read it. The corpus-wide protocol is checked by the caller that can say
/// what a mismatch means; the per-shape stamps are [`fixture`]'s.
pub(crate) fn record() -> Record {
    let text = std::fs::read_to_string(root().join("shapes.json")).expect("corpus/shapes.json");
    let value: Value = serde_json::from_str(&text).expect("shapes.json is JSON");
    Record {
        protocol: value["protocol"].as_u64().expect("a corpus protocol"),
        floor: value["floor"].as_u64().expect("a corpus floor"),
        shapes: value["shapes"]
            .as_object()
            .expect("the shape record")
            .iter()
            .map(|(key, shape)| (key.clone(), signature(shape)))
            .collect(),
        deprecated: value["deprecated"]
            .as_array()
            .expect("a deprecation list")
            .iter()
            .map(|named| named.as_str().expect("a deprecated name").to_owned())
            .collect(),
    }
}

/// One shape's stamped signature, read strictly: every path carries an edition
/// and a path that does not is a record this build cannot be judged by.
fn signature(shape: &Value) -> Signature {
    shape["signature"]
        .as_object()
        .expect("a signature")
        .iter()
        .map(|(path, stamp)| {
            (
                path.clone(),
                stamp
                    .as_u64()
                    .unwrap_or_else(|| panic!("{path} carries no edition")),
            )
        })
        .collect()
}