backhopper-core 0.37.0

Core types, snapshot I/O, store, and compatibility analysis for backhopper
Documentation
// Copyright (C) 2026 Michael S. Klishin and Contributors
// SPDX-License-Identifier: Apache-2.0 OR MIT
// See LICENSE-APACHE and LICENSE-MIT for details.

//! Generation of versioned envelope JSON-schemas.
//!
//! Behind the `schemars` feature so downstream consumers that don't
//! need schema metadata don't pay the build cost.

use schemars::{JsonSchema, schema_for};

use crate::model::wire_envelope::WireEnvelope;
use serde_json::{Value, json};
use thiserror::Error;

use crate::jigsaw::{JigsawCheckPayload, JigsawPlanPayload};
use crate::model::batch::BatchPayload;
use crate::model::cache::{
    CacheListPayload, CacheMutationPayload, CacheShowPayload, CacheStatsPayload,
};
use crate::model::check_payload::CheckPayload;
use crate::model::summary::SummaryRow;
use crate::suites::SuitePlan;

pub use crate::envelope_version::{
    CURRENT_SCHEMA_VERSION, MIN_EMBEDDED_VERSION, embedded_versions,
};

/// Frozen v1 schema bytes. Generated before `touched_paths` and
/// `pr_commits` were added to `SeriesEvaluation`; preserved so
/// `schema show 1` stays a stable historical record across binary
/// versions.
const SCHEMA_V1_FROZEN: &str = include_str!("schema_v1_snapshot.json");

/// Frozen v3 and v5 schema bytes. Generated before v7 added `series`
/// and `parent_count` to `SummaryRow`; frozen so older versions stay
/// stable historical records instead of silently absorbing later
/// type changes. v4 and v6 derive from these (they only retitle).
const SCHEMA_V3_FROZEN: &str = include_str!("schema_v3_snapshot.json");
const SCHEMA_V5_FROZEN: &str = include_str!("schema_v5_snapshot.json");
/// v2, v7, v8, and v9 froze when v10 changed the live types they
/// were generated from (`SeriesEvaluation` grew the already-present
/// and dep-pin diagnostics): historical versions must not drift.
const SCHEMA_V2_FROZEN: &str = include_str!("schema_v2_snapshot.json");
const SCHEMA_V7_FROZEN: &str = include_str!("schema_v7_snapshot.json");
const SCHEMA_V8_FROZEN: &str = include_str!("schema_v8_snapshot.json");
const SCHEMA_V9_FROZEN: &str = include_str!("schema_v9_snapshot.json");
const SCHEMA_V10_FROZEN: &str = include_str!("schema_v10_snapshot.json");
const SCHEMA_V11_FROZEN: &str = include_str!("schema_v11_snapshot.json");
/// v12 froze when v13 added the apply forecast to the live payload
/// types (`SeriesEvaluation.apply`, `BatchResult.apply`).
const SCHEMA_V12_FROZEN: &str = include_str!("schema_v12_snapshot.json");
/// v13 froze when v14 added the symbol-axis target findings to the
/// live payload types (`SeriesEvaluation.target_findings`,
/// `BatchResult.target_findings`, `SummaryRow.target_findings`).
const SCHEMA_V13_FROZEN: &str = include_str!("schema_v13_snapshot.json");
/// v14 froze when v15 added `ExportedTypeUndefinedOnTarget` to
/// `Reason`, which every payload carrying a verdict embeds.
const SCHEMA_V14_FROZEN: &str = include_str!("schema_v14_snapshot.json");
/// v15 froze when v16 removed the `siblings doctor` verb: the
/// `siblings_doctor_payload` document, `CacheLevel::Siblings`, and
/// `CacheStatsPayload.siblings` left the live types.
const SCHEMA_V15_FROZEN: &str = include_str!("schema_v15_snapshot.json");
/// v16 froze when v17 (doc 025) added six new `Reason` variants
/// (`BehaviourCallbackUnknownOnPin`, `BehaviourCallbackMissingOnPin`,
/// `OptionKeyUnknownOnPin`, `SuiteNotRegisteredForCt`,
/// `SchemaFeatureUnsupportedOnPin`, `SchemaKeyReaderMissing`) and
/// `Diagnostics.dormant_detectors` to the live types.
const SCHEMA_V16_FROZEN: &str = include_str!("schema_v16_snapshot.json");
/// v17 froze when v18 registered the `jigsaw plan` and `jigsaw check`
/// payloads in the live document.
const SCHEMA_V17_FROZEN: &str = include_str!("schema_v17_snapshot.json");

/// Errors that can come out of schema generation.
#[derive(Debug, Error)]
pub enum SchemaError {
    #[error("no schema embedded for version {requested}; known versions: {known:?}")]
    UnknownVersion { requested: u32, known: Vec<u32> },
}

/// Canonical JSON for one schema version: sorted keys, trailing
/// newline. Stable across regenerations so diffs stay informative.
pub fn rendered_schema(version: u32) -> Result<String, SchemaError> {
    let value = schema_value_for(version)?;
    let mut bytes = serde_json::to_vec_pretty(&value).expect("schemars output serialises");
    bytes.push(b'\n');
    Ok(String::from_utf8(bytes).expect("pretty json is utf-8"))
}

/// One embedded schema version: a frozen historical snapshot, or a
/// function deriving the value from the current live types.
enum Embedded {
    Frozen(&'static str),
    Derived(fn() -> Value),
}

/// One row per version `schema_value_for` can answer, in ascending
/// order from `MIN_EMBEDDED_VERSION` to `CURRENT_SCHEMA_VERSION`. The
/// assertion below turns a missing, duplicated, or misplaced row into a
/// compile error rather than a wrong answer at run time.
const EMBEDDED: &[(u32, Embedded)] = &[
    (1, Embedded::Frozen(SCHEMA_V1_FROZEN)),
    (2, Embedded::Frozen(SCHEMA_V2_FROZEN)),
    (3, Embedded::Frozen(SCHEMA_V3_FROZEN)),
    (4, Embedded::Derived(combined_v4)),
    (5, Embedded::Frozen(SCHEMA_V5_FROZEN)),
    (6, Embedded::Derived(combined_v6)),
    (7, Embedded::Frozen(SCHEMA_V7_FROZEN)),
    (8, Embedded::Frozen(SCHEMA_V8_FROZEN)),
    (9, Embedded::Frozen(SCHEMA_V9_FROZEN)),
    (10, Embedded::Frozen(SCHEMA_V10_FROZEN)),
    (11, Embedded::Frozen(SCHEMA_V11_FROZEN)),
    (12, Embedded::Frozen(SCHEMA_V12_FROZEN)),
    (13, Embedded::Frozen(SCHEMA_V13_FROZEN)),
    (14, Embedded::Frozen(SCHEMA_V14_FROZEN)),
    (15, Embedded::Frozen(SCHEMA_V15_FROZEN)),
    (16, Embedded::Frozen(SCHEMA_V16_FROZEN)),
    (17, Embedded::Frozen(SCHEMA_V17_FROZEN)),
    (18, Embedded::Derived(combined_v18)),
];

const _: () = {
    assert!(
        EMBEDDED.len() as u32 == CURRENT_SCHEMA_VERSION - MIN_EMBEDDED_VERSION + 1,
        "EMBEDDED needs one row per version from MIN_EMBEDDED_VERSION to CURRENT_SCHEMA_VERSION"
    );
    let mut i = 0;
    while i < EMBEDDED.len() {
        assert!(
            EMBEDDED[i].0 == MIN_EMBEDDED_VERSION + i as u32,
            "EMBEDDED rows must run in ascending order from MIN_EMBEDDED_VERSION"
        );
        i += 1;
    }
};

/// Structured `serde_json::Value` for one schema version. Used by
/// `schema diff` to compare two versions field-by-field.
pub fn schema_value_for(version: u32) -> Result<Value, SchemaError> {
    let row = version
        .checked_sub(MIN_EMBEDDED_VERSION)
        .and_then(|i| EMBEDDED.get(i as usize));
    match row {
        Some((_, Embedded::Frozen(text))) => {
            Ok(serde_json::from_str(text).expect("frozen schema snapshot is valid JSON"))
        }
        Some((_, Embedded::Derived(f))) => Ok(f()),
        None => Err(SchemaError::UnknownVersion {
            requested: version,
            known: embedded_versions(),
        }),
    }
}

fn combined_v4() -> Value {
    let mut v3: Value =
        serde_json::from_str(SCHEMA_V3_FROZEN).expect("frozen v3 snapshot is valid JSON");
    if let Some(obj) = v3.as_object_mut() {
        obj.insert("schema_version".into(), json!(4));
        obj.insert("title".into(), json!("backhopper envelope v4"));
        obj.insert(
            "description".into(),
            json!(
                "v4 wires the path-translation pipeline: `Reason::PathRename` is emitted for \
                 translated paths and `InapplicableReason::PathsMissingOnTarget` now carries \
                 `Vec<RelativePath>` instead of `Vec<PathBuf>`. Reflected in the \
                 SeriesEvaluation payload."
            ),
        );
    }
    v3
}

fn combined_v6() -> Value {
    let mut v5: Value =
        serde_json::from_str(SCHEMA_V5_FROZEN).expect("frozen v5 snapshot is valid JSON");
    if let Some(obj) = v5.as_object_mut() {
        obj.insert("schema_version".into(), json!(6));
        obj.insert("title".into(), json!("backhopper envelope v6"));
        obj.insert(
            "description".into(),
            json!(
                "v6: operator-facing SHA inputs accept 7-to-40 hex prefixes (resolved through \
                 gix before any analyser sees them), new typed `GitError::AmbiguousSha` and \
                 `GitError::NotACommit` variants surface through the error path, the \
                 `rev resolve` companion verb expands a prefix to a full SHA, and \
                 `BisectPayload.commit` plus `MultiPayload.commit` carry typed `CommitSha` \
                 rather than raw strings."
            ),
        );
    }
    v5
}

fn combined_v18() -> Value {
    combined_live(
        18,
        "v18: the `jigsaw plan` and `jigsaw check` payloads (`jigsaw_plan_payload`, \
         `jigsaw_check_payload`). No existing payload changed.",
    )
}

/// The live payload schema: generated from the current types, so it
/// must be frozen to a snapshot before the next version changes them.
fn combined_live(version: u32, description: &str) -> Value {
    let series = envelope_with_payload::<CheckPayload>(version, "check", description);
    let summary_row =
        serde_json::to_value(schema_for!(SummaryRow)).expect("SummaryRow schema serialises");
    let batch_payload =
        serde_json::to_value(schema_for!(BatchPayload)).expect("BatchPayload schema serialises");
    let cache_stats_payload = serde_json::to_value(schema_for!(CacheStatsPayload))
        .expect("CacheStatsPayload schema serialises");
    let cache_list_payload = serde_json::to_value(schema_for!(CacheListPayload))
        .expect("CacheListPayload schema serialises");
    let cache_show_payload = serde_json::to_value(schema_for!(CacheShowPayload))
        .expect("CacheShowPayload schema serialises");
    let cache_mutation_payload = serde_json::to_value(schema_for!(CacheMutationPayload))
        .expect("CacheMutationPayload schema serialises");
    let suite_plan_payload =
        serde_json::to_value(schema_for!(SuitePlan)).expect("SuitePlan schema serialises");
    let jigsaw_plan_payload = serde_json::to_value(schema_for!(JigsawPlanPayload))
        .expect("JigsawPlanPayload schema serialises");
    let jigsaw_check_payload = serde_json::to_value(schema_for!(JigsawCheckPayload))
        .expect("JigsawCheckPayload schema serialises");
    let Value::Object(mut obj) = series else {
        unreachable!("envelope_with_payload always returns a Value::Object")
    };
    obj.insert("summary_row".into(), summary_row);
    obj.insert("batch_payload".into(), batch_payload);
    obj.insert("cache_stats_payload".into(), cache_stats_payload);
    obj.insert("cache_list_payload".into(), cache_list_payload);
    obj.insert("cache_show_payload".into(), cache_show_payload);
    obj.insert("cache_mutation_payload".into(), cache_mutation_payload);
    obj.insert("suite_plan_payload".into(), suite_plan_payload);
    obj.insert("jigsaw_plan_payload".into(), jigsaw_plan_payload);
    obj.insert("jigsaw_check_payload".into(), jigsaw_check_payload);
    obj.insert("schema_version".into(), json!(version));
    obj.insert(
        "title".into(),
        json!(format!("backhopper envelope v{version}")),
    );
    Value::Object(obj)
}

// frame derived from WireEnvelope<T> itself, so the documented shape cannot drift from the wire
fn envelope_with_payload<T: JsonSchema>(
    version: u32,
    command_family: &str,
    description: &str,
) -> Value {
    let envelope =
        serde_json::to_value(schema_for!(WireEnvelope<T>)).expect("envelope schema serialises");
    json!({
        "$schema": "https://json-schema.org/draft-07/schema",
        "title": format!("backhopper envelope v{version}"),
        "schema_version": version,
        "command_family": command_family,
        "description": description,
        "envelope": envelope,
    })
}