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}