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 _ => {
91 return Err(ScriptError::Internal(
92 "meta.phases[*] must be a table".into(),
93 ))
94 }
95 };
96 let label: String = table
97 .get("label")
98 .map_err(|e| ScriptError::Internal(format!("meta.phases[*].label: {e}")))?;
99 let detail: String = table
100 .get("detail")
101 .map_err(|e| ScriptError::Internal(format!("meta.phases[*].detail: {e}")))?;
102 let agents: usize = table
103 .get::<Option<usize>>("agents")
104 .map_err(|e| ScriptError::Internal(format!("meta.phases[*].agents: {e}")))?
105 .unwrap_or(0);
106 let depends_on: Vec<u32> = table
107 .get::<Option<Vec<u32>>>("depends_on")
108 .map_err(|e| ScriptError::Internal(format!("meta.phases[*].depends_on: {e}")))?
109 .unwrap_or_default();
110 Ok(MetaPhase {
111 label,
112 detail,
113 agents,
114 depends_on,
115 })
116}
117
118fn register_stubs(lua: &Lua) -> mlua::Result<()> {
120 let globals = lua.globals();
121 let phase = lua.create_function(|_, _: mlua::MultiValue| Ok(0u32))?;
122 globals.set("phase", phase)?;
123 let report = lua.create_function(|_, _: mlua::MultiValue| Ok(Value::Nil))?;
124 globals.set("report", report)?;
125 let log = lua.create_function(|_, _: mlua::MultiValue| Ok(Value::Nil))?;
126 globals.set("log", log)?;
127 let budget = lua.create_function(|_, _: mlua::MultiValue| Ok(Value::Nil))?;
128 globals.set("budget", budget)?;
129 let agent =
130 lua.create_function(|lua, _: mlua::MultiValue| Ok(Value::Table(lua.create_table()?)))?;
131 globals.set("agent", agent)?;
132 let parallel =
133 lua.create_function(|lua, _: mlua::MultiValue| Ok(Value::Table(lua.create_table()?)))?;
134 globals.set("parallel", parallel)?;
135 let pipeline =
136 lua.create_function(|lua, _: mlua::MultiValue| Ok(Value::Table(lua.create_table()?)))?;
137 globals.set("pipeline", pipeline)?;
138 let workflow =
139 lua.create_function(|lua, _: mlua::MultiValue| Ok(Value::Table(lua.create_table()?)))?;
140 globals.set("workflow", workflow)?;
141
142 let json_table = lua.create_table()?;
143 let encode = lua.create_function(|_, v: Value| {
144 Ok(serde_json::to_string(&lua_value_to_json(&v)).unwrap_or_default())
145 })?;
146 let decode = lua.create_function(|lua, s: String| {
147 match serde_json::from_str::<serde_json::Value>(&s) {
148 Ok(v) => json_to_lua_value(lua, &v),
149 Err(_) => Ok(Value::Nil),
150 }
151 })?;
152 json_table.set("encode", encode)?;
153 json_table.set("decode", decode)?;
154 globals.set("json", json_table)?;
155
156 globals.set("args", lua.create_table()?)?;
157 let ctx = lua.create_table()?;
158 ctx.set("run_id", "")?;
159 globals.set("ctx", ctx)?;
160 Ok(())
161}
162
163fn lua_value_to_json(v: &Value) -> serde_json::Value {
164 match v {
165 Value::Nil => serde_json::Value::Null,
166 Value::Boolean(b) => serde_json::Value::Bool(*b),
167 Value::Integer(i) => serde_json::json!(i),
168 Value::Number(n) => serde_json::json!(n),
169 Value::String(s) => serde_json::Value::String(s.to_string_lossy()),
170 Value::Table(t) => {
171 let len = t.raw_len();
172 if len > 0 {
173 let mut arr = Vec::with_capacity(len);
174 for i in 1..=len {
175 match t.raw_get::<Value>(i) {
176 Ok(v) => arr.push(lua_value_to_json(&v)),
177 Err(_) => break,
178 }
179 }
180 if arr.len() == len {
181 return serde_json::Value::Array(arr);
182 }
183 }
184 let mut map = serde_json::Map::new();
185 for pair in t.pairs::<Value, Value>().flatten() {
186 if let Value::String(k) = pair.0 {
187 map.insert(k.to_string_lossy(), lua_value_to_json(&pair.1));
188 }
189 }
190 serde_json::Value::Object(map)
191 }
192 _ => serde_json::Value::Null,
193 }
194}
195
196fn json_to_lua_value(lua: &Lua, v: &serde_json::Value) -> mlua::Result<Value> {
197 match v {
198 serde_json::Value::Null => Ok(Value::Nil),
199 serde_json::Value::Bool(b) => Ok(Value::Boolean(*b)),
200 serde_json::Value::Number(n) => {
201 if let Some(i) = n.as_i64() {
202 Ok(Value::Integer(i))
203 } else if let Some(f) = n.as_f64() {
204 Ok(Value::Number(f))
205 } else {
206 Ok(Value::Nil)
207 }
208 }
209 serde_json::Value::String(s) => Ok(Value::String(lua.create_string(s)?)),
210 serde_json::Value::Array(arr) => {
211 let t = lua.create_table()?;
212 for (i, item) in arr.iter().enumerate() {
213 t.raw_set(i + 1, json_to_lua_value(lua, item)?)?;
214 }
215 Ok(Value::Table(t))
216 }
217 serde_json::Value::Object(map) => {
218 let t = lua.create_table()?;
219 for (k, val) in map {
220 t.set(k.as_str(), json_to_lua_value(lua, val)?)?;
221 }
222 Ok(Value::Table(t))
223 }
224 }
225}
226
227pub fn validate_meta(meta: &PlanMeta, script: &str) -> MetaValidation {
229 let mut out = MetaValidation::default();
230
231 if meta.phases.is_empty() {
232 out.warnings
233 .push("meta.phases is empty; progress display will show nothing".into());
234 }
235
236 let mut seen_labels = std::collections::HashSet::new();
237 for (idx, phase) in meta.phases.iter().enumerate() {
238 if phase.label.trim().is_empty() {
239 out.errors
240 .push(format!("meta.phases[{}].label is empty", idx));
241 }
242 if !seen_labels.insert(phase.label.as_str()) {
243 out.errors
244 .push(format!("duplicate phase label: '{}'", phase.label));
245 }
246 for dep in &phase.depends_on {
247 if *dep == 0 {
248 out.errors.push(format!(
249 "meta.phases[{}].depends_on uses 0; must be 1-based",
250 idx
251 ));
252 continue;
253 }
254 let dep_idx = (*dep as usize).saturating_sub(1);
255 if dep_idx >= meta.phases.len() {
256 out.errors.push(format!(
257 "meta.phases[{}].depends_on={} out of range (have {} phases)",
258 idx,
259 dep,
260 meta.phases.len()
261 ));
262 } else if dep_idx == idx {
263 out.errors
264 .push(format!("meta.phases[{}] depends on itself", idx));
265 }
266 }
267 }
268
269 if !meta.phases.is_empty() {
270 for phase in &meta.phases {
271 let needle1 = format!("phase(\"{}\"", phase.label);
272 let needle2 = format!("phase('{}'", phase.label);
273 if !script.contains(&needle1) && !script.contains(&needle2) {
274 out.warnings.push(format!(
275 "phase label '{}' has no matching phase(...) call in script",
276 phase.label
277 ));
278 }
279 }
280 }
281
282 out
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 #[test]
290 fn extract_meta_full() {
291 let script = r#"
292meta = {
293 phases = {
294 { label = "discovery", detail = "find files", agents = 1, depends_on = {} },
295 { label = "analysis", detail = "analyze", agents = 3, depends_on = { 1 } }
296 },
297 reasoning = "two-stage pipeline"
298}
299function main() report({ ok = true }) end
300"#;
301 let meta = extract_meta(script).unwrap().unwrap();
302 assert_eq!(meta.phases.len(), 2);
303 assert_eq!(meta.phases[0].label, "discovery");
304 assert_eq!(meta.phases[1].depends_on, vec![1]);
305 assert_eq!(meta.reasoning, "two-stage pipeline");
306 }
307
308 #[test]
309 fn extract_meta_missing() {
310 assert!(extract_meta("report({ ok = true })").unwrap().is_none());
311 }
312
313 #[test]
314 fn extract_meta_not_a_table() {
315 assert!(extract_meta("meta = \"hello\"\nreport({})")
316 .unwrap()
317 .is_none());
318 }
319
320 #[test]
321 fn extract_meta_default_values() {
322 let script =
323 "meta = { phases = { { label = 'p', detail = 'd' } } }\nfunction main() report({}) end";
324 let meta = extract_meta(script).unwrap().unwrap();
325 assert_eq!(meta.phases[0].agents, 0);
326 assert!(meta.phases[0].depends_on.is_empty());
327 }
328
329 #[test]
330 fn extract_meta_top_level_phase_safe() {
331 let script = "meta = { phases = { { label = 'x', detail = 'y' } } }\nphase('x', 1)\nfunction main() report({}) end";
332 let meta = extract_meta(script).unwrap().unwrap();
333 assert_eq!(meta.phases[0].label, "x");
334 }
335
336 #[test]
337 fn validate_meta_ok() {
338 let meta = PlanMeta {
339 phases: vec![
340 MetaPhase {
341 label: "a".into(),
342 detail: "1".into(),
343 agents: 1,
344 depends_on: vec![],
345 },
346 MetaPhase {
347 label: "b".into(),
348 detail: "2".into(),
349 agents: 2,
350 depends_on: vec![1],
351 },
352 ],
353 reasoning: String::new(),
354 };
355 let v = validate_meta(&meta, "phase(\"a\", 1); phase(\"b\", 2)");
356 assert!(v.is_valid());
357 }
358
359 #[test]
360 fn validate_meta_duplicate_label() {
361 let meta = PlanMeta {
362 phases: vec![
363 MetaPhase {
364 label: "a".into(),
365 detail: "1".into(),
366 ..Default::default()
367 },
368 MetaPhase {
369 label: "a".into(),
370 detail: "2".into(),
371 ..Default::default()
372 },
373 ],
374 reasoning: String::new(),
375 };
376 assert!(!validate_meta(&meta, "").is_valid());
377 }
378
379 #[test]
380 fn validate_meta_zero_depends_on() {
381 let meta = PlanMeta {
382 phases: vec![
383 MetaPhase {
384 label: "a".into(),
385 detail: "1".into(),
386 ..Default::default()
387 },
388 MetaPhase {
389 label: "b".into(),
390 detail: "2".into(),
391 depends_on: vec![0],
392 ..Default::default()
393 },
394 ],
395 reasoning: String::new(),
396 };
397 let v = validate_meta(&meta, "");
398 assert!(v.errors.iter().any(|e| e.contains("1-based")));
399 }
400
401 #[test]
402 fn validate_meta_self_dependency() {
403 let meta = PlanMeta {
404 phases: vec![MetaPhase {
405 label: "a".into(),
406 detail: "1".into(),
407 depends_on: vec![1],
408 ..Default::default()
409 }],
410 reasoning: String::new(),
411 };
412 assert!(!validate_meta(&meta, "").is_valid());
413 }
414
415 #[test]
416 fn validate_meta_out_of_range() {
417 let meta = PlanMeta {
418 phases: vec![MetaPhase {
419 label: "a".into(),
420 detail: "1".into(),
421 depends_on: vec![5],
422 ..Default::default()
423 }],
424 reasoning: String::new(),
425 };
426 assert!(!validate_meta(&meta, "").is_valid());
427 }
428
429 #[test]
430 fn plan_meta_serde_roundtrip() {
431 let meta = PlanMeta {
432 phases: vec![MetaPhase {
433 label: "x".into(),
434 detail: "y".into(),
435 agents: 3,
436 depends_on: vec![1, 2],
437 }],
438 reasoning: "why".into(),
439 };
440 let json = serde_json::to_string(&meta).unwrap();
441 assert_eq!(meta, serde_json::from_str(&json).unwrap());
442 }
443}