Skip to main content

shuyi_cli/contract/
cli.rs

1//! The contract on the command line: `shuyi <op> [args] [--json]`. Each
2//! op's arguments become its input, its answer is printed for people or,
3//! with `--json`, as one JSON document ([`Answer::to_json`]). An error
4//! carries a problem code ([`codes`]), and the exit code is 0 if it
5//! passed, 1 if not, 2 for wrong arguments (`SH0nnn`), and 3 for a setup
6//! to fix before running it again (`SH1nnn`: no project, a project file
7//! that lacks what it needs, the toolchain).
8//!
9//! `new`, `doctor`, `explain`, `attach` and `schema` need no project. `lint`,
10//! `modules` and `add` run on the project, not its game. The others run on
11//! the project's game, in its tool program; `import` without one reads
12//! only the engine's standard kinds.
13
14use 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
27/// Whether `args` ask for JSON, and the rest of them.
28pub 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
36/// Prints `answer` as `json` says, `text` writing its result for people,
37/// and returns its exit code.
38fn 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
49/// `answer`, also written to `path` as the JSON `--json` prints. If it
50/// can't be, the answer keeps its result but fails with [`codes::FILE`],
51/// naming the file as given, `file`.
52fn 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
68/// Says that `op`'s arguments are wrong (`error`), with its `usage` as
69/// the help, as `json` says; the exit code is 2 ([`codes::WRONG_ARGUMENTS`]).
70fn 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
75/// Says that command `name` can't run with `args`: its setup is wrong
76/// (`error`, of setup code `code`). An op with `--json` answers a failed
77/// [`Answer`]; anything else says it for people. Returns the code's exit
78/// code, 3.
79pub 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
91/// Says that command `name` (one that runs the game) can't run with
92/// `args`: the project names no game tool program
93/// ([`codes::PROJECT_FILE`]). The exit code is 3.
94pub 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
102/// Runs `name` with `args` if it is an op that needs no project (`new`,
103/// `doctor`, `explain`, `attach`, `schema`, and `mcp`, which finds a project per
104/// call), in the working folder `cwd`; `None` if it isn't.
105pub 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
144/// `shuyi schema`: the schemas' names; `schema <name>`, one of them;
145/// `schema --out <dir>`, every one written into `dir`.
146fn 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
202/// Whether op `name` with `args` checks targets (`check --target`, and
203/// `export`, which starts with that check), which runs on the project
204/// file alone, without the game's tools.
205pub fn checks_targets(name: &str, args: &[String]) -> bool {
206    name == "export" || (name == "check" && args.iter().any(|a| a == "--target"))
207}
208
209/// `check --target` with `args` on `project`, with the game's tools
210/// `game` if they are at hand.
211pub 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
223/// `export` with `args` on `project`, with the game's tools `game` if
224/// they are at hand (for its check's controls).
225pub 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
238/// `lint` with `args` on `project`, running cargo with `cargo`.
239pub 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
249/// `modules` or `add` (`name`) with `args` on `project`: on its files,
250/// not its game.
251pub 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
267/// `replay`'s input from `args` (without `--json`): one or two records,
268/// and `--any-stamp` anywhere; `None` for anything else.
269fn 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
286/// Whether `name` is an op that runs on the project's game.
287pub 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
302/// Runs op `name` (one that [`runs_the_game`]) with `args` on `project`'s
303/// `game`.
304pub 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
388/// `import` with `args` (no `--json`) by `importers`, from `root`, with
389/// `limit` added to its answer if it is given.
390pub 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        // Each run was kept in the project's history; a brief one isn't.
523        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        // A scene that isn't there fails the op, not its arguments.
541        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        // A file that can't be written fails the answer, which keeps its
570        // result.
571        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        // The fake game offers no controls: an answer of 0 is the
593        // project's.
594        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        // With its controls, a warning: its title declares nothing.
605        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    /// A cargo that runs and finds nothing.
619    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        // A file where the folder should be.
686        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}