Skip to main content

sui_spec/
store_recipe.rs

1//! Declarative pipeline composition for store operations.
2//!
3//! A `StoreRecipe` is a typed Lisp authoring surface that
4//! composes everything below it into one declarative artifact:
5//!
6//!   1. Pick a slice (selector by name + pattern + max-entries)
7//!   2. Apply N transforms in declared order
8//!   3. Materialize the result at a destination
9//!   4. Verify the round-trip
10//!
11//! Authored as:
12//!
13//! ```lisp
14//! (defstore-recipe
15//!   :name        "redacted-sources"
16//!   :description "Take the tiny-sources slice, redact secrets,
17//!                 materialize under ~/.cache/sui/recipes/."
18//!   :slice       "tiny-sources"
19//!   :transforms  ("redact-base64-secrets" "strip-shell-comments")
20//!   :dest-suffix "redacted-sources")
21//! ```
22//!
23//! The Rust executor resolves the slice + transform names from
24//! their canonical catalogs and runs the full pipeline.
25
26use serde::{Deserialize, Serialize};
27use tatara_lisp::DeriveTataraDomain;
28
29use crate::SpecError;
30
31/// Typed pipeline declaration.  Operator-facing Lisp form.
32#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
33#[tatara(keyword = "defstore-recipe")]
34pub struct StoreRecipe {
35    /// Stable name.
36    pub name: String,
37    /// Operator-facing description.
38    pub description: String,
39    /// Slice name from `store_ops::load_canonical_slices`.
40    pub slice: String,
41    /// Ordered list of transform names from
42    /// `store_transform::load_canonical`.
43    pub transforms: Vec<String>,
44    /// Suffix under `~/.cache/sui/recipes/` for the output.
45    /// The final dest is `~/.cache/sui/recipes/<dest-suffix>`.
46    #[serde(rename = "destSuffix")]
47    pub dest_suffix: String,
48}
49
50/// Per-entry outcome of running a recipe.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct RecipeEntryOutcome {
53    pub source: std::path::PathBuf,
54    pub dest: std::path::PathBuf,
55    /// Total rewrites across all transforms (file + ref + entry).
56    pub total_rewrites: usize,
57    /// `true` if every transform was a no-op for this entry.
58    pub noop: bool,
59}
60
61/// Aggregate outcome of running a recipe end-to-end.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct RecipeOutcome {
64    pub recipe: String,
65    pub slice: String,
66    pub transforms: Vec<String>,
67    pub dest_root: std::path::PathBuf,
68    pub entries: Vec<RecipeEntryOutcome>,
69}
70
71impl RecipeOutcome {
72    /// Number of entries the recipe modified (at least one
73    /// transform produced a non-zero count).
74    #[must_use]
75    pub fn modified_count(&self) -> usize {
76        self.entries.iter().filter(|e| !e.noop).count()
77    }
78
79    /// Total rewrites across every entry + every transform.
80    #[must_use]
81    pub fn total_rewrites(&self) -> usize {
82        self.entries.iter().map(|e| e.total_rewrites).sum()
83    }
84}
85
86/// Resolve + run a recipe end-to-end.  Lifts the slice
87/// definition from `store_ops::load_canonical_slices`, the
88/// transform definitions from `store_transform::load_canonical`,
89/// runs the materialize → transform → re-materialize pipeline.
90///
91/// # Errors
92///
93/// - `recipe-unknown-slice` if `recipe.slice` isn't authored.
94/// - `recipe-unknown-transform` if any transform name isn't authored.
95/// - Whatever the underlying primitives return on failure.
96pub fn run(
97    recipe: &StoreRecipe,
98    dest_root_base: &std::path::Path,
99) -> Result<RecipeOutcome, SpecError> {
100    use crate::store_ops::{self, ParsedNar};
101    use crate::store_transform;
102    use crate::nar;
103
104    let slices = store_ops::load_canonical_slices()?;
105    let slice = slices.iter().find(|s| s.name == recipe.slice)
106        .ok_or_else(|| SpecError::Interp {
107            phase: "recipe-unknown-slice".into(),
108            message: format!("recipe `{}` references unknown slice `{}`",
109                recipe.name, recipe.slice),
110        })?;
111
112    let all_transforms = store_transform::load_canonical()?;
113    let resolved: Vec<_> = recipe.transforms.iter().map(|name| {
114        all_transforms.iter().find(|t| &t.name == name)
115            .cloned()
116            .ok_or_else(|| SpecError::Interp {
117                phase: "recipe-unknown-transform".into(),
118                message: format!("recipe `{}` references unknown transform `{}`",
119                    recipe.name, name),
120            })
121    }).collect::<Result<Vec<_>, _>>()?;
122
123    let dest_root = dest_root_base.join(&recipe.dest_suffix);
124    std::fs::create_dir_all(&dest_root).map_err(|e| SpecError::Interp {
125        phase: "recipe-mkdir".into(),
126        message: format!("mkdir {}: {e}", dest_root.display()),
127    })?;
128
129    let plans = store_ops::build_materialization_plan(slice, &dest_root)?;
130    let mut entries: Vec<RecipeEntryOutcome> = Vec::with_capacity(plans.len());
131
132    for plan in &plans {
133        // 1. Encode source.
134        let nar_bytes = nar::encode(&plan.source).map_err(|e| SpecError::Interp {
135            phase: "recipe-encode".into(),
136            message: format!("encode {}: {e}", plan.source.display()),
137        })?;
138        // 2. Parse to typed tree.
139        let mut parsed = ParsedNar::parse(&nar_bytes).map_err(|e| SpecError::Interp {
140            phase: "recipe-parse".into(),
141            message: format!("parse {}: {e}", plan.source.display()),
142        })?;
143        // 3. Apply every transform.
144        let outcomes = store_transform::apply_all(&mut parsed, &resolved)?;
145        let total = outcomes.iter().map(|o|
146            o.file_rewrites + o.ref_rewrites + o.entries_renamed
147        ).sum::<usize>();
148        // 4. Serialize → decode → dest.
149        let new_nar = parsed.serialize();
150        if plan.dest.exists() {
151            std::fs::remove_dir_all(&plan.dest).ok();
152        }
153        nar::decode(&new_nar, &plan.dest).map_err(|e| SpecError::Interp {
154            phase: "recipe-decode".into(),
155            message: format!("decode → {}: {e}", plan.dest.display()),
156        })?;
157        entries.push(RecipeEntryOutcome {
158            source: plan.source.clone(),
159            dest: plan.dest.clone(),
160            total_rewrites: total,
161            noop: total == 0,
162        });
163    }
164
165    Ok(RecipeOutcome {
166        recipe: recipe.name.clone(),
167        slice: recipe.slice.clone(),
168        transforms: recipe.transforms.clone(),
169        dest_root,
170        entries,
171    })
172}
173
174// ── Canonical Lisp spec ────────────────────────────────────────────
175
176pub const CANONICAL_STORE_RECIPES_LISP: &str =
177    include_str!("../specs/store_recipes.lisp");
178
179/// Compile every authored recipe.
180///
181/// # Errors
182///
183/// Returns an error if the Lisp source can't be parsed.
184pub fn load_canonical() -> Result<Vec<StoreRecipe>, SpecError> {
185    crate::loader::load_all::<StoreRecipe>(CANONICAL_STORE_RECIPES_LISP)
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn canonical_recipes_parse() {
194        let rs = load_canonical().expect("recipes must compile");
195        assert!(!rs.is_empty());
196    }
197
198    #[test]
199    fn recipe_references_known_slice() {
200        // Substrate-wide invariant: every recipe's slice exists
201        // in the canonical slice catalog.
202        let recipes = load_canonical().unwrap();
203        let slices = crate::store_ops::load_canonical_slices().unwrap();
204        let slice_names: std::collections::HashSet<String> =
205            slices.iter().map(|s| s.name.clone()).collect();
206        for r in &recipes {
207            assert!(slice_names.contains(&r.slice),
208                "recipe `{}` references unknown slice `{}`", r.name, r.slice);
209        }
210    }
211
212    #[test]
213    fn recipe_references_known_transforms() {
214        let recipes = load_canonical().unwrap();
215        let xforms = crate::store_transform::load_canonical().unwrap();
216        let xform_names: std::collections::HashSet<String> =
217            xforms.iter().map(|t| t.name.clone()).collect();
218        for r in &recipes {
219            for t in &r.transforms {
220                assert!(xform_names.contains(t),
221                    "recipe `{}` references unknown transform `{}`", r.name, t);
222            }
223        }
224    }
225}