use serde::{Deserialize, Serialize};
pub const META_NAME_MAX: usize = 64;
pub const META_DESCRIPTION_MAX: usize = 1024;
pub const META_WHEN_TO_USE_MAX: usize = 2048;
pub const META_PHASES_MAX: usize = 64;
pub const META_PHASE_TITLE_MAX: usize = 128;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PhaseMeta {
pub title: String,
#[serde(default)]
pub detail: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkflowMeta {
pub name: String,
pub description: String,
#[serde(default)]
pub phases: Vec<PhaseMeta>,
#[serde(default)]
pub when_to_use: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum MetaError {
#[error("meta extract failed: {0}")]
Failed(String),
#[error("meta missing field: {0}")]
Missing(&'static str),
#[error("meta invalid {field}: {reason}")]
Invalid {
field: &'static str,
reason: String,
},
}
pub fn extract_meta(script: &str) -> Result<WorkflowMeta, MetaError> {
require_leading_meta_stmt(script)?;
let mut engine = rhai::Engine::new();
engine.set_max_operations(100_000);
engine.set_max_expr_depths(128, 64);
engine.set_max_string_size(META_WHEN_TO_USE_MAX.saturating_mul(2));
engine.disable_symbol("eval");
engine.register_fn("agent", |_p: &str| rhai::Map::new());
engine.register_fn("phase", |_t: &str| {});
engine.register_fn(
"complete",
|_v: rhai::Dynamic| -> Result<(), Box<rhai::EvalAltResult>> { Err("complete".into()) },
);
engine.register_fn(
"pause",
|_k: &str, _m: &str| -> Result<(), Box<rhai::EvalAltResult>> { Err("pause".into()) },
);
engine.register_fn(
"await_user",
|_k: &str, _m: &str| -> Result<(), Box<rhai::EvalAltResult>> { Err("await_user".into()) },
);
engine.register_fn("log", |_m: &str| {});
engine.register_fn("print", |_m: &str| {});
engine.register_fn("debug", |_m: &str| {});
engine.register_fn("telemetry_event", |_n: &str, _f: rhai::Map| {});
engine.register_fn("write_scratch_file", |_n: &str, _c: &str| String::new());
engine.register_fn("read_scratch_file", |_n: &str| String::new());
engine.register_fn("render_template", |_n: &str, _v: rhai::Dynamic| {
String::new()
});
engine.register_fn("git_diff_since", |_c: &str| String::new());
engine.register_fn("parallel", |_a: rhai::Array| rhai::Array::new());
engine.register_fn("budget", rhai::Map::new);
engine.register_fn("json_encode", |_v: rhai::Dynamic| String::from("null"));
engine.register_fn("fingerprint", |_t: &str| "0".repeat(32));
let mut scope = rhai::Scope::new();
scope.push_dynamic("args", rhai::Dynamic::UNIT);
drop(engine.eval_with_scope::<rhai::Dynamic>(&mut scope, script));
let meta_map = scope
.get_value::<rhai::Map>("meta")
.ok_or(MetaError::Missing("meta"))?;
let value = rhai::serde::from_dynamic::<serde_json::Value>(&meta_map.into())
.map_err(|e| MetaError::Failed(e.to_string()))?;
let meta: WorkflowMeta =
serde_json::from_value(value).map_err(|e| MetaError::Failed(e.to_string()))?;
validate_meta(&meta)?;
Ok(meta)
}
fn require_leading_meta_stmt(script: &str) -> Result<(), MetaError> {
let stripped = strip_leading_ws_and_comments(script);
let lower = stripped.to_ascii_lowercase();
let ok = lower.starts_with("let meta")
&& stripped
.get("let meta".len()..)
.is_some_and(|rest| rest.trim_start().starts_with('='));
if !ok {
return Err(MetaError::Invalid {
field: "script",
reason: "first statement must be `let meta = #{ ... };`".into(),
});
}
Ok(())
}
fn strip_leading_ws_and_comments(script: &str) -> &str {
let mut s = script.trim_start();
loop {
if s.starts_with("//") {
s = s.split_once('\n').map_or("", |(_, rest)| rest.trim_start());
continue;
}
if s.starts_with("/*") {
s = s.split_once("*/").map_or("", |(_, rest)| rest.trim_start());
continue;
}
break;
}
s
}
fn validate_meta(meta: &WorkflowMeta) -> Result<(), MetaError> {
let name = meta.name.trim();
if name.is_empty() {
return Err(MetaError::Missing("meta.name"));
}
if name.len() > META_NAME_MAX {
return Err(MetaError::Invalid {
field: "meta.name",
reason: format!("exceeds {META_NAME_MAX} bytes"),
});
}
if !is_kebab_case(name) {
return Err(MetaError::Invalid {
field: "meta.name",
reason: "must be kebab-case ([a-z0-9]+(-[a-z0-9]+)*)".into(),
});
}
let description = meta.description.trim();
if description.is_empty() {
return Err(MetaError::Missing("meta.description"));
}
if description.len() > META_DESCRIPTION_MAX {
return Err(MetaError::Invalid {
field: "meta.description",
reason: format!("exceeds {META_DESCRIPTION_MAX} bytes"),
});
}
if let Some(w) = &meta.when_to_use
&& w.len() > META_WHEN_TO_USE_MAX
{
return Err(MetaError::Invalid {
field: "meta.when_to_use",
reason: format!("exceeds {META_WHEN_TO_USE_MAX} bytes"),
});
}
if meta.phases.len() > META_PHASES_MAX {
return Err(MetaError::Invalid {
field: "meta.phases",
reason: format!("at most {META_PHASES_MAX} phases"),
});
}
let mut seen = std::collections::BTreeSet::new();
for (i, phase) in meta.phases.iter().enumerate() {
let title = phase.title.trim();
if title.is_empty() {
return Err(MetaError::Invalid {
field: "meta.phases.title",
reason: format!("phase {i} title is empty"),
});
}
if title.len() > META_PHASE_TITLE_MAX {
return Err(MetaError::Invalid {
field: "meta.phases.title",
reason: format!("phase {i} title exceeds {META_PHASE_TITLE_MAX} bytes"),
});
}
if !seen.insert(title.to_owned()) {
return Err(MetaError::Invalid {
field: "meta.phases.title",
reason: format!("duplicate phase title `{title}`"),
});
}
}
Ok(())
}
fn is_kebab_case(s: &str) -> bool {
if s.is_empty() || s.starts_with('-') || s.ends_with('-') {
return false;
}
let mut prev_dash = false;
for c in s.chars() {
match c {
'a'..='z' | '0'..='9' => prev_dash = false,
'-' if !prev_dash => prev_dash = true,
_ => return false,
}
}
true
}
#[cfg(test)]
#[allow(clippy::expect_used, reason = "unit tests")]
mod tests {
use super::*;
#[test]
fn extracts_meta() {
let script = r#"
let meta = #{ name: "fanout", description: "test workflow", phases: [] };
complete(#{});
"#;
let meta = extract_meta(script).expect("meta");
assert_eq!(meta.name, "fanout");
}
#[test]
fn rejects_non_kebab_name() {
let script = r#"
let meta = #{ name: "Bad_Name", description: "x" };
complete(1);
"#;
let err = extract_meta(script).expect_err("kebab");
assert!(matches!(
err,
MetaError::Invalid {
field: "meta.name",
..
}
));
}
#[test]
fn rejects_missing_leading_meta() {
let script = r#"
phase("x");
let meta = #{ name: "fanout", description: "x" };
complete(1);
"#;
let err = extract_meta(script).expect_err("leading");
assert!(matches!(
err,
MetaError::Invalid {
field: "script",
..
}
));
}
#[test]
fn rejects_duplicate_phase_titles() {
let script = r#"
let meta = #{
name: "t",
description: "d",
phases: [ #{ title: "a" }, #{ title: "a" } ]
};
complete(1);
"#;
let err = extract_meta(script).expect_err("dup");
assert!(matches!(
err,
MetaError::Invalid {
field: "meta.phases.title",
..
}
));
}
}