Skip to main content

zenith_cli/library/
action.rs

1//! Action materialization: `library add` of an ACTION item.
2
3use std::collections::BTreeMap;
4
5use zenith_core::{ActionDef, KdlAdapter, KdlSource, LibraryDef, ProvenanceDef};
6use zenith_tx::{Transaction, TxResult, TxStatus, run_transaction};
7
8use super::add::{
9    AddError, collect_all_ids, dependency_conflict, load_pack_document, unique_id,
10    unknown_package_error,
11};
12use super::registry::LibraryPack;
13
14/// The outcome of a successful [`materialize_action`] call.
15///
16/// All ids are the FINAL ids written into the target document. When
17/// `tx_result.status == Rejected` the function still returns `Ok` but
18/// `final_source` and `provenance_id` are `None` and no document mutation was
19/// recorded.
20#[derive(Debug, Clone, PartialEq)]
21pub struct ActionAddOutcome {
22    /// The package id the item came from (e.g. `@test/actions`).
23    pub pkg_id: String,
24    /// The item name within the pack (e.g. `apply-brand-kit`).
25    pub item: String,
26    /// The transaction result (status, diagnostics, source_before/after,
27    /// affected_node_ids).
28    pub tx_result: TxResult,
29    /// The fully-formatted new document source: tx applied, action copied in,
30    /// library import added, and provenance recorded. `None` when the tx was
31    /// Rejected.
32    pub final_source: Option<String>,
33    /// The recorded provenance entry id. `None` when the tx was Rejected.
34    pub provenance_id: Option<String>,
35    /// Non-fatal warnings (e.g. action id already present with different
36    /// metadata).
37    pub warnings: Vec<String>,
38}
39
40/// Materialize the action item `pkg_id#action_id` against `target_src`,
41/// returning the [`ActionAddOutcome`] describing what happened.
42///
43/// This is the PURE core of a `library add` for an ACTION item: it produces a
44/// new document source string and performs NO filesystem or process I/O.
45/// Steps:
46///
47/// 1. Resolve the FIRST pack in `packs` whose id == `pkg_id`; load its full
48///    [`zenith_core::Document`] and find the [`ActionDef`] whose id ==
49///    `action_id`.
50/// 2. Parse `target_src` into a [`zenith_core::Document`].
51/// 3. Parse the action's `tx_json` into a [`Transaction`].
52/// 4. Run the transaction against the target.  If the result is Rejected,
53///    return immediately with `final_source: None` and `provenance_id: None`.
54/// 5. Re-parse `tx_result.source_after` and:
55///    - Copy the [`ActionDef`] into `result_doc.actions` (dedup by id;
56///      same-id-different-content keeps the existing one and records a
57///      warning).
58///    - Add a `libraries` import for `pkg_id` (dedup by id; conflict warning
59///      on mismatch, using the pack's version — mirror `materialize_token`).
60///    - Generate a unique provenance id (via `unique_id`/`collect_all_ids`)
61///      and push a [`ProvenanceDef`] whose `node` is the action id.
62/// 6. Format the result document and return the full outcome.
63///
64/// # Errors
65///
66/// Returns [`AddError`] when the package or item is unknown (the message lists
67/// the available options), the target or the tx envelope cannot be parsed, or
68/// the formatted output cannot be produced.
69pub fn materialize_action(
70    target_src: &str,
71    packs: &[LibraryPack],
72    pkg_id: &str,
73    action_id: &str,
74) -> Result<ActionAddOutcome, AddError> {
75    // 1. Resolve the pack + load its document. ────────────────────────────────
76    let pack = packs
77        .iter()
78        .find(|p| p.id == pkg_id)
79        .ok_or_else(|| unknown_package_error(pkg_id, packs))?;
80
81    let pack_doc = load_pack_document(pack)?;
82
83    let pack_action = pack_doc
84        .actions
85        .iter()
86        .find(|a| a.id == action_id)
87        .ok_or_else(|| {
88            let available: Vec<&str> = pack_doc.actions.iter().map(|a| a.id.as_str()).collect();
89            AddError::new(format!(
90                "unknown action item '{}' in package '{}' (available: {})",
91                action_id,
92                pkg_id,
93                if available.is_empty() {
94                    "none".to_owned()
95                } else {
96                    available.join(", ")
97                }
98            ))
99        })?;
100
101    // 2. Parse the target document. ────────────────────────────────────────────
102    let target_doc = KdlAdapter
103        .parse(target_src.as_bytes())
104        .map_err(|e| AddError::new(format!("error parsing target document: {}", e)))?;
105
106    // 3. Parse the action's tx envelope. ──────────────────────────────────────
107    let tx = Transaction::from_json(&pack_action.tx_json).map_err(|e| {
108        AddError::new(format!(
109            "malformed tx-script in action '{}': {}",
110            action_id, e
111        ))
112    })?;
113
114    // 4. Run the transaction. ──────────────────────────────────────────────────
115    let tx_result = run_transaction(&target_doc, &tx)
116        .map_err(|e| AddError::new(format!("transaction error: {}", e)))?;
117
118    // Rejected → return immediately; the CLI layer decides what to show the user.
119    match &tx_result.status {
120        TxStatus::Rejected => {
121            return Ok(ActionAddOutcome {
122                pkg_id: pkg_id.to_owned(),
123                item: action_id.to_owned(),
124                tx_result,
125                final_source: None,
126                provenance_id: None,
127                warnings: vec![],
128            });
129        }
130        TxStatus::Accepted | TxStatus::AcceptedWithWarnings => {}
131    }
132
133    // 5. Re-parse the post-tx source and record the action + library + provenance.
134    let mut result_doc = KdlAdapter
135        .parse(tx_result.source_after.as_bytes())
136        .map_err(|e| {
137            AddError::new(format!(
138                "internal error: could not re-parse transaction output: {}",
139                e
140            ))
141        })?;
142
143    let mut warnings: Vec<String> = Vec::new();
144
145    // Copy the ActionDef (dedup by id; conflict on same-id different content).
146    match result_doc.actions.iter().find(|a| a.id == action_id) {
147        Some(existing) if existing.tx_json != pack_action.tx_json => {
148            warnings.push(dependency_conflict("action", action_id));
149        }
150        Some(_) => {}
151        None => result_doc.actions.push(ActionDef {
152            id: pack_action.id.clone(),
153            label: pack_action.label.clone(),
154            version: pack_action.version.clone(),
155            tx_json: pack_action.tx_json.clone(),
156            source_span: None,
157            unknown_props: BTreeMap::new(),
158        }),
159    }
160
161    // Add the libraries import (mirror materialize_token exactly). ─────────────
162    if !result_doc.libraries.iter().any(|l| l.id == pkg_id) {
163        result_doc.libraries.push(LibraryDef {
164            id: pkg_id.to_owned(),
165            version: pack.version.clone(),
166            hash: None,
167            source_span: None,
168            unknown_props: BTreeMap::new(),
169        });
170    }
171
172    // Generate a unique provenance id and push the record. ────────────────────
173    let all_ids = collect_all_ids(&result_doc);
174    let provenance_id = unique_id(&format!("prov.{}", action_id), &all_ids);
175    result_doc.provenance.push(ProvenanceDef {
176        id: provenance_id.clone(),
177        node: action_id.to_owned(),
178        library: pkg_id.to_owned(),
179        item: Some(action_id.to_owned()),
180        linked: Some(true),
181        source_span: None,
182        unknown_props: BTreeMap::new(),
183    });
184
185    // 6. Format the final document. ────────────────────────────────────────────
186    let final_bytes = KdlAdapter
187        .format(&result_doc)
188        .map_err(|e| AddError::new(format!("error formatting result document: {}", e)))?;
189    let final_source = String::from_utf8(final_bytes)
190        .map_err(|e| AddError::new(format!("error encoding result document: {}", e)))?;
191
192    Ok(ActionAddOutcome {
193        pkg_id: pkg_id.to_owned(),
194        item: action_id.to_owned(),
195        tx_result,
196        final_source: Some(final_source),
197        provenance_id: Some(provenance_id),
198        warnings,
199    })
200}