1use 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
47pub const ENGINE_GIT: &str = "https://github.com/Zafnok/shuyi-engine";
49
50pub const ENGINE_SITE: &str = "https://zafnok.github.io/shuyi-engine";
52
53const PLAYTEST_WORKFLOW: &str = ".github/workflows/playtest.yml";
56
57pub const ENGINE_CRATES: [&str; 7] = ["shuyi", "core", "assets", "ui", "platform", "app", "cli"];
60
61#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
63#[serde(tag = "from", rename_all = "kebab-case", deny_unknown_fields)]
64#[non_exhaustive]
66pub enum Engine {
67 #[default]
69 Main,
70 Rev {
72 rev: String,
74 },
75 Path {
77 path: String,
79 },
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
84#[serde(deny_unknown_fields)]
85#[non_exhaustive]
88pub struct NewInput {
89 pub dir: String,
92 #[serde(default)]
95 pub template: Option<String>,
96 #[serde(default)]
99 pub answers: Option<String>,
100 #[serde(default)]
103 pub set: Answers,
104 #[serde(default)]
106 pub name: Option<String>,
107 #[serde(default)]
109 pub engine: Engine,
110 #[serde(default)]
112 pub force: bool,
113}
114
115impl NewInput {
116 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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
140#[serde(deny_unknown_fields)]
141pub struct NewOutput {
142 pub dir: String,
144 pub name: String,
146 pub crate_name: String,
148 pub template: String,
150 pub answers: Answers,
153 pub parts: Vec<String>,
155 pub files: Vec<String>,
157 pub overwritten: Vec<String>,
160 pub modules: Vec<String>,
162 pub targets: Vec<String>,
164 pub stages: Vec<String>,
166 pub skills: Vec<String>,
168}
169
170pub 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
193fn 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
209fn 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
219fn 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
241fn engine_ref(engine: &Engine) -> Option<&str> {
244 match engine {
245 Engine::Rev { rev } if rev.len() == 40 => Some(rev),
246 _ => None,
247 }
248}
249
250fn 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
266pub 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
281fn short_names(template: &Template) -> String {
283 let names: Vec<&str> = template.short_names.keys().map(String::as_str).collect();
284 names.join(", ")
285}
286
287pub 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
308fn 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
321pub 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
378pub 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
386fn 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
430pub 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
443pub 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
483pub 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 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;