Skip to main content

determa_state/
cli.rs

1//! The standard `determa-state`  CLI (SPEC §13). A thin wrapper over the library [`Engine`]
2//! that persists state through a pluggable store (§13.1). Commands, exit codes, and
3//! the `--json` output shapes are normative.
4
5use crate::machine::parse_duration;
6use crate::runtime::{Mode, RunResult, Status, StepRecord};
7use crate::store::{open, Store, StoreData, StoreSpec};
8use crate::value::Value;
9use crate::{build_machine, load_machines, validate, Engine};
10use std::collections::BTreeMap;
11use std::io::{Read, Write};
12
13// exit codes (§13.2)
14const EXIT_OK: i32 = 0;
15const EXIT_USAGE: i32 = 2;
16const EXIT_VALIDATION: i32 = 3;
17const EXIT_NOT_FOUND: i32 = 4;
18const EXIT_FAULTED: i32 = 5;
19const EXIT_OTHER: i32 = 1;
20
21struct CmdOut {
22    exit: i32,
23    json: Option<Value>,
24    text: Option<String>,
25}
26
27impl CmdOut {
28    fn json(exit: i32, v: Value) -> CmdOut {
29        CmdOut { exit, json: Some(v), text: None }
30    }
31    fn text(exit: i32, s: String) -> CmdOut {
32        CmdOut { exit, json: None, text: Some(s) }
33    }
34    fn ok() -> CmdOut {
35        CmdOut { exit: EXIT_OK, json: None, text: None }
36    }
37}
38
39/// Entry point used by the `determa-state` binary.
40pub fn run(args: Vec<String>) -> i32 {
41    let mut store_spec = StoreSpec::File(default_store_dir());
42    let mut json = false;
43    let mut positional: Vec<String> = Vec::new();
44    let mut iter = args.into_iter().skip(1).peekable();
45    while let Some(a) = iter.next() {
46        match a.as_str() {
47            "--help" | "-h" => {
48                print_help();
49                return EXIT_OK;
50            }
51            "--version" => {
52                println!("determa-state 0.0.5 (implements Determa State spec v0.0.5)");
53                return EXIT_OK;
54            }
55            "--json" => json = true,
56            "--store" => match iter.next() {
57                Some(s) => store_spec = StoreSpec::parse(&s),
58                None => return err_exit(EXIT_USAGE, "--store requires an argument"),
59            },
60            _ if a.starts_with("--store=") => {
61                store_spec = StoreSpec::parse(&a["--store=".len()..]);
62            }
63            _ => positional.push(a),
64        }
65    }
66
67    if positional.is_empty() {
68        print_help();
69        return EXIT_USAGE;
70    }
71
72    let cmd = positional[0].as_str();
73    if cmd == "run" {
74        return run_batch(&store_spec, &positional[1..]);
75    }
76
77    let store = open(&store_spec);
78    let mut data = store.load();
79    let mut engine = build_engine(&data);
80    let out = dispatch(&mut engine, &mut data, &positional);
81    // persist (defs may have grown via `new`)
82    persist(&*store, &mut data, &engine);
83    emit(out, json)
84}
85
86fn default_store_dir() -> std::path::PathBuf {
87    if let Ok(s) = std::env::var("DETERMA_STORE") {
88        return std::path::PathBuf::from(s);
89    }
90    std::path::PathBuf::from("./.determa")
91}
92
93fn err_exit(code: i32, msg: &str) -> i32 {
94    eprintln!("{msg}");
95    code
96}
97
98fn print_help() {
99    println!(
100        "determa-state — statechart engine CLI (spec v0.0.5)\n\
101         usage: determa-state [--store <spec>] [--json] <command> [args]\n\
102         commands: validate, export, new, send, advance, env, state, list, snapshot,\n\
103                   restore, mode, inject, step, inspect, run"
104    );
105}
106
107// ---------------------------------------------------------------------------
108// engine <-> store
109
110fn build_engine(data: &StoreData) -> Engine {
111    let mut eng = Engine::new();
112    for yaml in &data.defs {
113        if let Ok(docs) = load_machines(yaml) {
114            for raw in &docs {
115                if let Ok(m) = build_machine(raw) {
116                    eng.register(m);
117                }
118            }
119        }
120    }
121    for snap in data.instances.clone() {
122        let _ = eng.restore(snap);
123    }
124    eng.clock = data.clock;
125    eng.mode = data.mode();
126    eng
127}
128
129fn persist(store: &dyn Store, data: &mut StoreData, engine: &Engine) {
130    let mut snaps = Vec::new();
131    for lv in engine.list_view() {
132        if let Ok(s) = engine.snapshot(&lv.id) {
133            snaps.push(s);
134        }
135    }
136    data.instances = snaps;
137    data.clock = engine.clock;
138    data.mode = match engine.mode {
139        Mode::Auto => "auto",
140        Mode::Manual => "manual",
141    }
142    .to_string();
143    store.save(data);
144}
145
146// ---------------------------------------------------------------------------
147// dispatch a single command
148
149fn dispatch(engine: &mut Engine, data: &mut StoreData, args: &[String]) -> CmdOut {
150    let cmd = args[0].as_str();
151    let rest = &args[1..];
152    match cmd {
153        "validate" => cmd_validate(rest),
154        "export" => cmd_export(rest),
155        "new" => cmd_new(engine, data, rest),
156        "send" => cmd_send(engine, rest, false),
157        "advance" => cmd_advance(engine, rest),
158        "env" => cmd_env(engine, rest),
159        "state" => cmd_state(engine, rest),
160        "list" => cmd_list(engine),
161        "snapshot" => cmd_snapshot(engine, rest),
162        "restore" => cmd_restore(engine, rest),
163        "mode" => cmd_mode(engine, rest),
164        "inject" => cmd_send(engine, rest, true),
165        "step" => cmd_step(engine, rest),
166        "inspect" => cmd_inspect(engine, rest),
167        "enabled" => cmd_enabled(engine, rest),
168        other => CmdOut::text(EXIT_USAGE, format!("unknown command '{other}'")),
169    }
170}
171
172// ----- helpers -----
173
174fn status_str(s: Status) -> &'static str {
175    match s {
176        Status::Active => "active",
177        Status::Faulted => "faulted",
178        Status::Terminated => "terminated",
179    }
180}
181
182fn state_json(engine: &Engine, id: &str) -> Option<Value> {
183    match engine.state_view(id) {
184        Ok(v) => Some(state_value(&v)),
185        Err(_) => None,
186    }
187}
188
189fn state_value(v: &crate::runtime::StateView) -> Value {
190    let mut m = BTreeMap::new();
191    m.insert("instance".to_string(), Value::Str(v.instance.clone()));
192    m.insert("def".to_string(), Value::Str(v.def.clone()));
193    m.insert("status".to_string(), Value::Str(status_str(v.status).to_string()));
194    m.insert("config".to_string(), Value::List(v.config.iter().map(|s| Value::Str(s.clone())).collect()));
195    m.insert("esvs".to_string(), Value::Map(v.esvs.clone()));
196    Value::Map(m)
197}
198
199fn read_file(path: &str) -> Result<String, CmdOut> {
200    std::fs::read_to_string(path).map_err(|e| CmdOut::text(EXIT_OTHER, format!("{e}")))
201}
202
203/// Parse --payload / --external / --changed style flags.
204struct Flags {
205    payload_pairs: Vec<(String, String)>,
206    payload_json: Option<String>,
207    external_pairs: Vec<(String, String)>,
208    changed_pairs: Vec<(String, String)>,
209    positional: Vec<String>,
210    steps: Option<String>,
211}
212
213fn parse_flags(args: &[String]) -> Flags {
214    let mut f = Flags {
215        payload_pairs: Vec::new(),
216        payload_json: None,
217        external_pairs: Vec::new(),
218        changed_pairs: Vec::new(),
219        positional: Vec::new(),
220        steps: None,
221    };
222    let mut i = 0;
223    while i < args.len() {
224        let a = &args[i];
225        match a.as_str() {
226            "--payload" if i + 1 < args.len() => {
227                if let Some((k, v)) = split_eq(&args[i + 1]) {
228                    f.payload_pairs.push((k, v));
229                }
230                i += 2;
231            }
232            "--payload-json" if i + 1 < args.len() => {
233                f.payload_json = Some(args[i + 1].clone());
234                i += 2;
235            }
236            "--external" if i + 1 < args.len() => {
237                if let Some((k, v)) = split_eq(&args[i + 1]) {
238                    f.external_pairs.push((k, v));
239                }
240                i += 2;
241            }
242            "--changed" if i + 1 < args.len() => {
243                for part in args[i + 1].split(',') {
244                    if let Some((k, v)) = split_eq(part) {
245                        f.changed_pairs.push((k, v));
246                    }
247                }
248                i += 2;
249            }
250            "--steps" if i + 1 < args.len() => {
251                f.steps = Some(args[i + 1].clone());
252                i += 2;
253            }
254            _ if a.starts_with("--payload=") => {
255                if let Some((k, v)) = split_eq(&a["--payload=".len()..]) {
256                    f.payload_pairs.push((k, v));
257                }
258                i += 1;
259            }
260            _ => {
261                f.positional.push(a.clone());
262                i += 1;
263            }
264        }
265    }
266    f
267}
268
269fn split_eq(s: &str) -> Option<(String, String)> {
270    let idx = s.find('=')?;
271    Some((s[..idx].to_string(), s[idx + 1..].to_string()))
272}
273
274fn coerce_str(s: &str, ty: Option<&str>) -> Value {
275    match ty {
276        Some("int") => s.parse::<i64>().map(Value::Int).unwrap_or(Value::Str(s.to_string())),
277        Some("float") => s.parse::<f64>().map(Value::Float).unwrap_or(Value::Str(s.to_string())),
278        Some("bool") => match s {
279            "true" => Value::Bool(true),
280            "false" => Value::Bool(false),
281            _ => Value::Str(s.to_string()),
282        },
283        _ => {
284            // YAML-ish scalar inference for bare values
285            if let Ok(i) = s.parse::<i64>() {
286                Value::Int(i)
287            } else if s == "true" {
288                Value::Bool(true)
289            } else if s == "false" {
290                Value::Bool(false)
291            } else if s == "null" {
292                Value::Null
293            } else {
294                Value::Str(s.to_string())
295            }
296        }
297    }
298}
299
300fn event_field_type(engine: &Engine, inst: &str, etype: &str, field: &str) -> Option<String> {
301    let inst = engine.instance(inst).ok()?;
302    let m = engine.defs.get(&(inst.def_id.clone(), inst.def_version))?;
303    m.events.get(etype).and_then(|d| d.payload.iter().find(|(n, _)| n == field).map(|(_, f)| f.ty.clone()))
304}
305
306fn esv_type(engine: &Engine, inst: &str, name: &str) -> Option<String> {
307    let inst = engine.instance(inst).ok()?;
308    let m = engine.defs.get(&(inst.def_id.clone(), inst.def_version))?;
309    m.get(m.top).esvs.iter().find(|(n, _)| n == name).map(|(_, d)| d.ty.clone())
310}
311
312fn build_payload(engine: &Engine, inst: &str, etype: &str, f: &Flags) -> Result<Value, CmdOut> {
313    if let Some(j) = &f.payload_json {
314        let v: serde_json::Value = serde_json::from_str(j)
315            .map_err(|e| CmdOut::text(EXIT_VALIDATION, format!("invalid --payload-json: {e}")))?;
316        return Ok(Value::from_json(&v));
317    }
318    if f.payload_pairs.is_empty() {
319        return Ok(Value::Null);
320    }
321    let mut m = BTreeMap::new();
322    for (k, v) in &f.payload_pairs {
323        let ty = event_field_type(engine, inst, etype, k);
324        m.insert(k.clone(), coerce_str(v, ty.as_deref()));
325    }
326    Ok(Value::Map(m))
327}
328
329// ----- commands -----
330
331fn cmd_validate(args: &[String]) -> CmdOut {
332    let path = match args.first() {
333        Some(p) => p,
334        None => return CmdOut::text(EXIT_USAGE, "validate <machine.yaml>".into()),
335    };
336    let src = match read_file(path) {
337        Ok(s) => s,
338        Err(c) => return c,
339    };
340    let docs = match load_machines(&src) {
341        Ok(d) => d,
342        Err(errs) => {
343            return validate_result(false, errs.into_iter().map(|e| (e.path, e.message)).collect());
344        }
345    };
346    let (valid, errs) = validate(&docs, &[]);
347    validate_result(valid, errs.into_iter().map(|e| (e.path, e.message)).collect())
348}
349
350fn validate_result(valid: bool, errs: Vec<(String, String)>) -> CmdOut {
351    let mut m = BTreeMap::new();
352    m.insert("valid".to_string(), Value::Bool(valid));
353    let err_list: Vec<Value> = errs
354        .into_iter()
355        .map(|(path, message)| {
356            Value::Map(
357                [("path".to_string(), Value::Str(path)), ("message".to_string(), Value::Str(message))]
358                    .into_iter()
359                    .collect(),
360            )
361        })
362        .collect();
363    m.insert("errors".to_string(), Value::List(err_list));
364    let exit = if valid { EXIT_OK } else { EXIT_VALIDATION };
365    CmdOut::json(exit, Value::Map(m))
366}
367
368fn cmd_export(args: &[String]) -> CmdOut {
369    let path = match args.iter().find(|a| !a.starts_with("--")) {
370        Some(p) => p,
371        None => return CmdOut::text(EXIT_USAGE, "export <machine.yaml>".into()),
372    };
373    let src = match read_file(path) {
374        Ok(s) => s,
375        Err(c) => return c,
376    };
377    let docs = match load_machines(&src) {
378        Ok(d) => d,
379        Err(e) => return CmdOut::text(EXIT_VALIDATION, format!("{e:?}")),
380    };
381    let m = match build_machine(&docs[0]) {
382        Ok(m) => m,
383        Err(e) => return CmdOut::text(EXIT_VALIDATION, format!("{e:?}")),
384    };
385    let mermaid = crate::export::mermaid(&m);
386    CmdOut::text(EXIT_OK, mermaid)
387}
388
389fn cmd_new(engine: &mut Engine, data: &mut StoreData, args: &[String]) -> CmdOut {
390    let f = parse_flags(args);
391    let pos = &f.positional;
392    if pos.len() < 2 {
393        return CmdOut::text(EXIT_USAGE, "new <id> <machine.yaml> [--external k=v]".into());
394    }
395    let id = &pos[0];
396    let path = &pos[1];
397    if engine.instances.contains_key(id) {
398        return CmdOut::text(EXIT_USAGE, format!("instance '{id}' already exists"));
399    }
400    let src = match read_file(path) {
401        Ok(s) => s,
402        Err(c) => return c,
403    };
404    let docs = match load_machines(&src) {
405        Ok(d) => d,
406        Err(e) => return CmdOut::text(EXIT_VALIDATION, format!("{e:?}")),
407    };
408    let (valid, errs) = validate(&docs, &[]);
409    if !valid {
410        return validate_result(false, errs.into_iter().map(|e| (e.path, e.message)).collect());
411    }
412    // register all docs, remember the root def
413    data.defs.push(src);
414    let root_def = docs[0].id.clone();
415    let root_ver = docs[0].version.unwrap_or(1);
416    for raw in &docs {
417        if let Ok(m) = build_machine(raw) {
418            engine.register(m);
419        }
420    }
421    // external esvs
422    let mut external = BTreeMap::new();
423    for (k, v) in &f.external_pairs {
424        let ty = esv_type(engine, id, k);
425        external.insert(k.clone(), coerce_str(v, ty.as_deref()));
426    }
427    if let Err(e) = engine.create_root(id, &root_def, Some(root_ver), &external) {
428        return CmdOut::text(EXIT_OTHER, format!("{e:?}"));
429    }
430    match state_json(engine, id) {
431        Some(v) => fault_exit(engine, id, CmdOut::json(EXIT_OK, v)),
432        None => CmdOut::text(EXIT_NOT_FOUND, format!("instance '{id}' not found")),
433    }
434}
435
436fn cmd_send(engine: &mut Engine, args: &[String], inject: bool) -> CmdOut {
437    let f = parse_flags(args);
438    let pos = &f.positional;
439    if pos.len() < 2 {
440        return CmdOut::text(EXIT_USAGE, "<instance> <event> [--payload k=v]".into());
441    }
442    let id = &pos[0];
443    let etype = &pos[1];
444    if !engine.instances.contains_key(id) {
445        return CmdOut::text(EXIT_NOT_FOUND, format!("instance '{id}' not found"));
446    }
447    let payload = match build_payload(engine, id, etype, &f) {
448        Ok(p) => p,
449        Err(c) => return c,
450    };
451    let mut run_result: Option<RunResult> = None;
452    if inject {
453        let accepted = match engine.inject(id, etype, payload) {
454            Ok(a) => a,
455            Err(e) => return CmdOut::text(EXIT_OTHER, format!("{e:?}")),
456        };
457        if !accepted {
458            return CmdOut::text(EXIT_VALIDATION, format!("event '{etype}' rejected"));
459        }
460    } else {
461        let accepted = engine.validate_event(id, etype, &payload).is_ok();
462        if !accepted {
463            return CmdOut::text(EXIT_VALIDATION, format!("event '{etype}' rejected"));
464        }
465        let run = match engine.send(id, etype, payload) {
466            Ok(r) => r,
467            Err(e) => return CmdOut::text(EXIT_OTHER, format!("{e:?}")),
468        };
469        run_result = Some(run);
470    }
471    let mut v = match state_json(engine, id) {
472        Some(v) => v,
473        None => return CmdOut::text(EXIT_NOT_FOUND, format!("instance '{id}' not found")),
474    };
475    if !inject {
476        if let Value::Map(m) = &mut v {
477            let published = run_result
478                .map(|r| r.published.into_iter().map(Value::Str).collect::<Vec<_>>())
479                .unwrap_or_default();
480            m.insert("published".to_string(), Value::List(published));
481        }
482    }
483    fault_exit(engine, id, CmdOut::json(EXIT_OK, v))
484}
485
486/// If the addressed instance ended up faulted, override the exit code to 5.
487fn fault_exit(engine: &Engine, id: &str, out: CmdOut) -> CmdOut {
488    if let Ok(inst) = engine.instance(id) {
489        if inst.status == Status::Faulted && out.exit == EXIT_OK {
490            return CmdOut { exit: EXIT_FAULTED, ..out };
491        }
492    }
493    out
494}
495
496fn cmd_advance(engine: &mut Engine, args: &[String]) -> CmdOut {
497    let dur = match args.first() {
498        Some(d) => d,
499        None => return CmdOut::text(EXIT_USAGE, "advance <duration>".into()),
500    };
501    let ms = match parse_duration(dur) {
502        Ok(m) => m,
503        Err(e) => return CmdOut::text(EXIT_USAGE, e),
504    };
505    if let Err(e) = engine.advance(ms) {
506        return CmdOut::text(EXIT_OTHER, format!("{e:?}"));
507    }
508    // print root state if present, else empty object
509    if engine.instances.contains_key("root") {
510        match state_json(engine, "root") {
511            Some(v) => return CmdOut::json(EXIT_OK, v),
512            None => {}
513        }
514    }
515    CmdOut::json(EXIT_OK, Value::Map(BTreeMap::new()))
516}
517
518fn cmd_env(engine: &mut Engine, args: &[String]) -> CmdOut {
519    let f = parse_flags(args);
520    let id = match f.positional.first() {
521        Some(i) => i,
522        None => return CmdOut::text(EXIT_USAGE, "env <instance> --changed k=v,...".into()),
523    };
524    if !engine.instances.contains_key(id) {
525        return CmdOut::text(EXIT_NOT_FOUND, format!("instance '{id}' not found"));
526    }
527    let mut changed = BTreeMap::new();
528    for (k, v) in &f.changed_pairs {
529        let ty = esv_type(engine, id, k);
530        changed.insert(k.clone(), coerce_str(v, ty.as_deref()));
531    }
532    if let Err(e) = engine.env_change(id, changed) {
533        return CmdOut::text(EXIT_OTHER, format!("{e:?}"));
534    }
535    match state_json(engine, id) {
536        Some(v) => fault_exit(engine, id, CmdOut::json(EXIT_OK, v)),
537        None => CmdOut::text(EXIT_NOT_FOUND, format!("instance '{id}' not found")),
538    }
539}
540
541fn cmd_state(engine: &Engine, args: &[String]) -> CmdOut {
542    let id = match args.first() {
543        Some(i) => i,
544        None => return CmdOut::text(EXIT_USAGE, "state <instance>".into()),
545    };
546    match state_json(engine, id) {
547        Some(v) => CmdOut::json(EXIT_OK, v),
548        None => CmdOut::text(EXIT_NOT_FOUND, format!("instance '{id}' not found")),
549    }
550}
551
552fn cmd_list(engine: &Engine) -> CmdOut {
553    let list: Vec<Value> = engine
554        .list_view()
555        .into_iter()
556        .map(|lv| {
557            let mut m = BTreeMap::new();
558            m.insert("id".to_string(), Value::Str(lv.id));
559            m.insert("def".to_string(), Value::Str(lv.def));
560            m.insert("parent".to_string(), lv.parent.map(Value::Str).unwrap_or(Value::Null));
561            m.insert("status".to_string(), Value::Str(status_str(lv.status).to_string()));
562            m.insert("config".to_string(), Value::List(lv.config.into_iter().map(Value::Str).collect()));
563            Value::Map(m)
564        })
565        .collect();
566    CmdOut::json(EXIT_OK, Value::List(list))
567}
568
569fn cmd_snapshot(engine: &Engine, args: &[String]) -> CmdOut {
570    let id = match args.first() {
571        Some(i) => i,
572        None => return CmdOut::text(EXIT_USAGE, "snapshot <instance>".into()),
573    };
574    match engine.snapshot(id) {
575        Ok(s) => {
576            let json = serde_json::to_string(&s).unwrap_or_default();
577            let v: serde_json::Value = serde_json::from_str(&json).unwrap_or_default();
578            CmdOut::json(EXIT_OK, Value::from_json(&v))
579        }
580        Err(e) => CmdOut::text(EXIT_NOT_FOUND, format!("{e:?}")),
581    }
582}
583
584fn cmd_restore(engine: &mut Engine, args: &[String]) -> CmdOut {
585    let path = match args.first() {
586        Some(p) => p,
587        None => return CmdOut::text(EXIT_USAGE, "restore <snapshot.json>".into()),
588    };
589    let src = match read_file(path) {
590        Ok(s) => s,
591        Err(c) => return c,
592    };
593    let v: serde_json::Value = match serde_json::from_str(&src) {
594        Ok(v) => v,
595        Err(e) => return CmdOut::text(EXIT_VALIDATION, format!("invalid snapshot: {e}")),
596    };
597    // snapshot must reference a registered def
598    let def_id = v.get("def_id").and_then(|x| x.as_str()).unwrap_or("").to_string();
599    let ver = v.get("def_version").and_then(|x| x.as_i64()).unwrap_or(1);
600    if !engine.defs.contains_key(&(def_id.clone(), ver)) {
601        return CmdOut::text(EXIT_NOT_FOUND, format!("definition '{def_id}@{ver}' not registered"));
602    }
603    let snap: crate::runtime::Snapshot = serde_json::from_value(v).unwrap();
604    let id = snap.id.clone();
605    if let Err(e) = engine.restore(snap) {
606        return CmdOut::text(EXIT_OTHER, format!("{e:?}"));
607    }
608    match state_json(engine, &id) {
609        Some(v) => CmdOut::json(EXIT_OK, v),
610        None => CmdOut::ok(),
611    }
612}
613
614fn cmd_mode(engine: &mut Engine, args: &[String]) -> CmdOut {
615    if let Some(m) = args.first() {
616        let mode = match m.as_str() {
617            "auto" => Mode::Auto,
618            "manual" => Mode::Manual,
619            _ => return CmdOut::text(EXIT_USAGE, "mode [auto|manual]".into()),
620        };
621        engine.set_mode(mode);
622    }
623    let mut map = BTreeMap::new();
624    map.insert(
625        "mode".to_string(),
626        Value::Str(if engine.mode == Mode::Manual { "manual" } else { "auto" }.to_string()),
627    );
628    CmdOut::json(EXIT_OK, Value::Map(map))
629}
630
631fn cmd_step(engine: &mut Engine, args: &[String]) -> CmdOut {
632    let f = parse_flags(args);
633    let id = match f.positional.first() {
634        Some(i) => i,
635        None => return CmdOut::text(EXIT_USAGE, "step <instance> [--steps N]".into()),
636    };
637    if !engine.instances.contains_key(id) {
638        return CmdOut::text(EXIT_NOT_FOUND, format!("instance '{id}' not found"));
639    }
640    let n: usize = f
641        .steps
642        .as_deref()
643        .and_then(|s| s.parse().ok())
644        .unwrap_or(1);
645    let records = match engine.step(id, n) {
646        Ok(r) => r,
647        Err(e) => return CmdOut::text(EXIT_OTHER, format!("{e:?}")),
648    };
649    let mut v = match state_json(engine, id) {
650        Some(v) => v,
651        None => return CmdOut::text(EXIT_NOT_FOUND, format!("instance '{id}' not found")),
652    };
653    let steps: Vec<Value> = records.iter().map(step_record_value).collect();
654    if let Value::Map(m) = &mut v {
655        m.insert("steps".to_string(), Value::List(steps));
656    }
657    fault_exit(engine, id, CmdOut::json(EXIT_OK, v))
658}
659
660fn step_record_value(r: &StepRecord) -> Value {
661    let mut m = BTreeMap::new();
662    m.insert("event".to_string(), Value::Str(r.event.clone()));
663    m.insert(
664        "transition".to_string(),
665        r.transition.clone().map(Value::Str).unwrap_or(Value::Null),
666    );
667    m.insert("entered".to_string(), Value::List(r.entered.iter().map(|s| Value::Str(s.clone())).collect()));
668    m.insert("exited".to_string(), Value::List(r.exited.iter().map(|s| Value::Str(s.clone())).collect()));
669    m.insert("published".to_string(), Value::List(r.published.iter().map(|s| Value::Str(s.clone())).collect()));
670    m.insert("spawned".to_string(), Value::List(r.spawned.iter().map(|s| Value::Str(s.clone())).collect()));
671    m.insert("faulted".to_string(), Value::Bool(r.faulted));
672    Value::Map(m)
673}
674
675fn cmd_inspect(engine: &Engine, args: &[String]) -> CmdOut {
676    let id = match args.first() {
677        Some(i) => i,
678        None => return CmdOut::text(EXIT_USAGE, "inspect <instance>".into()),
679    };
680    let v = match engine.inspect_view(id) {
681        Ok(v) => v,
682        Err(e) => return CmdOut::text(EXIT_NOT_FOUND, format!("{e:?}")),
683    };
684    let mut m = BTreeMap::new();
685    m.insert("instance".to_string(), Value::Str(v.instance));
686    m.insert("status".to_string(), Value::Str(status_str(v.status).to_string()));
687    m.insert("config".to_string(), Value::List(v.config.into_iter().map(Value::Str).collect()));
688    m.insert("esvs".to_string(), Value::Map(v.esvs));
689    m.insert(
690        "enabled".to_string(),
691        Value::List(v.enabled.into_iter().map(Value::Str).collect()),
692    );
693    m.insert(
694        "queue".to_string(),
695        Value::List(v.queue.into_iter().map(event_obj).collect()),
696    );
697    m.insert(
698        "deferred".to_string(),
699        Value::List(v.deferred.into_iter().map(event_obj).collect()),
700    );
701    m.insert(
702        "timers".to_string(),
703        Value::List(
704            v.timers
705                .into_iter()
706                .map(|t| {
707                    Value::Map(
708                        [("state".to_string(), Value::Str(t.state.to_string())), ("due".to_string(), Value::Int(t.due as i64))]
709                            .into_iter()
710                            .collect(),
711                    )
712                })
713                .collect(),
714        ),
715    );
716    m.insert(
717        "history".to_string(),
718        Value::Map(v.history.into_iter().map(|(k, v)| (k, Value::Str(v))).collect()),
719    );
720    m.insert(
721        "dead_letter".to_string(),
722        Value::List(v.dead_letter.into_iter().map(|d| Value::Str(d.event.etype)).collect()),
723    );
724    CmdOut::json(EXIT_OK, Value::Map(m))
725}
726
727fn cmd_enabled(engine: &Engine, args: &[String]) -> CmdOut {
728    let id = match args.first() {
729        Some(i) => i,
730        None => return CmdOut::text(EXIT_USAGE, "enabled <instance>".into()),
731    };
732    match engine.enabled_events(id) {
733        Ok(enabled) => {
734            let mut m = BTreeMap::new();
735            m.insert("instance".to_string(), Value::Str(id.to_string()));
736            m.insert(
737                "enabled".to_string(),
738                Value::List(enabled.into_iter().map(Value::Str).collect()),
739            );
740            CmdOut::json(EXIT_OK, Value::Map(m))
741        }
742        Err(e) => CmdOut::text(EXIT_NOT_FOUND, format!("{e:?}")),
743    }
744}
745
746fn event_obj(e: crate::runtime::QueuedEvent) -> Value {
747    let mut m = BTreeMap::new();
748    m.insert("type".to_string(), Value::Str(e.etype));
749    if !matches!(e.payload, Value::Null) {
750        m.insert("payload".to_string(), e.payload);
751    }
752    Value::Map(m)
753}
754
755// ---------------------------------------------------------------------------
756// batch / streaming mode (§13.7)
757
758fn run_batch(store_spec: &StoreSpec, args: &[String]) -> i32 {
759    // `run -` reads NDJSON argv lines from stdin; `-` optional.
760    let _ = args;
761    let store = open(store_spec);
762    let mut data = store.load();
763    let mut engine = build_engine(&data);
764    let mut first_nonzero = 0;
765
766    let mut stdin = String::new();
767    if std::io::stdin().read_to_string(&mut stdin).is_err() {
768        return EXIT_OTHER;
769    }
770    let stdout = std::io::stdout();
771    let mut out = stdout.lock();
772
773    for line in stdin.lines() {
774        if line.trim().is_empty() {
775            continue;
776        }
777        let argv: Vec<String> = match serde_json::from_str(line) {
778            Ok(v) => v,
779            Err(_) => {
780                let _ = write_line(&mut out, batch_obj(false, EXIT_USAGE, Value::Null, "invalid NDJSON line"));
781                if first_nonzero == 0 {
782                    first_nonzero = EXIT_USAGE;
783                }
784                continue;
785            }
786        };
787        if argv.is_empty() {
788            continue;
789        }
790        let r = dispatch(&mut engine, &mut data, &argv);
791        let ok = r.exit == EXIT_OK;
792        let err_text = r.text.clone().unwrap_or_default();
793        let result = if let Some(j) = r.json {
794            j
795        } else if let Some(t) = r.text {
796            Value::Str(t)
797        } else {
798            Value::Null
799        };
800        let obj = if ok {
801            batch_obj(true, EXIT_OK, result, "")
802        } else {
803            batch_obj(false, r.exit, Value::Null, &err_text)
804        };
805        let _ = write_line(&mut out, obj);
806        if r.exit != EXIT_OK && first_nonzero == 0 {
807            first_nonzero = r.exit;
808        }
809        let _ = result;
810    }
811    // persist final state
812    persist(&*store, &mut data, &engine);
813    let _ = out.flush();
814    first_nonzero
815}
816
817fn batch_obj(ok: bool, exit: i32, result: Value, err: &str) -> String {
818    let mut m = BTreeMap::new();
819    m.insert("ok".to_string(), Value::Bool(ok));
820    m.insert("exit".to_string(), Value::Int(exit as i64));
821    m.insert("result".to_string(), result);
822    if !ok {
823        let mut e = BTreeMap::new();
824        e.insert("message".to_string(), Value::Str(err.to_string()));
825        m.insert("error".to_string(), Value::Map(e));
826    } else {
827        m.insert("error".to_string(), Value::Null);
828    }
829    serde_json::to_string(&Value::Map(m).to_json()).unwrap_or_default()
830}
831
832fn write_line<W: Write>(w: &mut W, s: String) -> std::io::Result<()> {
833    writeln!(w, "{s}")
834}
835
836// ---------------------------------------------------------------------------
837// emit
838
839fn emit(out: CmdOut, json: bool) -> i32 {
840    let exit = out.exit;
841    match (out.json, out.text) {
842        (Some(v), _) => {
843            println!("{}", serde_json::to_string_pretty(&v.to_json()).unwrap_or_default());
844        }
845        (None, Some(t)) => {
846            // for validate (which has both json shape and text fallback) prefer json
847            if json {
848                println!("{t}");
849            } else {
850                println!("{t}");
851            }
852        }
853        (None, None) => {}
854    }
855    exit
856}
857
858// silence unused warning for RunResult import path
859#[allow(unused_imports)]
860use crate::machine::Machine as _Machine;