rustigram-types 0.12.0

Telegram Bot API type definitions for rustigram
Documentation
//! Loader for the committed Bot API spec snapshot.
//!
//! Every conformance test compares the crate against this snapshot rather than
//! against the live docs, so the suite is deterministic and runs offline. The
//! snapshot is generated by the `telegram-docs-setup` skill:
//!
//! ```text
//! python3 ~/.claude/skills/telegram-docs-setup/scripts/make_spec_snapshot.py \
//!     --tdocs tdocs --out crates/rustigram-types/tests/spec/bot-api-10.2.json
//! ```
//!
//! It is committed deliberately. A spec change should arrive as a reviewable
//! diff, not as a silent shift in what the tests believe.

#![allow(dead_code)] // each test binary uses a different subset

pub mod payload;
pub mod rust_source;

use std::collections::BTreeMap;

use serde::Deserialize;

/// The whole snapshot.
#[derive(Debug, Deserialize)]
pub struct Spec {
    pub snapshot_version: String,
    pub bot_api_version: String,
    pub bot_api_date: String,
    pub source_url: String,
    pub types: BTreeMap<String, SpecType>,
    pub methods: BTreeMap<String, SpecMethod>,
    /// Type name to the literal value of its `type`/`source`/`status` field.
    pub discriminants: BTreeMap<String, String>,
    /// Type name to the field that carries its discriminant.
    ///
    /// Not inferable from the literal alone: the passport error types have both
    /// a `type` (the document kind) and a `source` (the error kind), and only
    /// one of them is the tag.
    pub discriminant_fields: BTreeMap<String, String>,
    /// `Type.field` to the literal string values the docs list for that field.
    ///
    /// Telegram documents string enums in prose rather than in a schema, so
    /// these are the only machine-readable record of which values a field
    /// accepts. Without them a generated payload has to invent a string, serde
    /// rejects it, and the failure looks like a decode bug in the crate.
    pub enum_values: BTreeMap<String, Vec<String>>,
    /// Union base name to the list of type names that can inhabit it.
    pub unions: BTreeMap<String, Vec<String>>,
}

/// A field or parameter, stored as `["Integer", 0]` rather than an object.
///
/// The tuple form exists to keep the committed snapshot small: as objects it
/// was 13,700 lines, and the reviewable-diff argument for that is already served
/// better by the mirror's own `--check` drift report.
#[derive(Debug, Deserialize)]
pub struct SpecField(String, u8);

impl SpecField {
    /// The spec's own type text, e.g. `Integer` or `Array of PhotoSize`.
    pub fn kind(&self) -> &str {
        &self.0
    }

    pub fn optional(&self) -> bool {
        self.1 == 1
    }

    /// The element type, with any `Array of ` prefix removed.
    pub fn base_type(&self) -> &str {
        self.0.strip_prefix("Array of ").unwrap_or(&self.0)
    }
}

/// A spec type: its name maps straight to its field table.
pub type SpecType = BTreeMap<String, SpecField>;

/// A spec method: its name maps straight to its parameter table.
pub type SpecMethod = BTreeMap<String, SpecField>;

const SNAPSHOT: &str = include_str!("../spec/bot-api-10.2.json");

/// Loads the snapshot, panicking with a usable message if it is unreadable.
///
/// Failing loudly matters: a conformance suite that silently loads an empty
/// spec passes every test while checking nothing, which is worse than no suite
/// at all.
pub fn load() -> Spec {
    let spec: Spec = serde_json::from_str(SNAPSHOT).unwrap_or_else(|e| {
        panic!(
            "the committed spec snapshot failed to parse: {e}\n\
             Regenerate it with make_spec_snapshot.py — do not hand-edit it."
        )
    });
    assert!(
        !spec.types.is_empty() && !spec.methods.is_empty(),
        "the spec snapshot parsed but is empty; every conformance test would \
         vacuously pass. Regenerate it."
    );
    spec
}

// ─── Source-tree helpers ─────────────────────────────────────────────────────
//
// Two conformance properties are about the shape of the code rather than its
// runtime behaviour — whether a type is referenced, and whether the declared
// surface matches the spec. Neither can be answered by a running program, so
// those tests read the tree. These helpers are shared so the walk and its
// sanity checks exist once.

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

/// The workspace root, reached from this crate's manifest directory.
pub fn workspace_root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("..")
        .join("..")
        .canonicalize()
        .expect("workspace root resolves")
}

/// Every library source file in the workspace.
///
/// Tests and examples are excluded on purpose: a type referenced only by its own
/// test is still dead weight in the library, and counting those references would
/// hide precisely the bug this checks for.
pub fn library_sources() -> Vec<(PathBuf, String)> {
    fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
        let Ok(entries) = std::fs::read_dir(dir) else {
            return;
        };
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                walk(&path, out);
            } else if path.extension().is_some_and(|e| e == "rs") {
                out.push(path);
            }
        }
    }

    let crates = workspace_root().join("crates");
    let mut files = Vec::new();
    for entry in std::fs::read_dir(&crates)
        .expect("crates/ exists")
        .flatten()
    {
        walk(&entry.path().join("src"), &mut files);
    }
    assert!(
        files.len() > 20,
        "expected to find the workspace sources, found {} files — the layout may \
         have changed and this test would silently check nothing",
        files.len()
    );
    files
        .into_iter()
        .map(|p| {
            let text = std::fs::read_to_string(&p).expect("source file is readable");
            (p, text)
        })
        .collect()
}

/// Counts word-boundary occurrences of `needle` in `haystack`.
pub fn count_occurrences(haystack: &str, needle: &str) -> usize {
    let bytes = haystack.as_bytes();
    let mut count = 0;
    let mut from = 0;
    while let Some(found) = haystack[from..].find(needle) {
        let start = from + found;
        let end = start + needle.len();
        let before_ok = start == 0 || !is_ident_byte(bytes[start - 1]);
        let after_ok = end == bytes.len() || !is_ident_byte(bytes[end]);
        if before_ok && after_ok {
            count += 1;
        }
        from = end;
    }
    count
}

pub fn is_ident_byte(b: u8) -> bool {
    b.is_ascii_alphanumeric() || b == b'_'
}