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}