ishou-render 0.1.14

ishou — target-specific renderers for the pleme-io design token set
Documentation
//! The base16 **already-parsed Nix attrset** render target — shared by every
//! palette, because there is exactly one right way to hand stylix a scheme
//! without an import-from-derivation.
//!
//! ## Why this target exists at all
//!
//! stylix's `base16.nix` classifies its input with
//! `is-not-parsed = builtins.isAttrs scheme && !(scheme ? "yaml")`. That gives
//! three shapes a caller can pass, and they are not equivalent:
//!
//! | shape                    | what stylix does            | cost                |
//! |--------------------------|-----------------------------|---------------------|
//! | bare derivation          | misreads it as a colour set | **throws**          |
//! | `{ yaml = <derivation>; }` | `readFile` the built file   | **IFD**             |
//! | a literal attrset        | uses it directly            | none                |
//!
//! The middle one is the trap, because it *works*. It also forces a build
//! during evaluation, and since the derivation is per-system that build is
//! Linux — which leaves every NixOS node in the fleet **unevaluable from a
//! Mac**, all-or-nothing per node (`checkUnmatched` forces the whole module
//! graph). A workstation cannot then so much as typecheck the config it is
//! about to deploy.
//!
//! This module emits the third shape.
//!
//! ## Why it is shared rather than per-palette
//!
//! It was not, briefly: the Vellum renderer grew its own copy first. The
//! moment a second palette needed the same output, that copy would have become
//! two — and the parts most worth getting right are the parts a copy silently
//! drops, namely the metadata block documented below. Duplication budget is
//! zero; this is the extraction.

use crate::nix_ast::{AttrEntry, NixFile, attrset, str_};
use ishou_tokens::Rgb;

/// base16 scheme metadata.
///
/// ★ Not decoration — omitting it silently RENAMES things rather than
/// erroring. stylix's `input-meta` defaults every absent field
/// (`scheme`/`author` → `"untitled"`, `variant` → `"unspecified"`), and those
/// strings are interpolated into generated artefact NAMES: `base16-${slug}`
/// (fish), `"Base16 ${scheme-name}"` (zed), `${slug}-gnome-shell-theme`. So a
/// metadata-less attrset keeps every colour byte-identical while turning
/// `base16-vellum` into `base16-untitled` and renaming a pile of store paths.
///
/// The struct exists so that is unforgettable: there is no constructor that
/// omits a field, which is the difference between "we remembered" and "it
/// cannot be forgotten".
#[derive(Debug, Clone, Copy)]
pub struct SchemeMeta {
    /// `name`, deliberately — NOT `scheme`. base16.nix's
    /// `convert-scheme-to-common-format` maps `name` → `scheme`, and `name` is
    /// what the sibling YAML renders emit, so both spellings stay identical.
    pub name: &'static str,
    pub author: &'static str,
    /// `"dark"` / `"light"`. Read by consumers that pick contrast.
    pub variant: &'static str,
    /// Interpolated into artefact names — see the struct docs.
    pub slug: &'static str,
}

/// Lowercase, unprefixed six-char hex — the stylix/base16 wire format.
///
/// A leading `#` here would be a silent colour change rather than a parse
/// error: stylix passes the value through to GTK, which expects bare hex.
fn slot_hex(rgb: Rgb) -> String {
    format!("{:02x}{:02x}{:02x}", rgb.r, rgb.g, rgb.b)
}

/// Render a base16 palette as an already-parsed Nix attrset file.
///
/// `source_of_truth` and `consumed_as` are woven into the header so a reader
/// who opens the generated file lands on the Rust that produced it and on the
/// exact expression that consumes it.
///
/// Emitted through the typed `nix_ast` printer rather than by formatting
/// strings — `format!()` of Nix syntax is banned (theory/NIX-AST.md), and the
/// failure it prevents is silent: an unbalanced brace or a missing semicolon
/// ships as a file that parses to the wrong thing.
#[must_use]
pub fn render(
    meta: &SchemeMeta,
    slots: &[(&'static str, Rgb)],
    source_of_truth: &str,
    consumed_as: &str,
) -> String {
    let mut entries: Vec<AttrEntry> = vec![
        AttrEntry::new("system", str_("base16")),
        AttrEntry::new("name", str_(meta.name)),
        AttrEntry::new("author", str_(meta.author)),
        AttrEntry::new("variant", str_(meta.variant)),
        AttrEntry::new("slug", str_(meta.slug)),
    ];
    entries.extend(
        slots
            .iter()
            .map(|(slot, rgb)| AttrEntry::new(*slot, str_(slot_hex(*rgb)))),
    );

    NixFile::new(
        [
            "Generated by ishou-render::base16_nix — DO NOT EDIT".to_string(),
            format!("Source of truth: {source_of_truth}"),
            "Architecture:    pleme-io/theory/THEME-ARCHITECTURE.md".to_string(),
            String::new(),
            "base16 as an ALREADY-PARSED attrset. Consumed as:".to_string(),
            format!("  {consumed_as}"),
            String::new(),
            "The `{ yaml = <derivation>; }` form this replaces made stylix".to_string(),
            "readFile a derivation — an IFD — which forced a linux build during".to_string(),
            "evaluation and left every NixOS node unevaluable from a Mac.".to_string(),
        ],
        attrset(entries),
    )
    .render()
}

#[cfg(test)]
mod tests {
    use super::*;

    const META: SchemeMeta = SchemeMeta {
        name: "Test",
        author: "pleme-io",
        variant: "dark",
        slug: "test",
    };

    fn slots() -> Vec<(&'static str, Rgb)> {
        vec![("base00", Rgb::new(0x2e, 0x34, 0x40))]
    }

    #[test]
    fn hex_is_lowercase_and_unprefixed() {
        // A leading `#` reaches GTK and changes the colour SILENTLY rather
        // than failing, so pin both halves of the wire format.
        let out = render(&META, &slots(), "src", "expr");
        assert!(out.contains(r#"base00 = "2e3440";"#), "got:\n{out}");
        assert!(!out.contains("\"#2e3440\""), "hex must not carry a `#`:\n{out}");
    }

    #[test]
    fn every_metadata_field_is_emitted() {
        // The whole point of SchemeMeta: a missing field renames store paths
        // silently rather than failing, so assert all five are present.
        let out = render(&META, &slots(), "src", "expr");
        for key in ["system", "name", "author", "variant", "slug"] {
            assert!(out.contains(&format!("{key} = ")), "missing {key} in:\n{out}");
        }
    }

    #[test]
    fn output_is_deterministic() {
        assert_eq!(
            render(&META, &slots(), "src", "expr"),
            render(&META, &slots(), "src", "expr")
        );
    }
}

/// The committed `rendered/*.nix` artefacts vs. their generators.
///
/// Those files carry "DO NOT EDIT" headers, and until this module existed that
/// was advisory — nothing checked. A hand-edit, or a generator change whose
/// regeneration was never run, ships a file that still *claims* to be
/// generated while no longer matching the Rust that generates it. The failure
/// is silent by construction: the file is valid Nix and stylix consumes it
/// happily, so the fleet renders a palette no source of truth agrees with.
///
/// These tests make the header enforceable. `include_str!` resolves at compile
/// time relative to this file, so the check runs with no filesystem access and
/// no path assumptions beyond the repo layout itself.
#[cfg(test)]
mod committed_artifacts {
    const VELLUM: &str = include_str!("../../../rendered/stylix-base16-vellum.nix");
    const NORD: &str = include_str!("../../../rendered/stylix-base16-nord-dark.nix");

    #[test]
    fn vellum_matches_its_generator() {
        assert_eq!(
            crate::vellum::render_base16_nix(),
            VELLUM,
            "rendered/stylix-base16-vellum.nix is STALE. Regenerate:\n  \
             cargo run -p ishou-cli -- render --target stylix-vellum-nix \
             > rendered/stylix-base16-vellum.nix"
        );
    }

    #[test]
    fn nord_matches_its_generator() {
        assert_eq!(
            crate::stylix::render_nix(&ishou_tokens::TokenSet::pleme()),
            NORD,
            "rendered/stylix-base16-nord-dark.nix is STALE. Regenerate:\n  \
             cargo run -p ishou-cli -- render --target stylix-nord-nix \
             > rendered/stylix-base16-nord-dark.nix"
        );
    }

    /// Both artefacts must be the literal-attrset shape, never `{ yaml = …; }`.
    ///
    /// This is the whole reason the target exists: the `yaml` form makes stylix
    /// `readFile` a derivation mid-evaluation, and that per-system build leaves
    /// every NixOS node unevaluable from a Mac. A regression here would be
    /// invisible on Linux CI and fatal on the workstation that deploys.
    #[test]
    fn neither_artifact_reintroduces_the_ifd_shape() {
        for (name, body) in [("vellum", VELLUM), ("nord", NORD)] {
            // Comment lines are stripped first: the provenance header
            // deliberately QUOTES the `{ yaml = <drv>; }` shape to explain what
            // it replaced, and matching against the raw file makes the header's
            // own prose fail the check. (It did, on the first run — which is
            // the red run this assertion needed anyway.)
            let code: String = body
                .lines()
                .filter(|l| !l.trim_start().starts_with('#'))
                .collect::<Vec<_>>()
                .join("\n");
            assert!(
                !code.contains("yaml ="),
                "{name} reintroduced the `{{ yaml = <drv>; }}` IFD shape"
            );
            assert!(
                code.contains("base00 = \""),
                "{name} is not a parsed colour attrset"
            );
        }
    }
}