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