Skip to main content

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}