1use 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
13const 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
39pub 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(&*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
127struct 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
184fn 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
223fn 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
249fn 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
280struct 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 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
406fn 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 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 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
563fn 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 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 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
832fn run_batch(store_spec: &StoreSpec, args: &[String]) -> i32 {
836 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(&*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
913fn 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 if json {
925 println!("{t}");
926 } else {
927 println!("{t}");
928 }
929 }
930 (None, None) => {}
931 }
932 exit
933}
934
935#[allow(unused_imports)]
937use crate::machine::Machine as _Machine;