Skip to main content

shuyi_cli/contract/
new.rs

1//! `new <dir>`: a new game in `dir`, from the engine's template,
2//! `templates/shuyi-default`, made of parts (ADR-0013 §3,
3//! [`crate::template`]). It is embedded in the `shuyi` binary, so `new`
4//! needs no checkout of the engine.
5//!
6//! The answers pick the parts: a short name of the template's
7//! (`--template grid`, `--template blank`), then an answers file
8//! (`--answers answers.ron`), then `--set <question>=<answer[,answer]>`,
9//! each in place of the one before for the questions it answers. A
10//! question none answers takes its default. The template's composer
11//! ([`template::compose`]) makes the files with no I/O, and
12//! [`template::apply`] writes them.
13//!
14//! A template's text files may hold these placeholders, and no others
15//! (`docs/templates.md`):
16//!
17//! | Placeholder | Becomes |
18//! | ----------- | ------- |
19//! | `{{name}}` | the game's name, as given (`Moon Garden`) |
20//! | `{{crate_name}}` | its crate's name (`moon-garden`) |
21//! | `"{{engine.<crate>}}"` | the dependency on that engine crate (`shuyi`, `core`, `assets`, `ui`, `platform`, `app`, `cli`) in `Cargo.toml`: the engine's repository at `main` or at a commit, or a checkout on this machine |
22//! | `{{engine_ref}}` | the engine's commit as a workflow's `uses: …@` names it: a 40-digit `--engine-rev`, else `main` |
23//! | `{{engine.docs}}` | a Markdown list of the engine's rules and docs for the game's agent: at `main` and on the site, at a commit, or in a checkout on this machine |
24//! | `{{answer.<question>}}` | that question's answers, joined by `,` |
25//!
26//! A GitHub Actions expression, `${{ … }}`, is left as it is. The game's
27//! Rust code names its own crates `game` and `rules` (their `[lib]
28//! name`), so its Rust holds a placeholder only in the strings of
29//! `app_info.rs`: who the game is, as `app-info` writes it from
30//! `shuyi.ron`'s `app` ([`crate::app_info`]). Its one `const` is kept from
31//! rustfmt, so the file `new` fills is the one `app-info` writes, whatever
32//! the name's length.
33
34use std::collections::BTreeMap;
35use std::fmt::Write as _;
36use std::fs;
37use std::path::Path;
38
39use schemars::JsonSchema;
40use serde::{Deserialize, Serialize};
41
42use super::codes::{self, Code};
43use super::{Answer, Op};
44use crate::catalog::Catalog;
45use crate::template::{self, Answers, ComposeError, Fill, Plan, Template};
46
47/// The engine's repository, which a new game depends on by git.
48pub const ENGINE_GIT: &str = "https://github.com/Zafnok/shuyi-engine";
49
50/// The engine's website, whose docs for agents follow `main`.
51pub const ENGINE_SITE: &str = "https://zafnok.github.io/shuyi-engine";
52
53/// The game's workflow that calls the engine's reusable playtest
54/// workflow, at `{{engine_ref}}` (the grid stage's part writes it).
55const PLAYTEST_WORKFLOW: &str = ".github/workflows/playtest.yml";
56
57/// The engine's crates a template may depend on, by their folder under
58/// `crates/`: `shuyi`, the front door, and the default engine's.
59pub const ENGINE_CRATES: [&str; 7] = ["shuyi", "core", "assets", "ui", "platform", "app", "cli"];
60
61/// Where the new game gets the engine from.
62#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
63#[serde(tag = "from", rename_all = "kebab-case", deny_unknown_fields)]
64// More places may come (ADR-0039).
65#[non_exhaustive]
66pub enum Engine {
67    /// The engine's repository at its `main` branch.
68    #[default]
69    Main,
70    /// The engine's repository at a commit.
71    Rev {
72        /// The commit, 7 to 40 hex digits.
73        rev: String,
74    },
75    /// A checkout of the engine on this machine.
76    Path {
77        /// Its folder (the one holding `crates/`).
78        path: String,
79    },
80}
81
82/// What `new` makes.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
84#[serde(deny_unknown_fields)]
85// An input: built with `NewInput::new` (a folder has no default) and its
86// fields assigned, it may grow (ADR-0039).
87#[non_exhaustive]
88pub struct NewInput {
89    /// The new game's folder, from the working folder: made if missing,
90    /// and refused unless empty (or `force`).
91    pub dir: String,
92    /// A short name of the template's for a set of answers (`grid`,
93    /// `blank`); none for none.
94    #[serde(default)]
95    pub template: Option<String>,
96    /// An answers file (`answers.ron`), from the working folder: its
97    /// answers in place of the short name's.
98    #[serde(default)]
99    pub answers: Option<String>,
100    /// Answers by question, in place of the short name's and the file's
101    /// (`--set perspective=top-down-grid`).
102    #[serde(default)]
103    pub set: Answers,
104    /// The game's name; the folder's name if none.
105    #[serde(default)]
106    pub name: Option<String>,
107    /// Where it gets the engine from.
108    #[serde(default)]
109    pub engine: Engine,
110    /// Write into a folder that isn't empty, over the files there.
111    #[serde(default)]
112    pub force: bool,
113}
114
115impl NewInput {
116    /// A game in the folder `dir`, named for its folder, from the
117    /// template's defaults, on the engine's `main` ([`Engine::Main`]).
118    pub fn new(dir: impl Into<String>) -> Self {
119        Self {
120            dir: dir.into(),
121            template: None,
122            answers: None,
123            set: Answers::default(),
124            name: None,
125            engine: Engine::Main,
126            force: false,
127        }
128    }
129
130    /// The same, from the template's short name `name` (`grid`).
131    #[must_use]
132    pub fn template(mut self, name: impl Into<String>) -> Self {
133        self.template = Some(name.into());
134        self
135    }
136}
137
138/// What `new` made.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
140#[serde(deny_unknown_fields)]
141pub struct NewOutput {
142    /// The game's folder, as given.
143    pub dir: String,
144    /// The game's name.
145    pub name: String,
146    /// Its crate's name.
147    pub crate_name: String,
148    /// The template's id (`shuyi-default`).
149    pub template: String,
150    /// The answers it was made for, each question left out at its
151    /// default.
152    pub answers: Answers,
153    /// The template's parts it was made of, in order.
154    pub parts: Vec<String>,
155    /// The files made, from its folder, `/`-separated, in order.
156    pub files: Vec<String>,
157    /// The files that were there already and were written over (with
158    /// `force`).
159    pub overwritten: Vec<String>,
160    /// The engine's modules turned on, by crate.
161    pub modules: Vec<String>,
162    /// The targets it ships on.
163    pub targets: Vec<String>,
164    /// Its stages, in order.
165    pub stages: Vec<String>,
166    /// The engine's skills it holds in `.claude/skills/`, by name.
167    pub skills: Vec<String>,
168}
169
170/// The crate name for game name `name`: lowercase ASCII letters and
171/// digits, every other run of characters one `-`; it starts with a letter.
172pub fn crate_name(name: &str) -> Result<String, String> {
173    let mut out = String::new();
174    for c in name.chars() {
175        if c.is_ascii_alphanumeric() {
176            out.push(c.to_ascii_lowercase());
177        } else if !out.is_empty() && !out.ends_with('-') {
178            out.push('-');
179        }
180    }
181    while out.ends_with('-') {
182        out.pop();
183    }
184    if out.starts_with(|c: char| c.is_ascii_lowercase()) {
185        Ok(out)
186    } else {
187        Err(format!(
188            "`{name}` makes no crate name: it needs a letter first, then letters, digits or spaces"
189        ))
190    }
191}
192
193/// Whether `name` can be a game's name: something to show, with no
194/// quote, backslash, brace or control character, which the files it goes
195/// into would have to escape.
196fn check_name(name: &str) -> Result<(), String> {
197    if name.trim().is_empty() {
198        return Err("the game's name is empty".to_owned());
199    }
200    match name
201        .chars()
202        .find(|c| c.is_control() || matches!(c, '"' | '\\' | '{' | '}'))
203    {
204        Some(c) => Err(format!("the game's name may not hold {c:?}")),
205        None => Ok(()),
206    }
207}
208
209/// The dependency on engine crate `krate` (a folder of `crates/`), as a
210/// TOML inline table.
211fn dependency(engine: &Engine, root: Option<&str>, krate: &str) -> String {
212    match (engine, root) {
213        (Engine::Path { .. }, Some(root)) => format!("{{ path = '{root}/crates/{krate}' }}"),
214        (Engine::Rev { rev }, _) => format!("{{ git = \"{ENGINE_GIT}\", rev = \"{rev}\" }}"),
215        _ => format!("{{ git = \"{ENGINE_GIT}\", branch = \"main\" }}"),
216    }
217}
218
219/// The engine's rules and docs for a game's agent, as a Markdown list, one
220/// link or path per line: those of the engine the game builds with.
221fn engine_docs(engine: &Engine, root: Option<&str>) -> String {
222    match (engine, root) {
223        (Engine::Path { .. }, Some(root)) => format!(
224            "- its rules: `{root}/AGENTS.md`\n\
225             - its book, in Markdown: `{root}/site/content/learn/`, whose code is in \
226             `{root}/site/snippets/`"
227        ),
228        (Engine::Rev { rev }, _) => format!(
229            "- its rules: [`AGENTS.md`]({ENGINE_GIT}/blob/{rev}/AGENTS.md)\n\
230             - its book, in Markdown: [`site/content/learn`]({ENGINE_GIT}/tree/{rev}/site/content/learn), \
231             whose code is in `site/snippets/` at the same commit"
232        ),
233        _ => format!(
234            "- its rules: [`AGENTS.md`]({ENGINE_GIT}/blob/main/AGENTS.md)\n\
235             - its docs, page by page: [`llms.txt`]({ENGINE_SITE}/llms.txt)\n\
236             - its docs, in one file: [`llms-full.txt`]({ENGINE_SITE}/llms-full.txt)"
237        ),
238    }
239}
240
241/// The commit a workflow's `uses: …@` can name for `engine`: a full
242/// 40-digit rev (GitHub takes no shorter one there).
243fn engine_ref(engine: &Engine) -> Option<&str> {
244    match engine {
245        Engine::Rev { rev } if rev.len() == 40 => Some(rev),
246        _ => None,
247    }
248}
249
250/// The engine checkout at `path` (from `cwd`), as an absolute path with
251/// `/`, or why it is none.
252fn engine_root(cwd: &Path, path: &str) -> Result<String, String> {
253    let root = std::path::absolute(cwd.join(path)).map_err(|e| format!("{path}: {e}"))?;
254    if !root.join("crates/core/Cargo.toml").is_file() {
255        return Err(format!(
256            "{path} is no checkout of the engine: it has no crates/core/Cargo.toml"
257        ));
258    }
259    let root = root.to_string_lossy().replace('\\', "/");
260    if root.contains('\'') {
261        return Err(format!("the engine's path may not hold a quote: {root}"));
262    }
263    Ok(root)
264}
265
266/// What fills the placeholders of a game named `name`, of crate `krate`,
267/// on `engine` (at `root` if a checkout).
268pub fn fill_for(name: &str, krate: &str, engine: &Engine, root: Option<&str>) -> Fill {
269    Fill {
270        name: name.to_owned(),
271        crate_name: krate.to_owned(),
272        engine: ENGINE_CRATES
273            .iter()
274            .map(|krate| ((*krate).to_owned(), dependency(engine, root, krate)))
275            .collect(),
276        engine_ref: engine_ref(engine).unwrap_or("main").to_owned(),
277        engine_docs: engine_docs(engine, root),
278    }
279}
280
281/// The template's short names, joined by commas.
282fn short_names(template: &Template) -> String {
283    let names: Vec<&str> = template.short_names.keys().map(String::as_str).collect();
284    names.join(", ")
285}
286
287/// The answers `input` gives, from the working folder `cwd`: its short
288/// name's, then its file's, then its `set`, each in place of the one
289/// before for the questions it answers.
290pub fn answers_of(cwd: &Path, template: &Template, input: &NewInput) -> Result<Answers, String> {
291    let mut answers = Answers::default();
292    if let Some(name) = &input.template {
293        let named = template.short_name(name).ok_or_else(|| {
294            format!(
295                "no template {name}: the templates are {}",
296                short_names(template)
297            )
298        })?;
299        answers = answers.and(named);
300    }
301    if let Some(file) = &input.answers {
302        let text = fs::read_to_string(cwd.join(file)).map_err(|e| format!("{file}: {e}"))?;
303        answers = answers.and(&Answers::parse(file, &text)?);
304    }
305    Ok(answers.and(&input.set))
306}
307
308/// The problem code of a template that makes no game: the answers'
309/// fault ([`codes::WRONG_ARGUMENTS`]) or the template's
310/// ([`codes::FILE`]).
311fn code_of(error: &ComposeError) -> Code {
312    match error {
313        ComposeError::Answer { .. }
314        | ComposeError::Requires { .. }
315        | ComposeError::Conflict { .. }
316        | ComposeError::Module { .. } => codes::WRONG_ARGUMENTS,
317        _ => codes::FILE,
318    }
319}
320
321/// The game `template` makes for `input` (named `name`, of crate
322/// `krate`) under `cwd`, with no file written: its plan, and its limits.
323pub fn plan(
324    cwd: &Path,
325    template: &Template,
326    input: &NewInput,
327    name: &str,
328    krate: &str,
329) -> Result<(Plan, Vec<String>), (Code, String)> {
330    let refused = |e| (codes::WRONG_ARGUMENTS, e);
331    let mut limits = Vec::new();
332    let root = match &input.engine {
333        Engine::Main => {
334            limits.push(
335                "the game follows the engine's main branch, not a commit: pass --engine-rev \
336                 <commit> to pin it"
337                    .to_owned(),
338            );
339            None
340        }
341        Engine::Rev { rev } => {
342            let hex = rev.chars().all(|c| c.is_ascii_hexdigit());
343            if !(hex && (7..=40).contains(&rev.len())) {
344                return Err(refused(format!("--engine-rev: not a commit: {rev}")));
345            }
346            None
347        }
348        Engine::Path { path } => {
349            let root = engine_root(cwd, path).map_err(refused)?;
350            limits.push(format!(
351                "the game builds with the engine checkout at {root}: it moves with that folder"
352            ));
353            Some(root)
354        }
355    };
356    let catalog = match &root {
357        Some(root) => Catalog::read(Path::new(root)),
358        None => Catalog::embedded(),
359    }
360    .map_err(|e| (codes::ENGINE_DEPENDENCY, e))?;
361    let answers = answers_of(cwd, template, input).map_err(refused)?;
362    let fill = fill_for(name, krate, &input.engine, root.as_deref());
363    let plan = template::compose(template, &answers, &catalog, &fill)
364        .map_err(|e| (code_of(&e), e.to_string()))?;
365    limits.extend(plan.limits.iter().cloned());
366    if matches!(input.engine, Engine::Rev { .. })
367        && engine_ref(&input.engine).is_none()
368        && plan.files.contains_key(Path::new(PLAYTEST_WORKFLOW))
369    {
370        limits.push(format!(
371            "{PLAYTEST_WORKFLOW} calls the engine's playtest workflow at main: a workflow names \
372             a commit by its 40 digits, so pass the whole commit to --engine-rev to pin it too"
373        ));
374    }
375    Ok((plan, limits))
376}
377
378/// `new`: the game `input` asks for, made under `cwd`.
379pub fn new(cwd: &Path, input: &NewInput) -> Answer<NewOutput> {
380    match make(cwd, input) {
381        Ok((output, limits)) => Answer::new(Op::New, true, output, limits),
382        Err((code, e)) => Answer::failed(Op::New, code, e),
383    }
384}
385
386/// The game `input` asks for, made under `cwd`, with its limits; or the
387/// problem code and why not: an input refused
388/// ([`codes::WRONG_ARGUMENTS`]), or a template or files that can't be
389/// read, composed or written ([`codes::FILE`]).
390fn make(cwd: &Path, input: &NewInput) -> Result<(NewOutput, Vec<String>), (Code, String)> {
391    let refused = |e| (codes::WRONG_ARGUMENTS, e);
392    let dir = cwd.join(&input.dir);
393    let folder = std::path::absolute(&dir)
394        .ok()
395        .and_then(|d| d.file_name().map(|n| n.to_string_lossy().into_owned()))
396        .unwrap_or_default();
397    let name = input.name.clone().unwrap_or(folder);
398    check_name(&name).map_err(refused)?;
399    let krate = crate_name(&name).map_err(refused)?;
400    let template = template::built_in().map_err(|e| (codes::FILE, e.to_string()))?;
401    let (plan, mut limits) = plan(cwd, &template, input, &name, &krate)?;
402    let applied = template::apply(&plan, &dir, input.force).map_err(|e| {
403        if e.starts_with("is not empty") {
404            refused(format!("{} {e}", input.dir))
405        } else {
406            (codes::FILE, e)
407        }
408    })?;
409    limits.push(format!(
410        "the game was made, not built: run `cargo test` in {}",
411        input.dir
412    ));
413    let output = NewOutput {
414        dir: input.dir.clone(),
415        name,
416        crate_name: krate,
417        template: template.id.clone(),
418        answers: plan.answers,
419        parts: plan.parts,
420        files: applied.written,
421        overwritten: applied.overwritten,
422        modules: plan.modules,
423        targets: plan.targets,
424        stages: plan.stages,
425        skills: plan.skills,
426    };
427    Ok((output, limits))
428}
429
430/// `new`'s usage.
431pub fn usage(command: &str) -> String {
432    let names = template::built_in().map_or_else(|_| String::new(), |t| short_names(&t));
433    format!(
434        "usage: {command} new <dir> [--template NAME] [--answers FILE] [--set QUESTION=ANSWER[,ANSWER]]...\n\
435         \x20          [--name NAME] [--engine-rev COMMIT | --engine-path DIR] [--force] [--json]\n\n\
436         Makes a new game in <dir> (missing or empty, or any with --force) from the engine's\n\
437         template, its parts picked by the answers: --template's short name ({names}),\n\
438         then --answers' file, then each --set. The game depends on the engine's repository\n\
439         at main, at --engine-rev's commit, or on the checkout at --engine-path."
440    )
441}
442
443/// Reads `new`'s arguments (not `--json`). `new <template> <dir>`, the
444/// older form, is `new <dir> --template <template>`.
445pub fn parse_args(args: &[String]) -> Result<NewInput, String> {
446    let mut positional = Vec::new();
447    let mut input = NewInput::new("");
448    let mut set = BTreeMap::new();
449    let mut iter = args.iter();
450    while let Some(arg) = iter.next() {
451        let mut value = || {
452            iter.next()
453                .ok_or_else(|| format!("{arg} needs a value"))
454                .cloned()
455        };
456        match arg.as_str() {
457            "--template" => input.template = Some(value()?),
458            "--answers" => input.answers = Some(value()?),
459            "--set" => {
460                let (question, answers) = Answers::parse_set(&value()?)?;
461                set.insert(question, answers);
462            }
463            "--name" => input.name = Some(value()?),
464            "--engine-rev" => input.engine = Engine::Rev { rev: value()? },
465            "--engine-path" => input.engine = Engine::Path { path: value()? },
466            "--force" => input.force = true,
467            flag if flag.starts_with("--") => return Err(format!("unknown option: {flag}")),
468            _ => positional.push(arg.clone()),
469        }
470    }
471    input.set = Answers(set);
472    match positional.as_slice() {
473        [dir] => input.dir.clone_from(dir),
474        [template, dir] if input.template.is_none() => {
475            input.template = Some(template.clone());
476            input.dir.clone_from(dir);
477        }
478        _ => return Err("a <dir>, nothing else".to_owned()),
479    }
480    Ok(input)
481}
482
483/// What `new` made, for people.
484pub fn text(output: &NewOutput) -> String {
485    let mut out = format!(
486        "new: {} ({}) in {}, {} file(s), from {}: {}\n",
487        output.name,
488        output.crate_name,
489        output.dir,
490        output.files.len(),
491        output.template,
492        output.parts.join(", ")
493    );
494    // Writing to a `String` can't fail.
495    if !output.modules.is_empty() {
496        let _ = writeln!(out, "modules on: {}", output.modules.join(", "));
497    }
498    if !output.skills.is_empty() {
499        let _ = writeln!(out, "skills: {}", output.skills.join(", "));
500    }
501    if !output.overwritten.is_empty() {
502        let _ = writeln!(out, "written over: {}", output.overwritten.join(", "));
503    }
504    for file in &output.files {
505        let _ = writeln!(out, "  {file}");
506    }
507    out
508}
509
510#[cfg(test)]
511mod tests;