1use serde::{Deserialize, Serialize};
6
7pub const META_NAME_MAX: usize = 64;
9pub const META_DESCRIPTION_MAX: usize = 1024;
11pub const META_WHEN_TO_USE_MAX: usize = 2048;
13pub const META_PHASES_MAX: usize = 64;
15pub const META_PHASE_TITLE_MAX: usize = 128;
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct PhaseMeta {
21 pub title: String,
23 #[serde(default)]
25 pub detail: Option<String>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct WorkflowMeta {
31 pub name: String,
33 pub description: String,
35 #[serde(default)]
37 pub phases: Vec<PhaseMeta>,
38 #[serde(default)]
40 pub when_to_use: Option<String>,
41}
42
43#[derive(Debug, thiserror::Error)]
45pub enum MetaError {
46 #[error("meta extract failed: {0}")]
48 Failed(String),
49 #[error("meta missing field: {0}")]
51 Missing(&'static str),
52 #[error("meta invalid {field}: {reason}")]
54 Invalid {
55 field: &'static str,
57 reason: String,
59 },
60}
61
62pub 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 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 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 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}