zenith_cli/library/token.rs
1//! Token materialization: `library add` of a filter/mask TOKEN item.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use zenith_core::{LibraryDef, ProvenanceDef, Token, TokenLiteral, TokenType, TokenValue};
6
7use super::add::{
8 AddError, collect_all_ids, copy_tokens, load_pack_document, unique_id, unknown_package_error,
9};
10use super::registry::{LibraryPack, is_exportable_token};
11
12/// The outcome of a successful [`materialize_token`] call.
13///
14/// All ids are the FINAL ids written into the target document. The filter-token
15/// id is kept VERBATIM (e.g. `noir`), so the user can apply it via
16/// `filter=(token)"noir"`.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct TokenAddOutcome {
19 /// The package id the item came from (e.g. `@zenith/filters`).
20 pub pkg_id: String,
21 /// The item name within the pack (e.g. `noir`).
22 pub item: String,
23 /// The copied token id (kept as-is, e.g. `noir` or `vignette`).
24 pub token_id: String,
25 /// The property the copied token is applied through: `"filter"` or `"mask"`.
26 pub apply_property: &'static str,
27 /// Dependency tokens copied alongside the item token (sorted, deduped).
28 pub dep_token_ids: Vec<String>,
29 /// The unique id of the recorded provenance entry.
30 pub provenance_id: String,
31 /// Non-fatal dependency-conflict warnings (see [`super::AddOutcome::warnings`]).
32 pub warnings: Vec<String>,
33}
34
35/// Collect the transitive token DEPS of `filter_token` among `pack_tokens`.
36///
37/// A filter token references color tokens only through its `Duotone` ops'
38/// `shadow`/`highlight` ids. Each referenced id is followed through any
39/// `TokenValue::Reference` alias chain to a fixpoint (cycle-guarded by a visited
40/// set), so the full closure of dependency token ids is returned. The filter
41/// token itself is NOT included. For non-duotone filters the result is empty.
42///
43/// Deterministic: returns a [`BTreeSet`] (sorted, deduped).
44pub(crate) fn collect_filter_dep_ids(
45 filter_token: &Token,
46 pack_tokens: &[Token],
47) -> BTreeSet<String> {
48 // Seed: the direct color-token ids referenced by duotone ops.
49 let mut seeds: Vec<String> = Vec::new();
50 if let TokenValue::Literal(TokenLiteral::Filter(lit)) = &filter_token.value {
51 for op in &lit.ops {
52 if op.kind == zenith_core::FilterKind::Duotone {
53 if let Some(s) = &op.shadow {
54 seeds.push(s.clone());
55 }
56 if let Some(h) = &op.highlight {
57 seeds.push(h.clone());
58 }
59 }
60 }
61 }
62
63 let mut deps: BTreeSet<String> = BTreeSet::new();
64 let mut stack: Vec<String> = seeds;
65 while let Some(id) = stack.pop() {
66 // `insert` returns false if already present → fixpoint / cycle guard.
67 if !deps.insert(id.clone()) {
68 continue;
69 }
70 // Follow an alias chain: if the dep token is itself a reference, the
71 // referenced target is also a dependency.
72 if let Some(tok) = pack_tokens.iter().find(|t| t.id == id)
73 && let TokenValue::Reference { token_id } = &tok.value
74 {
75 stack.push(token_id.clone());
76 }
77 }
78 deps
79}
80
81/// Materialize the filter-token item `pkg_id#item` into `target`, returning the
82/// [`TokenAddOutcome`] describing what was added.
83///
84/// This is the PURE core of a `library add` for a TOKEN item: it mutates the
85/// parsed `target` [`Document`](zenith_core::Document) in place and performs NO filesystem or process
86/// I/O. Unlike [`super::materialize`], it inserts NO instance and requires NO
87/// page. Steps:
88///
89/// 1. Resolve the FIRST pack in `packs` whose id == `pkg_id` (project shadows
90/// preset); load its full [`zenith_core::Document`].
91/// 2. Find the FILTER token whose id == `item`.
92/// 3. Collect the filter token's transitive color-token deps
93/// (`collect_filter_dep_ids`).
94/// 4. Ensure the target's tokens block has a format (adopt the pack's when empty).
95/// 5. Copy the dep tokens THEN the filter token into the target (dedup by id +
96/// conflict warnings, via the shared `copy_tokens`).
97/// 6. Record a `libraries` entry for `pkg_id` (if absent).
98/// 7. Record a unique `provenance` record whose `node` is the filter-token id —
99/// skipped if an identical `(node, library, item)` provenance already exists.
100///
101/// # Errors
102///
103/// Returns [`AddError`] when the package or item is unknown (the message lists
104/// the available options).
105pub fn materialize_token(
106 target: &mut zenith_core::Document,
107 packs: &[LibraryPack],
108 pkg_id: &str,
109 item: &str,
110 id_base: &str,
111) -> Result<TokenAddOutcome, AddError> {
112 // 1. Resolve the pack + load its document. ────────────────────────────────
113 let pack = packs
114 .iter()
115 .find(|p| p.id == pkg_id)
116 .ok_or_else(|| unknown_package_error(pkg_id, packs))?;
117
118 let pack_doc = load_pack_document(pack)?;
119
120 // 2. Find the FILTER token named `item`. ───────────────────────────────────
121 let item_token = pack_doc
122 .tokens
123 .tokens
124 .iter()
125 .find(|t| t.id == item && is_exportable_token(&t.token_type))
126 .ok_or_else(|| {
127 let available: Vec<&str> = pack_doc
128 .tokens
129 .tokens
130 .iter()
131 .filter(|t| is_exportable_token(&t.token_type))
132 .map(|t| t.id.as_str())
133 .collect();
134 AddError::new(format!(
135 "unknown token item '{}' in package '{}' (available: {})",
136 item,
137 pkg_id,
138 if available.is_empty() {
139 "none".to_owned()
140 } else {
141 available.join(", ")
142 }
143 ))
144 })?;
145
146 let mut warnings: Vec<String> = Vec::new();
147
148 // The property the token is applied through, and (for filters) its deps.
149 let apply_property = match item_token.token_type {
150 TokenType::Mask => "mask",
151 TokenType::Color
152 | TokenType::Dimension
153 | TokenType::Number
154 | TokenType::FontFamily
155 | TokenType::FontWeight
156 | TokenType::Gradient
157 | TokenType::Shadow
158 | TokenType::Filter
159 | TokenType::Unknown(_) => "filter",
160 };
161
162 // 3. Collect transitive color-token deps (filter duotone colors; none for
163 // masks, which are self-contained). ─────────────────────────────────────
164 let dep_ids = collect_filter_dep_ids(item_token, &pack_doc.tokens.tokens);
165
166 // 4. Ensure the target's tokens block has a format. ────────────────────────
167 if target.tokens.format.is_empty() {
168 target.tokens.format = pack_doc.tokens.format.clone();
169 }
170
171 // 5. Copy deps THEN the filter token (shared dedup + conflict logic). ───────
172 let mut to_copy: Vec<Token> = Vec::with_capacity(dep_ids.len() + 1);
173 for dep_id in &dep_ids {
174 if let Some(tok) = pack_doc.tokens.tokens.iter().find(|t| &t.id == dep_id) {
175 to_copy.push(tok.clone());
176 }
177 }
178 to_copy.push(item_token.clone());
179 copy_tokens(&to_copy, &mut target.tokens.tokens, &mut warnings);
180
181 // 6. Record the libraries import entry. ────────────────────────────────────
182 if !target.libraries.iter().any(|l| l.id == pkg_id) {
183 target.libraries.push(LibraryDef {
184 id: pkg_id.to_owned(),
185 version: pack.version.clone(),
186 hash: None,
187 source_span: None,
188 unknown_props: BTreeMap::new(),
189 });
190 }
191
192 // 7. Record provenance (dedup identical node+library+item). ────────────────
193 let token_id = item.to_owned();
194 let provenance_id = if let Some(existing) = target
195 .provenance
196 .iter()
197 .find(|p| p.node == token_id && p.library == pkg_id && p.item.as_deref() == Some(item))
198 {
199 // An identical provenance already links this token to its origin; reuse
200 // its id rather than appending a redundant duplicate record.
201 existing.id.clone()
202 } else {
203 let all_ids = collect_all_ids(target);
204 let provenance_id = unique_id(&format!("prov.{}", id_base), &all_ids);
205 target.provenance.push(ProvenanceDef {
206 id: provenance_id.clone(),
207 node: token_id.clone(),
208 library: pkg_id.to_owned(),
209 item: Some(item.to_owned()),
210 linked: Some(true),
211 source_span: None,
212 unknown_props: BTreeMap::new(),
213 });
214 provenance_id
215 };
216
217 Ok(TokenAddOutcome {
218 pkg_id: pkg_id.to_owned(),
219 item: item.to_owned(),
220 token_id,
221 apply_property,
222 dep_token_ids: dep_ids.into_iter().collect(),
223 provenance_id,
224 warnings,
225 })
226}