Skip to main content

machi_workflow/
meta.rs

1//! Workflow metadata extraction from scripts.
2//!
3//! Maturity: **core**
4
5use serde::{Deserialize, Serialize};
6
7/// Maximum `meta.name` length in bytes.
8pub const META_NAME_MAX: usize = 64;
9/// Maximum `meta.description` length in bytes.
10pub const META_DESCRIPTION_MAX: usize = 1024;
11/// Maximum optional `when_to_use` length in bytes.
12pub const META_WHEN_TO_USE_MAX: usize = 2048;
13/// Maximum number of phases.
14pub const META_PHASES_MAX: usize = 64;
15/// Maximum phase title length in bytes.
16pub const META_PHASE_TITLE_MAX: usize = 128;
17
18/// Phase descriptor for UIs.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct PhaseMeta {
21    /// Title.
22    pub title: String,
23    /// Optional detail.
24    #[serde(default)]
25    pub detail: Option<String>,
26}
27
28/// Workflow catalog metadata.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct WorkflowMeta {
31    /// Slug name (kebab-case, ≤64 bytes).
32    pub name: String,
33    /// Description (≤1024 bytes).
34    pub description: String,
35    /// Optional phases (≤64, unique titles ≤128).
36    #[serde(default)]
37    pub phases: Vec<PhaseMeta>,
38    /// Optional when-to-use hint (≤2048 bytes).
39    #[serde(default)]
40    pub when_to_use: Option<String>,
41}
42
43/// Meta extraction errors.
44#[derive(Debug, thiserror::Error)]
45pub enum MetaError {
46    /// Script failed to parse/run for meta probe.
47    #[error("meta extract failed: {0}")]
48    Failed(String),
49    /// Missing required field.
50    #[error("meta missing field: {0}")]
51    Missing(&'static str),
52    /// Field failed validation.
53    #[error("meta invalid {field}: {reason}")]
54    Invalid {
55        /// Field path.
56        field: &'static str,
57        /// Reason.
58        reason: String,
59    },
60}
61
62/// Extract `meta` map by evaluating the script with dummy host functions noop.
63///
64/// Scripts **must** start with `let meta = #{ name: "...", description: "..." };`
65/// as the first statement (after optional whitespace/comments).
66///
67/// # Errors
68///
69/// Returns [`MetaError`] on parse/eval/missing fields/validation.
70pub fn extract_meta(script: &str) -> Result<WorkflowMeta, MetaError> {
71    require_leading_meta_stmt(script)?;
72
73    let mut engine = rhai::Engine::new();
74    engine.set_max_operations(100_000);
75    engine.set_max_expr_depths(128, 64);
76    engine.set_max_string_size(META_WHEN_TO_USE_MAX.saturating_mul(2));
77    engine.disable_symbol("eval");
78    // Stub host fns so meta-only scripts that reference them later still compile.
79    engine.register_fn("agent", |_p: &str| rhai::Map::new());
80    engine.register_fn("phase", |_t: &str| {});
81    engine.register_fn(
82        "complete",
83        |_v: rhai::Dynamic| -> Result<(), Box<rhai::EvalAltResult>> { Err("complete".into()) },
84    );
85    engine.register_fn(
86        "pause",
87        |_k: &str, _m: &str| -> Result<(), Box<rhai::EvalAltResult>> { Err("pause".into()) },
88    );
89    engine.register_fn(
90        "await_user",
91        |_k: &str, _m: &str| -> Result<(), Box<rhai::EvalAltResult>> { Err("await_user".into()) },
92    );
93    engine.register_fn("log", |_m: &str| {});
94    engine.register_fn("print", |_m: &str| {});
95    engine.register_fn("debug", |_m: &str| {});
96    engine.register_fn("telemetry_event", |_n: &str, _f: rhai::Map| {});
97    engine.register_fn("write_scratch_file", |_n: &str, _c: &str| String::new());
98    engine.register_fn("read_scratch_file", |_n: &str| String::new());
99    engine.register_fn("render_template", |_n: &str, _v: rhai::Dynamic| {
100        String::new()
101    });
102    engine.register_fn("git_diff_since", |_c: &str| String::new());
103    engine.register_fn("parallel", |_a: rhai::Array| rhai::Array::new());
104    engine.register_fn("budget", rhai::Map::new);
105    engine.register_fn("json_encode", |_v: rhai::Dynamic| String::from("null"));
106    engine.register_fn("fingerprint", |_t: &str| "0".repeat(32));
107
108    let mut scope = rhai::Scope::new();
109    scope.push_dynamic("args", rhai::Dynamic::UNIT);
110    // Probe evaluation may fail after `meta` is bound (e.g. complete()); ignore.
111    drop(engine.eval_with_scope::<rhai::Dynamic>(&mut scope, script));
112
113    let meta_map = scope
114        .get_value::<rhai::Map>("meta")
115        .ok_or(MetaError::Missing("meta"))?;
116    let value = rhai::serde::from_dynamic::<serde_json::Value>(&meta_map.into())
117        .map_err(|e| MetaError::Failed(e.to_string()))?;
118    let meta: WorkflowMeta =
119        serde_json::from_value(value).map_err(|e| MetaError::Failed(e.to_string()))?;
120    validate_meta(&meta)?;
121    Ok(meta)
122}
123
124fn require_leading_meta_stmt(script: &str) -> Result<(), MetaError> {
125    let stripped = strip_leading_ws_and_comments(script);
126    let lower = stripped.to_ascii_lowercase();
127    // Accept `let meta = #{` or `let meta=#{`
128    let ok = lower.starts_with("let meta")
129        && stripped
130            .get("let meta".len()..)
131            .is_some_and(|rest| rest.trim_start().starts_with('='));
132    if !ok {
133        return Err(MetaError::Invalid {
134            field: "script",
135            reason: "first statement must be `let meta = #{ ... };`".into(),
136        });
137    }
138    Ok(())
139}
140
141fn strip_leading_ws_and_comments(script: &str) -> &str {
142    let mut s = script.trim_start();
143    loop {
144        if s.starts_with("//") {
145            s = s.split_once('\n').map_or("", |(_, rest)| rest.trim_start());
146            continue;
147        }
148        if s.starts_with("/*") {
149            s = s.split_once("*/").map_or("", |(_, rest)| rest.trim_start());
150            continue;
151        }
152        break;
153    }
154    s
155}
156
157fn validate_meta(meta: &WorkflowMeta) -> Result<(), MetaError> {
158    let name = meta.name.trim();
159    if name.is_empty() {
160        return Err(MetaError::Missing("meta.name"));
161    }
162    if name.len() > META_NAME_MAX {
163        return Err(MetaError::Invalid {
164            field: "meta.name",
165            reason: format!("exceeds {META_NAME_MAX} bytes"),
166        });
167    }
168    if !is_kebab_case(name) {
169        return Err(MetaError::Invalid {
170            field: "meta.name",
171            reason: "must be kebab-case ([a-z0-9]+(-[a-z0-9]+)*)".into(),
172        });
173    }
174
175    let description = meta.description.trim();
176    if description.is_empty() {
177        return Err(MetaError::Missing("meta.description"));
178    }
179    if description.len() > META_DESCRIPTION_MAX {
180        return Err(MetaError::Invalid {
181            field: "meta.description",
182            reason: format!("exceeds {META_DESCRIPTION_MAX} bytes"),
183        });
184    }
185
186    if let Some(w) = &meta.when_to_use
187        && w.len() > META_WHEN_TO_USE_MAX
188    {
189        return Err(MetaError::Invalid {
190            field: "meta.when_to_use",
191            reason: format!("exceeds {META_WHEN_TO_USE_MAX} bytes"),
192        });
193    }
194
195    if meta.phases.len() > META_PHASES_MAX {
196        return Err(MetaError::Invalid {
197            field: "meta.phases",
198            reason: format!("at most {META_PHASES_MAX} phases"),
199        });
200    }
201
202    let mut seen = std::collections::BTreeSet::new();
203    for (i, phase) in meta.phases.iter().enumerate() {
204        let title = phase.title.trim();
205        if title.is_empty() {
206            return Err(MetaError::Invalid {
207                field: "meta.phases.title",
208                reason: format!("phase {i} title is empty"),
209            });
210        }
211        if title.len() > META_PHASE_TITLE_MAX {
212            return Err(MetaError::Invalid {
213                field: "meta.phases.title",
214                reason: format!("phase {i} title exceeds {META_PHASE_TITLE_MAX} bytes"),
215            });
216        }
217        if !seen.insert(title.to_owned()) {
218            return Err(MetaError::Invalid {
219                field: "meta.phases.title",
220                reason: format!("duplicate phase title `{title}`"),
221            });
222        }
223    }
224    Ok(())
225}
226
227fn is_kebab_case(s: &str) -> bool {
228    if s.is_empty() || s.starts_with('-') || s.ends_with('-') {
229        return false;
230    }
231    let mut prev_dash = false;
232    for c in s.chars() {
233        match c {
234            'a'..='z' | '0'..='9' => prev_dash = false,
235            '-' if !prev_dash => prev_dash = true,
236            _ => return false,
237        }
238    }
239    true
240}
241
242#[cfg(test)]
243#[allow(clippy::expect_used, reason = "unit tests")]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn extracts_meta() {
249        let script = r#"
250            let meta = #{ name: "fanout", description: "test workflow", phases: [] };
251            complete(#{});
252        "#;
253        let meta = extract_meta(script).expect("meta");
254        assert_eq!(meta.name, "fanout");
255    }
256
257    #[test]
258    fn rejects_non_kebab_name() {
259        let script = r#"
260            let meta = #{ name: "Bad_Name", description: "x" };
261            complete(1);
262        "#;
263        let err = extract_meta(script).expect_err("kebab");
264        assert!(matches!(
265            err,
266            MetaError::Invalid {
267                field: "meta.name",
268                ..
269            }
270        ));
271    }
272
273    #[test]
274    fn rejects_missing_leading_meta() {
275        let script = r#"
276            phase("x");
277            let meta = #{ name: "fanout", description: "x" };
278            complete(1);
279        "#;
280        let err = extract_meta(script).expect_err("leading");
281        assert!(matches!(
282            err,
283            MetaError::Invalid {
284                field: "script",
285                ..
286            }
287        ));
288    }
289
290    #[test]
291    fn rejects_duplicate_phase_titles() {
292        let script = r#"
293            let meta = #{
294                name: "t",
295                description: "d",
296                phases: [ #{ title: "a" }, #{ title: "a" } ]
297            };
298            complete(1);
299        "#;
300        let err = extract_meta(script).expect_err("dup");
301        assert!(matches!(
302            err,
303            MetaError::Invalid {
304                field: "meta.phases.title",
305                ..
306            }
307        ));
308    }
309}