Skip to main content

zenith_cli/library/
registry.rs

1//! Pack format, embedded preset table, and the project/preset resolver.
2//!
3//! A library "pack" is a `.zen` file whose IDENTITY is declared by a single
4//! `library` SELF-entry in its own `libraries` block. This module owns the pack
5//! METADATA model ([`LibraryPack`], [`PackItem`], [`PackSource`], [`ItemKind`]),
6//! the [`EMBEDDED_PACKS`] preset table, parsing a pack's identity/items
7//! ([`parse_pack`]), and resolving project packs against embedded presets
8//! ([`resolve_packs`]).
9
10use std::path::{Path, PathBuf};
11
12use zenith_core::{KdlAdapter, KdlSource, TokenType};
13
14/// Embedded preset packs, as `(pack_id, pack_source)` pairs.
15///
16/// Each `pack_source` is the verbatim `.zen` text of a shipped preset library,
17/// bundled into the binary via [`include_str!`] (mirroring how the default
18/// fonts are bundled in `zenith-core`). The `pack_id` is the expected package
19/// id and is used only for diagnostics/lookup convenience; the authoritative id
20/// is parsed from the pack's own `library` self-entry.
21pub const EMBEDDED_PACKS: &[(&str, &str)] = &[
22    (
23        "@zenith/flowchart",
24        include_str!("../../assets/libraries/zenith-flowchart.zen"),
25    ),
26    (
27        "@zenith/filters",
28        include_str!("../../assets/libraries/zenith-filters.zen"),
29    ),
30    (
31        "@zenith/masks",
32        include_str!("../../assets/libraries/zenith-masks.zen"),
33    ),
34    (
35        "@zenith/brand-kit",
36        include_str!("../../assets/libraries/zenith-brand-kit.zen"),
37    ),
38];
39
40/// Where a [`LibraryPack`] was loaded from.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum PackSource {
43    /// A preset pack embedded in the binary.
44    Preset,
45    /// A project pack read from the given `.zen` file path.
46    Project(PathBuf),
47}
48
49impl PackSource {
50    /// A short, stable label for human/JSON output: `"preset"` or `"project"`.
51    pub fn label(&self) -> &'static str {
52        match self {
53            PackSource::Preset => "preset",
54            PackSource::Project(_) => "project",
55        }
56    }
57}
58
59/// What kind of thing a pack item is.
60///
61/// A pack exports COMPONENT items (materialized as an instance on a page),
62/// TOKEN items (filter tokens, copied into the target's tokens block with their
63/// color-token dependencies — no instance, no page required), and ACTION items
64/// (addressed as `<pkg>#<action-id>`).
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum ItemKind {
67    /// A component item, addressed `<pkg>#<component-id>`.
68    Component,
69    /// A filter-token item, addressed `<pkg>#<token-id>`.
70    Token,
71    /// An action item, addressed `<pkg>#<action-id>`.
72    Action,
73}
74
75impl ItemKind {
76    /// A short, stable label for human/JSON output: `"component"`, `"token"`, or `"action"`.
77    pub fn label(&self) -> &'static str {
78        match self {
79            ItemKind::Component => "component",
80            ItemKind::Token => "token",
81            ItemKind::Action => "action",
82        }
83    }
84}
85
86/// A single exported item of a [`LibraryPack`]: its id plus its kind.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct PackItem {
89    /// The item id (a component id, a filter-token id, or an action id).
90    pub id: String,
91    /// Whether the item is a component, a filter token, or an action.
92    pub kind: ItemKind,
93}
94
95/// A loaded library pack: its identity plus the items it provides.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct LibraryPack {
98    /// The package id, parsed from the pack's `library` self-entry.
99    pub id: String,
100    /// The pack version, parsed from the pack's `library` self-entry.
101    pub version: Option<String>,
102    /// Where the pack came from.
103    pub source: PackSource,
104    /// The items the pack provides: component ids first (in source order),
105    /// then exportable token ids (in source order), then action ids (in source
106    /// order).
107    pub items: Vec<PackItem>,
108}
109
110/// Whether a token type is an EXPORTABLE library item (addressable as
111/// `<pkg>#<token-id>` and copied by `materialize_token`).
112///
113/// Effect tokens — `filter` and `mask` — are self-contained, applyable units
114/// that other documents reference by id, so they are exported items. Color /
115/// dimension / gradient / shadow tokens are dependencies pulled in transitively
116/// when an exported token (or component) needs them, not standalone items.
117pub(super) fn is_exportable_token(ty: &TokenType) -> bool {
118    matches!(ty, TokenType::Filter | TokenType::Mask)
119}
120
121/// An error produced while parsing a pack.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct PackError {
124    /// Human-readable message describing the failure.
125    pub message: String,
126}
127
128impl PackError {
129    fn new(message: impl Into<String>) -> Self {
130        Self {
131            message: message.into(),
132        }
133    }
134}
135
136impl std::fmt::Display for PackError {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        f.write_str(&self.message)
139    }
140}
141
142impl std::error::Error for PackError {}
143
144/// Parse a `.zen` pack `source` into a [`LibraryPack`] tagged with `source_kind`.
145///
146/// Pack identity is derived from the document's `libraries` block: the library
147/// entry whose `id` matches the document's `project` id is the SELF-entry; if no
148/// entry matches the project id but there is exactly one library entry, that
149/// sole entry is used. A pack with no identifying library self-entry is an error
150/// (a pack MUST declare its identity).
151///
152/// Items are the document's component ids in source order, followed by its
153/// FILTER token ids in source order. (Only filter tokens are exported items;
154/// color/dimension tokens are dependencies, not items.)
155///
156/// # Errors
157///
158/// Returns [`PackError`] when the source fails to parse, or when no library
159/// self-entry can be determined.
160pub fn parse_pack(source: &str, source_kind: PackSource) -> Result<LibraryPack, PackError> {
161    let doc = KdlAdapter
162        .parse(source.as_bytes())
163        .map_err(|e| PackError::new(format!("parse error: {}", e)))?;
164
165    let project_id = doc.project.as_ref().map(|p| p.id.as_str());
166
167    // Prefer the library entry whose id matches the project id; otherwise fall
168    // back to the sole library entry when there is exactly one.
169    let self_entry = project_id
170        .and_then(|pid| doc.libraries.iter().find(|lib| lib.id == pid))
171        .or(match doc.libraries.as_slice() {
172            [only] => Some(only),
173            _ => None,
174        });
175
176    let self_entry = self_entry.ok_or_else(|| {
177        PackError::new(
178            "pack has no identifying library self-entry (declare \
179             `libraries { library id=\"…\" version=\"…\" }`)",
180        )
181    })?;
182
183    // Component items first (source order), then filter-token items (source
184    // order). A token is an exported item only when it is a filter token.
185    let mut items: Vec<PackItem> = doc
186        .components
187        .iter()
188        .map(|c| PackItem {
189            id: c.id.clone(),
190            kind: ItemKind::Component,
191        })
192        .collect();
193    items.extend(
194        doc.tokens
195            .tokens
196            .iter()
197            .filter(|t| is_exportable_token(&t.token_type))
198            .map(|t| PackItem {
199                id: t.id.clone(),
200                kind: ItemKind::Token,
201            }),
202    );
203    items.extend(doc.actions.iter().map(|a| PackItem {
204        id: a.id.clone(),
205        kind: ItemKind::Action,
206    }));
207
208    Ok(LibraryPack {
209        id: self_entry.id.clone(),
210        version: self_entry.version.clone(),
211        source: source_kind,
212        items,
213    })
214}
215
216/// Parse every entry in [`EMBEDDED_PACKS`] into a [`LibraryPack`].
217///
218/// An embedded pack that fails to parse is skipped (embedded content is shipped
219/// and tested, so this should not happen in practice); the returned vector
220/// contains only the packs that parsed successfully.
221pub fn load_embedded_packs() -> Vec<LibraryPack> {
222    EMBEDDED_PACKS
223        .iter()
224        .filter_map(|(_, src)| parse_pack(src, PackSource::Preset).ok())
225        .collect()
226}
227
228/// Scan `project_dir/libraries/*.zen` and parse each file into a [`LibraryPack`].
229///
230/// A missing `libraries/` directory (or a `project_dir` without one) yields an
231/// empty vector. Files that fail to read or parse are skipped — a note is
232/// written to stderr — so one bad pack never aborts the whole listing.
233pub fn load_project_packs(project_dir: &Path) -> Vec<LibraryPack> {
234    let libraries_dir = project_dir.join("libraries");
235    let entries = match std::fs::read_dir(&libraries_dir) {
236        Ok(entries) => entries,
237        // Missing directory (or any read error) → no project packs.
238        Err(_) => return Vec::new(),
239    };
240
241    let mut packs = Vec::new();
242    for entry in entries.flatten() {
243        let path = entry.path();
244        if path.extension().and_then(|e| e.to_str()) != Some("zen") {
245            continue;
246        }
247        let source = match std::fs::read_to_string(&path) {
248            Ok(s) => s,
249            Err(e) => {
250                eprintln!("note: skipping '{}': {}", path.display(), e);
251                continue;
252            }
253        };
254        match parse_pack(&source, PackSource::Project(path.clone())) {
255            Ok(pack) => packs.push(pack),
256            Err(e) => eprintln!("note: skipping '{}': {}", path.display(), e),
257        }
258    }
259    packs
260}
261
262/// Resolve all available packs: project packs first, then embedded presets.
263///
264/// Project packs take precedence over embedded packs of the same id (a project
265/// pack SHADOWS an embedded preset). Both are returned, each tagged with its
266/// [`PackSource`], so callers that LIST can show every pack; callers that
267/// MATERIALIZE should prefer the first pack for a given id. The result is sorted
268/// by id for deterministic output (project before embedded on ties).
269pub fn resolve_packs(project_dir: Option<&Path>) -> Vec<LibraryPack> {
270    let mut packs = Vec::new();
271    if let Some(dir) = project_dir {
272        packs.extend(load_project_packs(dir));
273    }
274    packs.extend(load_embedded_packs());
275
276    // Stable, deterministic order: by id, with project packs ahead of embedded
277    // on ties (so the shadowing winner sorts first).
278    packs.sort_by(|a, b| {
279        a.id.cmp(&b.id)
280            .then_with(|| source_rank(&a.source).cmp(&source_rank(&b.source)))
281    });
282    packs
283}
284
285/// Sort rank giving project packs precedence over embedded presets.
286fn source_rank(source: &PackSource) -> u8 {
287    match source {
288        PackSource::Project(_) => 0,
289        PackSource::Preset => 1,
290    }
291}