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 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(&*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
107fn 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
146fn 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
172fn 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
203struct 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 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
329fn 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 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 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
486fn 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 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 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
755fn run_batch(store_spec: &StoreSpec, args: &[String]) -> i32 {
759 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(&*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
836fn 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 if json {
848 println!("{t}");
849 } else {
850 println!("{t}");
851 }
852 }
853 (None, None) => {}
854 }
855 exit
856}
857
858#[allow(unused_imports)]
860use crate::machine::Machine as _Machine;