1use luft_runtime::ScriptError;
7use mlua::{Lua, Table, Value};
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
12pub struct PlanMeta {
13 pub phases: Vec<MetaPhase>,
14 #[serde(default)]
15 pub reasoning: String,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
20pub struct MetaPhase {
21 pub label: String,
22 pub detail: String,
23 #[serde(default)]
24 pub agents: usize,
25 #[serde(default)]
26 pub depends_on: Vec<u32>,
27}
28
29#[derive(Debug, Clone, Default)]
31pub struct MetaValidation {
32 pub errors: Vec<String>,
33 pub warnings: Vec<String>,
34}
35
36impl MetaValidation {
37 pub fn is_valid(&self) -> bool {
38 self.errors.is_empty()
39 }
40}
41
42pub fn extract_meta(script: &str) -> Result<Option<PlanMeta>, ScriptError> {
47 let lua = Lua::new();
48 register_stubs(&lua).map_err(ScriptError::from)?;
49 lua.load(script)
50 .exec()
51 .map_err(|e| ScriptError::Internal(format!("top-level exec failed: {e}")))?;
52
53 let globals = lua.globals();
54 let value: Value = match globals.get("meta") {
55 Ok(v) => v,
56 Err(_) => return Ok(None),
57 };
58 if !matches!(value, Value::Table(_)) {
59 return Ok(None);
60 }
61
62 let meta_table: Table = value.as_table().cloned().expect("checked Value::Table");
63
64 let phases_value: Table = match meta_table.get("phases") {
65 Ok(t) => t,
66 Err(_) => return Ok(None),
67 };
68
69 let mut phases = Vec::new();
70 let len = phases_value.raw_len();
71 for i in 1..=len {
72 let v: Value = phases_value
73 .raw_get(i)
74 .map_err(|e| ScriptError::Internal(format!("meta.phases[{i}]: {e}")))?;
75 let phase = lua_to_meta_phase(v)?;
76 phases.push(phase);
77 }
78
79 let reasoning: String = meta_table
80 .get::<Option<String>>("reasoning")
81 .map_err(|e| ScriptError::Internal(format!("meta.reasoning: {e}")))?
82 .unwrap_or_default();
83
84 Ok(Some(PlanMeta { phases, reasoning }))
85}
86
87fn lua_to_meta_phase(value: Value) -> Result<MetaPhase, ScriptError> {
88 let table = match value {
89 Value::Table(t) => t,
90 _ => return Err(ScriptError::Internal("meta.phases[*] must be a table".into())),
91 };
92 let label: String = table
93 .get("label")
94 .map_err(|e| ScriptError::Internal(format!("meta.phases[*].label: {e}")))?;
95 let detail: String = table
96 .get("detail")
97 .map_err(|e| ScriptError::Internal(format!("meta.phases[*].detail: {e}")))?;
98 let agents: usize = table
99 .get::<Option<usize>>("agents")
100 .map_err(|e| ScriptError::Internal(format!("meta.phases[*].agents: {e}")))?
101 .unwrap_or(0);
102 let depends_on: Vec<u32> = table
103 .get::<Option<Vec<u32>>>("depends_on")
104 .map_err(|e| ScriptError::Internal(format!("meta.phases[*].depends_on: {e}")))?
105 .unwrap_or_default();
106 Ok(MetaPhase { label, detail, agents, depends_on })
107}
108
109fn register_stubs(lua: &Lua) -> mlua::Result<()> {
111 let globals = lua.globals();
112 let phase = lua.create_function(|_, _: mlua::MultiValue| Ok(0u32))?;
113 globals.set("phase", phase)?;
114 let report = lua.create_function(|_, _: mlua::MultiValue| Ok(Value::Nil))?;
115 globals.set("report", report)?;
116 let log = lua.create_function(|_, _: mlua::MultiValue| Ok(Value::Nil))?;
117 globals.set("log", log)?;
118 let budget = lua.create_function(|_, _: mlua::MultiValue| Ok(Value::Nil))?;
119 globals.set("budget", budget)?;
120 let agent = lua.create_function(|lua, _: mlua::MultiValue| Ok(Value::Table(lua.create_table()?)))?;
121 globals.set("agent", agent)?;
122 let parallel = lua.create_function(|lua, _: mlua::MultiValue| Ok(Value::Table(lua.create_table()?)))?;
123 globals.set("parallel", parallel)?;
124 let pipeline = lua.create_function(|lua, _: mlua::MultiValue| Ok(Value::Table(lua.create_table()?)))?;
125 globals.set("pipeline", pipeline)?;
126 let workflow = lua.create_function(|lua, _: mlua::MultiValue| Ok(Value::Table(lua.create_table()?)))?;
127 globals.set("workflow", workflow)?;
128
129 let json_table = lua.create_table()?;
130 let encode = lua.create_function(|_, v: Value| {
131 Ok(serde_json::to_string(&lua_value_to_json(&v)).unwrap_or_default())
132 })?;
133 let decode = lua.create_function(|lua, s: String| match serde_json::from_str::<serde_json::Value>(&s) {
134 Ok(v) => json_to_lua_value(lua, &v),
135 Err(_) => Ok(Value::Nil),
136 })?;
137 json_table.set("encode", encode)?;
138 json_table.set("decode", decode)?;
139 globals.set("json", json_table)?;
140
141 globals.set("args", lua.create_table()?)?;
142 let ctx = lua.create_table()?;
143 ctx.set("run_id", "")?;
144 globals.set("ctx", ctx)?;
145 Ok(())
146}
147
148fn lua_value_to_json(v: &Value) -> serde_json::Value {
149 match v {
150 Value::Nil => serde_json::Value::Null,
151 Value::Boolean(b) => serde_json::Value::Bool(*b),
152 Value::Integer(i) => serde_json::json!(i),
153 Value::Number(n) => serde_json::json!(n),
154 Value::String(s) => serde_json::Value::String(s.to_string_lossy()),
155 Value::Table(t) => {
156 let len = t.raw_len();
157 if len > 0 {
158 let mut arr = Vec::with_capacity(len);
159 for i in 1..=len {
160 match t.raw_get::<Value>(i) {
161 Ok(v) => arr.push(lua_value_to_json(&v)),
162 Err(_) => break,
163 }
164 }
165 if arr.len() == len {
166 return serde_json::Value::Array(arr);
167 }
168 }
169 let mut map = serde_json::Map::new();
170 for pair in t.pairs::<Value, Value>().flatten() {
171 if let Value::String(k) = pair.0 {
172 map.insert(k.to_string_lossy(), lua_value_to_json(&pair.1));
173 }
174 }
175 serde_json::Value::Object(map)
176 }
177 _ => serde_json::Value::Null,
178 }
179}
180
181fn json_to_lua_value(lua: &Lua, v: &serde_json::Value) -> mlua::Result<Value> {
182 match v {
183 serde_json::Value::Null => Ok(Value::Nil),
184 serde_json::Value::Bool(b) => Ok(Value::Boolean(*b)),
185 serde_json::Value::Number(n) => {
186 if let Some(i) = n.as_i64() { Ok(Value::Integer(i)) }
187 else if let Some(f) = n.as_f64() { Ok(Value::Number(f)) }
188 else { Ok(Value::Nil) }
189 }
190 serde_json::Value::String(s) => Ok(Value::String(lua.create_string(s)?)),
191 serde_json::Value::Array(arr) => {
192 let t = lua.create_table()?;
193 for (i, item) in arr.iter().enumerate() {
194 t.raw_set(i + 1, json_to_lua_value(lua, item)?)?;
195 }
196 Ok(Value::Table(t))
197 }
198 serde_json::Value::Object(map) => {
199 let t = lua.create_table()?;
200 for (k, val) in map {
201 t.set(k.as_str(), json_to_lua_value(lua, val)?)?;
202 }
203 Ok(Value::Table(t))
204 }
205 }
206}
207
208pub fn validate_meta(meta: &PlanMeta, script: &str) -> MetaValidation {
210 let mut out = MetaValidation::default();
211
212 if meta.phases.is_empty() {
213 out.warnings.push("meta.phases is empty; progress display will show nothing".into());
214 }
215
216 let mut seen_labels = std::collections::HashSet::new();
217 for (idx, phase) in meta.phases.iter().enumerate() {
218 if phase.label.trim().is_empty() {
219 out.errors.push(format!("meta.phases[{}].label is empty", idx));
220 }
221 if !seen_labels.insert(phase.label.as_str()) {
222 out.errors.push(format!("duplicate phase label: '{}'", phase.label));
223 }
224 for dep in &phase.depends_on {
225 if *dep == 0 {
226 out.errors.push(format!("meta.phases[{}].depends_on uses 0; must be 1-based", idx));
227 continue;
228 }
229 let dep_idx = (*dep as usize).saturating_sub(1);
230 if dep_idx >= meta.phases.len() {
231 out.errors.push(format!(
232 "meta.phases[{}].depends_on={} out of range (have {} phases)",
233 idx, dep, meta.phases.len()
234 ));
235 } else if dep_idx == idx {
236 out.errors.push(format!("meta.phases[{}] depends on itself", idx));
237 }
238 }
239 }
240
241 if !meta.phases.is_empty() {
242 for phase in &meta.phases {
243 let needle1 = format!("phase(\"{}\"", phase.label);
244 let needle2 = format!("phase('{}'", phase.label);
245 if !script.contains(&needle1) && !script.contains(&needle2) {
246 out.warnings.push(format!(
247 "phase label '{}' has no matching phase(...) call in script",
248 phase.label
249 ));
250 }
251 }
252 }
253
254 out
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn extract_meta_full() {
263 let script = r#"
264meta = {
265 phases = {
266 { label = "discovery", detail = "find files", agents = 1, depends_on = {} },
267 { label = "analysis", detail = "analyze", agents = 3, depends_on = { 1 } }
268 },
269 reasoning = "two-stage pipeline"
270}
271function main() report({ ok = true }) end
272"#;
273 let meta = extract_meta(script).unwrap().unwrap();
274 assert_eq!(meta.phases.len(), 2);
275 assert_eq!(meta.phases[0].label, "discovery");
276 assert_eq!(meta.phases[1].depends_on, vec![1]);
277 assert_eq!(meta.reasoning, "two-stage pipeline");
278 }
279
280 #[test]
281 fn extract_meta_missing() {
282 assert!(extract_meta("report({ ok = true })").unwrap().is_none());
283 }
284
285 #[test]
286 fn extract_meta_not_a_table() {
287 assert!(extract_meta("meta = \"hello\"\nreport({})").unwrap().is_none());
288 }
289
290 #[test]
291 fn extract_meta_default_values() {
292 let script = "meta = { phases = { { label = 'p', detail = 'd' } } }\nfunction main() report({}) end";
293 let meta = extract_meta(script).unwrap().unwrap();
294 assert_eq!(meta.phases[0].agents, 0);
295 assert!(meta.phases[0].depends_on.is_empty());
296 }
297
298 #[test]
299 fn extract_meta_top_level_phase_safe() {
300 let script = "meta = { phases = { { label = 'x', detail = 'y' } } }\nphase('x', 1)\nfunction main() report({}) end";
301 let meta = extract_meta(script).unwrap().unwrap();
302 assert_eq!(meta.phases[0].label, "x");
303 }
304
305 #[test]
306 fn validate_meta_ok() {
307 let meta = PlanMeta {
308 phases: vec![
309 MetaPhase { label: "a".into(), detail: "1".into(), agents: 1, depends_on: vec![] },
310 MetaPhase { label: "b".into(), detail: "2".into(), agents: 2, depends_on: vec![1] },
311 ],
312 reasoning: String::new(),
313 };
314 let v = validate_meta(&meta, "phase(\"a\", 1); phase(\"b\", 2)");
315 assert!(v.is_valid());
316 }
317
318 #[test]
319 fn validate_meta_duplicate_label() {
320 let meta = PlanMeta {
321 phases: vec![
322 MetaPhase { label: "a".into(), detail: "1".into(), ..Default::default() },
323 MetaPhase { label: "a".into(), detail: "2".into(), ..Default::default() },
324 ],
325 reasoning: String::new(),
326 };
327 assert!(!validate_meta(&meta, "").is_valid());
328 }
329
330 #[test]
331 fn validate_meta_zero_depends_on() {
332 let meta = PlanMeta {
333 phases: vec![
334 MetaPhase { label: "a".into(), detail: "1".into(), ..Default::default() },
335 MetaPhase { label: "b".into(), detail: "2".into(), depends_on: vec![0], ..Default::default() },
336 ],
337 reasoning: String::new(),
338 };
339 let v = validate_meta(&meta, "");
340 assert!(v.errors.iter().any(|e| e.contains("1-based")));
341 }
342
343 #[test]
344 fn validate_meta_self_dependency() {
345 let meta = PlanMeta {
346 phases: vec![MetaPhase { label: "a".into(), detail: "1".into(), depends_on: vec![1], ..Default::default() }],
347 reasoning: String::new(),
348 };
349 assert!(!validate_meta(&meta, "").is_valid());
350 }
351
352 #[test]
353 fn validate_meta_out_of_range() {
354 let meta = PlanMeta {
355 phases: vec![MetaPhase { label: "a".into(), detail: "1".into(), depends_on: vec![5], ..Default::default() }],
356 reasoning: String::new(),
357 };
358 assert!(!validate_meta(&meta, "").is_valid());
359 }
360
361 #[test]
362 fn plan_meta_serde_roundtrip() {
363 let meta = PlanMeta {
364 phases: vec![MetaPhase { label: "x".into(), detail: "y".into(), agents: 3, depends_on: vec![1, 2] }],
365 reasoning: "why".into(),
366 };
367 let json = serde_json::to_string(&meta).unwrap();
368 assert_eq!(meta, serde_json::from_str(&json).unwrap());
369 }
370}