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        _ => 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
109/// Register no-op stubs so accidental top-level SDK calls don't crash extraction.
110fn 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
208/// Validate the extracted meta.
209pub 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}