Skip to main content

luft_planner/
meta.rs

1//! `planner::meta` — Declarative workflow metadata extraction.
2//!
3//! Extracts the `meta = { phases = {...}, reasoning = "..." }` table from a
4//! Lua workflow script without running `main()`.
5
6use luft_runtime::ScriptError;
7use mlua::{Lua, Table, Value};
8use serde::{Deserialize, Serialize};
9
10/// Declarative phase description for progress display and tracking.
11#[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/// Single phase in a workflow plan. `depends_on` uses 1-based indices.
19#[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/// Result of post-extraction validation.
30#[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
42/// Extract the `meta` table from a Lua workflow script.
43///
44/// Returns `None` if the script has no `meta` global or if it's not a table.
45/// Extraction is side-effect-free: `main()` is never called.
46pub 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
118/// Register no-op stubs so accidental top-level SDK calls don't crash extraction.
119fn 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
227/// Validate the extracted meta.
228pub 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}