1use std::fs;
15use std::path::Path;
16
17use serde::Serialize;
18
19use super::codes::{self, Code};
20use super::{
21 Answer, Op, add, attach, autobalance, bake, check, doctor, explain, export, import, lint,
22 modules, new, playtest, replay, run, schemas,
23};
24use crate::game::GameTools;
25use crate::project::Project;
26
27pub fn split_json(args: &[String]) -> (bool, Vec<String>) {
29 let json = args.iter().any(|a| a == "--json");
30 (
31 json,
32 args.iter().filter(|a| *a != "--json").cloned().collect(),
33 )
34}
35
36fn print<T: Serialize>(json: bool, answer: &Answer<T>, text: impl FnOnce(&T) -> String) -> u8 {
39 if json {
40 println!("{}", answer.to_json());
41 } else if answer.error.is_some() {
42 eprint!("{}", answer.to_text(text));
43 } else {
44 print!("{}", answer.to_text(text));
45 }
46 answer.exit_code()
47}
48
49fn also_written<T: Serialize>(mut answer: Answer<T>, path: &Path, file: &Path) -> Answer<T> {
53 if let Err(e) = fs::write(
54 path,
55 format!(
56 "{}
57",
58 answer.to_json()
59 ),
60 ) {
61 answer.ok = false;
62 answer.error = Some(format!("--report {}: {e}", file.display()));
63 answer = answer.with_code(codes::FILE);
64 }
65 answer
66}
67
68fn wrong(json: bool, op: Op, error: &str, usage: &str) -> u8 {
71 let answer = Answer::<()>::failed(op, codes::WRONG_ARGUMENTS, error).with_help(usage);
72 print(json, &answer, |()| String::new())
73}
74
75pub fn setup_failed(name: &str, args: &[String], code: Code, error: &str) -> u8 {
80 let (json, _) = split_json(args);
81 match Op::named(name) {
82 Some(op) if json => println!("{}", Answer::<()>::failed(op, code, error).to_json()),
83 _ => eprint!(
84 "{}",
85 codes::said(name, code.0, error, codes::help_of(code.0))
86 ),
87 }
88 codes::exit_code(Some(code.0))
89}
90
91pub fn no_game(name: &str, args: &[String]) -> u8 {
95 let error = format!(
96 "{} names no game tool program (`game`), and {name} runs the game",
97 crate::project::FILE
98 );
99 setup_failed(name, args, codes::PROJECT_FILE, &error)
100}
101
102pub fn without_project(name: &str, args: &[String], cwd: &Path, command: &str) -> Option<u8> {
106 if name == "mcp" {
107 return Some(crate::mcp::main(args, cwd));
108 }
109 let (json, args) = split_json(args);
110 Some(match name {
111 "new" => match new::parse_args(&args) {
112 Ok(input) => print(json, &new::new(cwd, &input), new::text),
113 Err(e) => wrong(json, Op::New, &e, &new::usage(command)),
114 },
115 "doctor" => {
116 if !args.is_empty() {
117 return Some(wrong(
118 json,
119 Op::Doctor,
120 "no arguments",
121 &format!("usage: {command} doctor [--json]"),
122 ));
123 }
124 let input = doctor::DoctorInput::default();
125 print(
126 json,
127 &doctor::doctor(&doctor::ThisMachine, &input),
128 doctor::text,
129 )
130 }
131 "explain" => match explain::parse_args(&args) {
132 Ok(input) => print(json, &explain::explain(&input), explain::text),
133 Err(e) => wrong(json, Op::Explain, &e, &explain::usage(command)),
134 },
135 "attach" => match attach::parse_args(&args) {
136 Ok(input) => print(json, &attach::attach(&input, cwd), attach::text),
137 Err(e) => wrong(json, Op::Attach, &e, &attach::usage(command)),
138 },
139 "schema" => schema(&args, cwd, command),
140 _ => return None,
141 })
142}
143
144fn schema(args: &[String], cwd: &Path, command: &str) -> u8 {
147 let files = schemas();
148 match args {
149 [] => {
150 for file in &files {
151 println!("{}", file.name.trim_end_matches(".json"));
152 }
153 0
154 }
155 [flag, dir] if flag == "--out" => {
156 let dir = cwd.join(dir);
157 for file in &files {
158 if let Err(e) = fs::create_dir_all(&dir)
159 .and_then(|()| fs::write(dir.join(&file.name), &file.text))
160 {
161 eprintln!("schema: {}: {e}", dir.join(&file.name).display());
162 return 1;
163 }
164 }
165 println!(
166 "schema: {} file(s) in {}, fingerprint {}",
167 files.len(),
168 dir.display(),
169 super::fingerprint(&files)
170 );
171 0
172 }
173 [name] => {
174 let file = files
175 .iter()
176 .find(|f| f.name == *name || f.name == format!("{name}.json"));
177 if let Some(file) = file {
178 print!("{}", file.text);
179 0
180 } else {
181 let help = format!("`{command} schema` lists them");
182 let error = format!("no schema {name}");
183 eprint!(
184 "{}",
185 codes::said("schema", codes::WRONG_ARGUMENTS.0, &error, Some(&help))
186 );
187 2
188 }
189 }
190 _ => {
191 let usage = format!("usage: {command} schema [<name> | --out <dir>]");
192 let error = "a schema's name, --out <dir>, or nothing";
193 eprint!(
194 "{}",
195 codes::said("schema", codes::WRONG_ARGUMENTS.0, error, Some(&usage))
196 );
197 2
198 }
199 }
200}
201
202pub fn checks_targets(name: &str, args: &[String]) -> bool {
206 name == "export" || (name == "check" && args.iter().any(|a| a == "--target"))
207}
208
209pub fn check_targets(project: &Project, args: &[String], game: Option<&dyn GameTools>) -> u8 {
212 let (json, args) = split_json(args);
213 match check::parse_args(&args) {
214 Ok(input) => print(
215 json,
216 &check::targets(&project.config, &project.root, &input, game),
217 check::text,
218 ),
219 Err(e) => wrong(json, Op::Check, &e, &check::usage(project.config.command())),
220 }
221}
222
223pub fn export(project: &Project, args: &[String], game: Option<&dyn GameTools>) -> u8 {
226 let (json, args) = split_json(args);
227 match export::parse_args(&args) {
228 Ok(input) => print(json, &export::export(project, &input, game), export::text),
229 Err(e) => wrong(
230 json,
231 Op::Export,
232 &e,
233 &export::usage(project.config.command()),
234 ),
235 }
236}
237
238pub fn lint(project: &Project, args: &[String], cargo: &dyn lint::Cargo) -> u8 {
240 let (json, args) = split_json(args);
241 if !args.is_empty() {
242 let usage = lint::usage(project.config.command());
243 return wrong(json, Op::Lint, "no arguments", &usage);
244 }
245 let input = lint::LintInput::default();
246 print(json, &lint::lint(cargo, project, &input), lint::text)
247}
248
249pub fn modules_or_add(project: &Project, name: &str, args: &[String]) -> u8 {
252 let (json, args) = split_json(args);
253 let command = project.config.command();
254 if name == "modules" {
255 if !args.is_empty() {
256 return wrong(json, Op::Modules, "no arguments", &modules::usage(command));
257 }
258 let input = modules::ModulesInput::default();
259 return print(json, &modules::modules(project, &input), modules::text);
260 }
261 match add::parse_args(&args) {
262 Ok(input) => print(json, &add::add(project, &input), add::text),
263 Err(e) => wrong(json, Op::Add, &e, &add::usage(command)),
264 }
265}
266
267fn replay_args(args: &[String]) -> Option<replay::ReplayInput> {
270 let any_stamp = args.iter().any(|a| a == "--any-stamp");
271 let records: Vec<&String> = args.iter().filter(|a| *a != "--any-stamp").collect();
272 let (record, other) = match records.as_slice() {
273 [record] => (record, None),
274 [record, other] => (record, Some((*other).clone())),
275 _ => return None,
276 };
277 if records.iter().any(|a| a.starts_with("--")) {
278 return None;
279 }
280 let mut input = replay::ReplayInput::new((*record).clone());
281 input.other = other;
282 input.any_stamp = any_stamp;
283 Some(input)
284}
285
286pub fn runs_the_game(name: &str) -> bool {
288 matches!(
289 name,
290 "check"
291 | "run"
292 | "scene"
293 | "replay"
294 | "playtest"
295 | "autobalance"
296 | "import"
297 | "bake"
298 | "export"
299 )
300}
301
302pub fn with_game(project: &Project, name: &str, args: &[String], game: &dyn GameTools) -> u8 {
305 let (json, args) = split_json(args);
306 let command = project.config.command();
307 let options = game.frame_options();
308 match name {
309 "check" => match check::parse_args(&args) {
310 Ok(input) if input.target.is_some() => print(
311 json,
312 &check::targets(&project.config, &project.root, &input, Some(game)),
313 check::text,
314 ),
315 Ok(input) => print(json, &check::check(game, &input), check::text),
316 Err(e) => wrong(json, Op::Check, &e, &check::usage(command)),
317 },
318 "export" => match export::parse_args(&args) {
319 Ok(input) => print(
320 json,
321 &export::export(project, &input, Some(game)),
322 export::text,
323 ),
324 Err(e) => wrong(json, Op::Export, &e, &export::usage(command)),
325 },
326 "bake" => match bake::parse_args(&args) {
327 Ok(input) => print(json, &bake::bake(game, project, &input), bake::text),
328 Err(e) => wrong(json, Op::Bake, &e, &bake::usage(command)),
329 },
330 "run" | "scene" => {
331 let op = if name == "run" { Op::Run } else { Op::Scene };
332 match run::parse_args(&args, options) {
333 Ok(input) if op == Op::Run => {
334 print(json, &run::run_at(game, &project.root, &input), run::text)
335 }
336 Ok(input) => print(
337 json,
338 &run::scene_at(game, &project.root, &input),
339 run::scene_output_text,
340 ),
341 Err(e) => wrong(json, op, &e, &run::usage(command, op, options)),
342 }
343 }
344 "replay" => {
345 let Some(input) = replay_args(&args) else {
346 let usage =
347 format!("usage: {command} replay <record> [<other>] [--any-stamp] [--json]");
348 return wrong(json, Op::Replay, "one or two records", &usage);
349 };
350 print(
351 json,
352 &replay::replay(game, &project.root, &input),
353 replay::text,
354 )
355 }
356 "playtest" => match playtest::parse_args(&args, options) {
357 Ok(parsed) => {
358 let answer = playtest::kept(game, &project.root, &parsed.input);
359 let answer = match &parsed.report {
360 Some(file) => also_written(answer, &project.root.join(file), file),
361 None => answer,
362 };
363 if parsed.brief {
364 print(json, &answer, |outputs| playtest::brief(outputs))
365 } else {
366 print(json, &answer, |outputs| playtest::text(outputs))
367 }
368 }
369 Err(e) => wrong(json, Op::Playtest, &e, &playtest::usage(command, options)),
370 },
371 "autobalance" => match autobalance::parse_args(&args, options) {
372 Ok(input) => print(
373 json,
374 &autobalance::autobalance(game, &input),
375 autobalance::text,
376 ),
377 Err(e) => wrong(
378 json,
379 Op::Autobalance,
380 &e,
381 &autobalance::usage(command, options),
382 ),
383 },
384 _ => import(json, &args, &game.importers(), &project.root, None, command),
385 }
386}
387
388pub fn import(
391 json: bool,
392 args: &[String],
393 importers: &import::Importers,
394 root: &Path,
395 limit: Option<&str>,
396 command: &str,
397) -> u8 {
398 match import::parse_args(args) {
399 Ok(input) => {
400 let mut answer = import::import(importers, root, &input);
401 if let Some(limit) = limit {
402 answer = answer.limited(limit);
403 }
404 print(json, &answer, import::text)
405 }
406 Err(e) => wrong(
407 json,
408 Op::Import,
409 &e,
410 &format!("usage: {command} import <file> [--kind KIND] [--print] [--json]"),
411 ),
412 }
413}
414
415#[cfg(test)]
416mod tests {
417 use std::path::PathBuf;
418
419 use super::*;
420 use crate::contract::fake::{Fake, FakeSession};
421 use crate::project::Config;
422
423 fn args(items: &[&str]) -> Vec<String> {
424 items.iter().map(|s| (*s).to_owned()).collect()
425 }
426
427 fn project(root: PathBuf) -> Project {
428 Project {
429 root,
430 config: Config::default(),
431 }
432 }
433
434 #[test]
435 fn replay_takes_one_or_two_records_and_any_stamp_anywhere() {
436 let one = replay_args(&args(&["a.ron"])).unwrap();
437 assert_eq!(one, replay::ReplayInput::new("a.ron"));
438 let two = replay_args(&args(&["--any-stamp", "a.ron", "b.ron"])).unwrap();
439 assert_eq!(
440 (two.record.as_str(), two.other.as_deref(), two.any_stamp),
441 ("a.ron", Some("b.ron"), true)
442 );
443 let after = replay_args(&args(&["a.ron", "--any-stamp"])).unwrap();
444 assert!(after.any_stamp && after.other.is_none(), "{after:?}");
445 for wrong in [
446 &[][..],
447 &["--any-stamp"],
448 &["a", "b", "c"],
449 &["a.ron", "--bogus"],
450 ] {
451 assert_eq!(replay_args(&args(wrong)), None, "{wrong:?}");
452 }
453 }
454
455 #[test]
456 fn json_is_a_flag_anywhere() {
457 assert_eq!(
458 split_json(&args(&["a", "--json", "b"])),
459 (true, args(&["a", "b"]))
460 );
461 assert_eq!(split_json(&args(&["a"])), (false, args(&["a"])));
462 }
463
464 #[test]
465 fn the_ops_that_run_the_game_are_named() {
466 for op in [
467 "check",
468 "run",
469 "scene",
470 "replay",
471 "playtest",
472 "autobalance",
473 "import",
474 "bake",
475 "export",
476 ] {
477 assert!(runs_the_game(op), "{op}");
478 }
479 for other in ["new", "doctor", "schema", "frame-png", "check-keys"] {
480 assert!(!runs_the_game(other), "{other}");
481 }
482 }
483
484 #[test]
485 fn each_op_answers_with_its_exit_code() {
486 let dir = std::env::temp_dir().join(format!("shuyi-cli-ops-{}", std::process::id()));
487 let _ = fs::remove_dir_all(&dir);
488 fs::create_dir_all(&dir).unwrap();
489 fs::write(dir.join("a.ron"), "h0\nh1").unwrap();
490 fs::write(dir.join("b.ron"), "h0\nhx").unwrap();
491 let p = project(dir.clone());
492 let game = Fake::default();
493 let code = |items: &[&str]| {
494 let (name, rest) = items.split_first().unwrap();
495 with_game(&p, name, &args(rest), &game)
496 };
497 assert_eq!(code(&["check"]), 0);
498 assert_eq!(code(&["check", "--json"]), 0);
499 assert_eq!(code(&["check", "x"]), 2);
500 assert_eq!(code(&["check", "x", "--json"]), 2);
501 assert_eq!(code(&["check", "--baked"]), 0);
502 assert_eq!(code(&["bake", "--out", "p.shuyipak"]), 0);
503 assert!(dir.join("p.shuyipak").is_file());
504 assert_eq!(code(&["bake", "--bogus"]), 2);
505 assert_eq!(code(&["run", "--script", "Enter", "--frames", "2"]), 0);
506 assert_eq!(code(&["run", "--bogus"]), 2);
507 assert_eq!(code(&["scene", "--json"]), 0);
508 assert_eq!(code(&["scene", "--frames"]), 2);
509 assert_eq!(code(&["replay", "a.ron"]), 0);
510 assert_eq!(code(&["replay", "a.ron", "b.ron"]), 1);
511 assert_eq!(code(&["replay"]), 2);
512 assert_eq!(code(&["replay", "a", "b", "c"]), 2);
513 assert_eq!(code(&["replay", "missing.ron"]), 1);
514 assert_eq!(code(&["playtest", "--tries", "3"]), 0);
515 assert_eq!(code(&["playtest", "3"]), 2);
516 assert_eq!(code(&["playtest", "--tries", "3", "--report", "r.json"]), 0);
517 let written = fs::read_to_string(dir.join("r.json")).unwrap();
518 let written: Answer<Vec<playtest::PlaytestOutput>> =
519 serde_json::from_str(&written).unwrap();
520 assert!(written.ok);
521 assert_eq!(written.result.unwrap()[0].report.entrants[0].tries.len(), 3);
522 let kept = dir.join("target/playtest-history/stage-walk+run.jsonl");
524 assert_eq!(fs::read_to_string(&kept).unwrap().lines().count(), 2);
525 assert_eq!(
526 code(&["playtest", "--tries", "3", "--brief", "--no-history"]),
527 0
528 );
529 assert_eq!(fs::read_to_string(&kept).unwrap().lines().count(), 2);
530 assert_eq!(
531 code(&["playtest", "--report", "no/such/r.json", "--json"]),
532 1
533 );
534 assert_eq!(code(&["autobalance", "--tries", "10", "--quick", "10"]), 0);
535 assert_eq!(code(&["autobalance", "--tries", "10", "--budget", "2"]), 1);
536 assert_eq!(code(&["autobalance", "3"]), 2);
537 assert_eq!(code(&["autobalance", "--quick", "0", "--json"]), 2);
538 assert_eq!(code(&["import", "a.ron"]), 1);
539 assert_eq!(code(&["import"]), 2);
540 let mut none = FakeSession::on("title", "");
542 none.scene = Err("no stage".into());
543 let blank = Fake {
544 session: Ok(none),
545 ..Fake::default()
546 };
547 assert_eq!(with_game(&p, "scene", &[], &blank), 1);
548 assert_eq!(with_game(&p, "run", &[], &blank), 0);
549 fs::remove_dir_all(&dir).unwrap();
550 }
551
552 #[test]
553 fn a_report_file_holds_the_json_the_answer_prints() {
554 let dir = std::env::temp_dir().join(format!("shuyi-cli-report-{}", std::process::id()));
555 let _ = fs::remove_dir_all(&dir);
556 fs::create_dir_all(&dir).unwrap();
557 let answer = Answer::new(Op::Playtest, true, 7u8, vec!["a limit".to_owned()]);
558 let file = Path::new("r.json");
559 assert_eq!(also_written(answer.clone(), &dir.join(file), file), answer);
560 let written = fs::read_to_string(dir.join(file)).unwrap();
561 assert_eq!(
562 written,
563 format!(
564 "{}
565",
566 answer.to_json()
567 )
568 );
569 let file = Path::new("missing/r.json");
572 let failed = also_written(answer, &dir.join(file), file);
573 assert!(!failed.ok);
574 assert_eq!(failed.result, Some(7));
575 assert_eq!(failed.code.as_deref(), Some("SH3004"));
576 assert!(
577 failed
578 .error
579 .unwrap()
580 .starts_with("--report missing/r.json: ")
581 );
582 fs::remove_dir_all(&dir).unwrap();
583 }
584
585 #[test]
586 fn check_with_a_target_runs_on_the_project_file_in_the_game_program_too() {
587 let mut p = project(PathBuf::from("."));
588 p.config = Config::parse(
589 r#"(targets: {"desktop": (frame: (800, 512), scale: integer, text_px: 16)})"#,
590 )
591 .unwrap();
592 assert_eq!(
595 with_game(&p, "check", &args(&["--target", "all"]), &Fake::default()),
596 0
597 );
598 assert_eq!(
599 check_targets(&p, &args(&["--target", "all", "--json"]), None),
600 0
601 );
602 assert_eq!(check_targets(&p, &args(&["--target", "web"]), None), 1);
603 assert_eq!(check_targets(&p, &args(&["--target"]), None), 2);
604 let game = Fake {
606 controls: Some(Ok(())),
607 ..Fake::default()
608 };
609 assert_eq!(
610 check_targets(&p, &args(&["--target", "all"]), Some(&game)),
611 0
612 );
613 assert!(checks_targets("check", &args(&["--target", "all"])));
614 assert!(!checks_targets("check", &args(&["--json"])));
615 assert!(!checks_targets("run", &args(&["--target"])));
616 }
617
618 struct QuietCargo;
620
621 impl lint::Cargo for QuietCargo {
622 fn run(&self, _root: &Path, _args: &[&str]) -> Result<lint::Ran, String> {
623 Ok(lint::Ran {
624 success: true,
625 ..lint::Ran::default()
626 })
627 }
628 }
629
630 #[test]
631 fn lint_answers_with_its_exit_code() {
632 let dir = std::env::temp_dir().join(format!("shuyi-cli-lint-{}", std::process::id()));
633 let _ = fs::remove_dir_all(&dir);
634 fs::create_dir_all(dir.join("src")).unwrap();
635 let mut p = project(dir.clone());
636 p.config.rules = Some("r".to_owned());
637 assert_eq!(lint(&p, &[], &QuietCargo), 0);
638 assert_eq!(lint(&p, &args(&["--json"]), &QuietCargo), 0);
639 assert_eq!(lint(&p, &args(&["x"]), &QuietCargo), 2);
640 fs::write(
641 dir.join("src/a.rs"),
642 "fn f() {\n let _ = Key::Space;\n}\n",
643 )
644 .unwrap();
645 p.config.check_keys = Some(crate::project::CheckKeys {
646 rust: vec!["src".to_owned()],
647 ..crate::project::CheckKeys::default()
648 });
649 assert_eq!(lint(&p, &[], &QuietCargo), 1);
650 assert_eq!(lint(&p, &args(&["--json"]), &QuietCargo), 1);
651 fs::remove_dir_all(&dir).unwrap();
652 }
653
654 #[test]
655 fn a_project_with_no_game_program_says_so() {
656 assert_eq!(no_game("check", &args(&["--json"])), 3);
657 assert_eq!(no_game("check", &[]), 3);
658 assert_eq!(no_game("frame-png", &args(&["--json"])), 3);
659 assert_eq!(
660 setup_failed("run", &[], codes::TOOLCHAIN, "cannot run cargo"),
661 3
662 );
663 }
664
665 #[test]
666 fn the_ops_without_a_project_are_new_doctor_explain_attach_and_schema() {
667 let dir = std::env::temp_dir().join(format!("shuyi-cli-free-{}", std::process::id()));
668 let _ = fs::remove_dir_all(&dir);
669 fs::create_dir_all(&dir).unwrap();
670 assert_eq!(without_project("check", &[], &dir, "shuyi"), None);
671 assert_eq!(without_project("schema", &[], &dir, "shuyi"), Some(0));
672 assert_eq!(
673 without_project("schema", &args(&["check.input"]), &dir, "shuyi"),
674 Some(0)
675 );
676 assert_eq!(
677 without_project("schema", &args(&["check.input.json"]), &dir, "shuyi"),
678 Some(0)
679 );
680 assert_eq!(
681 without_project("schema", &args(&["--out", "s"]), &dir, "shuyi"),
682 Some(0)
683 );
684 assert_eq!(fs::read_dir(dir.join("s")).unwrap().count(), 49);
685 fs::write(dir.join("f"), "").unwrap();
687 assert_eq!(
688 without_project("schema", &args(&["--out", "f"]), &dir, "shuyi"),
689 Some(1)
690 );
691 assert!(matches!(
692 without_project("doctor", &args(&["--json"]), &dir, "shuyi"),
693 Some(0 | 3)
694 ));
695 assert_eq!(
696 without_project("new", &args(&["blank", "g", "--json"]), &dir, "shuyi"),
697 Some(0)
698 );
699 assert_eq!(
700 without_project("new", &args(&["blank", "g"]), &dir, "shuyi"),
701 Some(2)
702 );
703 assert_eq!(without_project("new", &[], &dir, "shuyi"), Some(2));
704 assert_eq!(
705 without_project("doctor", &args(&["x"]), &dir, "shuyi"),
706 Some(2)
707 );
708 assert_eq!(without_project("explain", &[], &dir, "shuyi"), Some(0));
709 assert_eq!(without_project("attach", &[], &dir, "shuyi"), Some(2));
710 assert_eq!(
711 without_project("attach", &args(&["127.0.0.1:1", "--json"]), &dir, "shuyi"),
712 Some(2)
713 );
714 assert_eq!(
715 without_project("explain", &args(&["SH2003", "--json"]), &dir, "shuyi"),
716 Some(0)
717 );
718 assert_eq!(
719 without_project("explain", &args(&["SH9999"]), &dir, "shuyi"),
720 Some(2)
721 );
722 assert_eq!(
723 without_project("explain", &args(&["a", "b"]), &dir, "shuyi"),
724 Some(2)
725 );
726 assert_eq!(
727 without_project("schema", &args(&["nope"]), &dir, "shuyi"),
728 Some(2)
729 );
730 assert_eq!(
731 without_project("schema", &args(&["a", "b", "c"]), &dir, "shuyi"),
732 Some(2)
733 );
734 fs::remove_dir_all(&dir).unwrap();
735 }
736}