1mod compose;
20mod ron_merge;
21
22use std::collections::BTreeMap;
23use std::fmt;
24use std::fs;
25use std::path::{Path, PathBuf};
26
27use include_dir::{Dir, DirEntry, include_dir};
28use schemars::JsonSchema;
29use semver::{Version, VersionReq};
30use serde::{Deserialize, Serialize};
31
32pub use compose::{
33 Applied, ComposeError, Fill, GENERATED_STAGES, Plan, apply, compose, fill, stages_rs,
34};
35
36pub const MANIFEST: &str = "template.ron";
38
39pub const FORMAT_VERSION: u32 = 1;
41
42pub const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION");
44
45static BUILT_IN: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/../../templates/shuyi-default");
47
48pub const BUILT_IN_ORIGIN: &str = "templates/shuyi-default";
50
51pub type Files = BTreeMap<String, Vec<u8>>;
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct Template {
59 pub id: String,
61 pub name: String,
63 pub version: Version,
65 pub engine: VersionReq,
67 pub license: String,
70 pub format_version: u32,
72 #[serde(default)]
75 pub questions: Vec<Question>,
76 #[serde(default)]
79 pub short_names: BTreeMap<String, Answers>,
80 pub parts: Vec<Part>,
82 #[serde(skip)]
84 pub files: Files,
85 #[serde(skip)]
87 pub origin: String,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(deny_unknown_fields)]
94pub struct Part {
95 pub id: String,
97 pub when: When,
99 pub files: PathBuf,
102 #[serde(default)]
104 pub modules: Vec<String>,
105 #[serde(default)]
107 pub targets: Vec<String>,
108 #[serde(default)]
111 pub stage: Option<String>,
112 #[serde(default)]
114 pub requires: Vec<String>,
115 #[serde(default)]
117 pub conflicts: Vec<String>,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub enum When {
123 Always,
125 Picked(String, String),
127 All(Vec<When>),
129 Any(Vec<When>),
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(deny_unknown_fields)]
136pub struct Question {
137 pub id: String,
139 pub text: String,
141 pub pick: Pick,
143 pub answers: Vec<Answer>,
145 #[serde(default)]
147 pub default: Vec<String>,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
152pub enum Pick {
153 One,
155 Many,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(deny_unknown_fields)]
162pub struct Answer {
163 pub id: String,
165 pub text: String,
167}
168
169#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
172#[serde(transparent)]
173pub struct Answers(pub BTreeMap<String, Vec<String>>);
174
175impl Answers {
176 pub fn parse(file: &str, text: &str) -> Result<Self, String> {
178 ron::from_str(text).map_err(|e| format!("{file}: {e}"))
179 }
180
181 pub fn to_ron(&self) -> String {
183 let pretty = ron::ser::PrettyConfig::new();
184 ron::ser::to_string_pretty(self, pretty).unwrap_or_default()
185 }
186
187 pub fn parse_set(set: &str) -> Result<(String, Vec<String>), String> {
189 let (question, answers) = set
190 .split_once('=')
191 .ok_or_else(|| format!("--set {set}: not <question>=<answer[,answer]>"))?;
192 let question = question.trim();
193 if question.is_empty() {
194 return Err(format!("--set {set}: no question before `=`"));
195 }
196 let answers = answers
197 .split(',')
198 .map(str::trim)
199 .filter(|a| !a.is_empty())
200 .map(str::to_owned)
201 .collect();
202 Ok((question.to_owned(), answers))
203 }
204
205 #[must_use]
208 pub fn and(mut self, other: &Answers) -> Self {
209 for (question, answers) in &other.0 {
210 self.0.insert(question.clone(), answers.clone());
211 }
212 self
213 }
214
215 pub fn picked(&self, question: &str, answer: &str) -> bool {
217 self.0
218 .get(question)
219 .is_some_and(|answers| answers.iter().any(|a| a == answer))
220 }
221}
222
223impl When {
224 pub fn holds(&self, answers: &Answers) -> bool {
226 match self {
227 When::Always => true,
228 When::Picked(question, answer) => answers.picked(question, answer),
229 When::All(all) => all.iter().all(|w| w.holds(answers)),
230 When::Any(any) => any.iter().any(|w| w.holds(answers)),
231 }
232 }
233
234 pub fn because(&self, answers: &Answers) -> String {
237 match self {
238 When::Always => "always".to_owned(),
239 When::Picked(question, answer) => format!("`{question}` is `{answer}`"),
240 When::All(all) => {
241 let words: Vec<String> = all.iter().map(|w| w.because(answers)).collect();
242 if words.is_empty() {
243 "always".to_owned()
244 } else {
245 words.join(" and ")
246 }
247 }
248 When::Any(any) => {
249 let words: Vec<String> = any
250 .iter()
251 .filter(|w| w.holds(answers))
252 .map(|w| w.because(answers))
253 .collect();
254 words.join(" or ")
255 }
256 }
257 }
258
259 pub fn picks(&self) -> Vec<(&str, &str)> {
261 match self {
262 When::Always => Vec::new(),
263 When::Picked(question, answer) => vec![(question.as_str(), answer.as_str())],
264 When::All(whens) | When::Any(whens) => whens.iter().flat_map(When::picks).collect(),
265 }
266 }
267}
268
269#[derive(Debug, Clone, PartialEq, Eq)]
271pub struct LoadError {
272 pub file: String,
274 pub field: String,
276 pub message: String,
278}
279
280impl fmt::Display for LoadError {
281 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
282 if self.field.is_empty() {
283 write!(f, "{}: {}", self.file, self.message)
284 } else {
285 write!(f, "{}: {}: {}", self.file, self.field, self.message)
286 }
287 }
288}
289
290impl std::error::Error for LoadError {}
291
292pub(crate) fn folder_of(files: &Path) -> Result<String, String> {
295 let text = files.to_string_lossy().replace('\\', "/");
296 let trimmed = text.trim_matches('/');
297 let escapes = trimmed
298 .split('/')
299 .any(|segment| segment == ".." || segment == "." || segment.is_empty());
300 if text.starts_with('/') || escapes {
303 return Err(format!(
304 "`{text}` is no folder inside the template: give a path from its folder, with no \
305 `.` or `..`"
306 ));
307 }
308 Ok(format!("{trimmed}/"))
309}
310
311fn is_stage_id(id: &str) -> bool {
314 id.starts_with(|c: char| c.is_ascii_lowercase())
315 && id.split('-').all(|w| {
316 !w.is_empty()
317 && w.chars()
318 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
319 })
320}
321
322impl Template {
323 pub fn load(dir: &Path) -> Result<Self, LoadError> {
326 let origin = dir.to_string_lossy().replace('\\', "/");
327 let mut files = Files::new();
328 read_dir(dir, dir, &mut files).map_err(|message| LoadError {
329 file: origin.clone(),
330 field: String::new(),
331 message,
332 })?;
333 Self::read(&origin, files, &engine_version())
334 }
335
336 pub fn read(origin: &str, mut files: Files, engine: &Version) -> Result<Self, LoadError> {
340 let file = format!("{}/{MANIFEST}", origin.trim_end_matches('/'));
341 let error = |field: &str, message: String| LoadError {
342 file: file.clone(),
343 field: field.to_owned(),
344 message,
345 };
346 let bytes = files
347 .remove(MANIFEST)
348 .ok_or_else(|| error("", "missing: every template has one".to_owned()))?;
349 let text =
350 std::str::from_utf8(&bytes).map_err(|e| error("", format!("not UTF-8 text: {e}")))?;
351 let mut template: Template = ron::from_str(text).map_err(|e| error("", e.to_string()))?;
352 template.files = files;
353 origin.clone_into(&mut template.origin);
354 template
355 .check(engine)
356 .map_err(|(field, message)| error(&field, message))?;
357 Ok(template)
358 }
359
360 fn check(&self, engine: &Version) -> Result<(), (String, String)> {
362 if self.format_version != FORMAT_VERSION {
363 return Err((
364 "format_version".to_owned(),
365 format!(
366 "{}: this shuyi reads templates of format {FORMAT_VERSION}",
367 self.format_version
368 ),
369 ));
370 }
371 if !self.engine.matches(engine) {
372 return Err((
373 "engine".to_owned(),
374 format!(
375 "`{}` doesn't match this engine, {engine}: the template makes games for \
376 another engine",
377 self.engine
378 ),
379 ));
380 }
381 self.check_questions()?;
382 for (name, answers) in &self.short_names {
383 self.check_answers(answers)
384 .map_err(|message| (format!("short_names[{name}]"), message))?;
385 }
386 self.check_parts()
387 }
388
389 fn check_questions(&self) -> Result<(), (String, String)> {
392 let mut seen = Vec::new();
393 for question in &self.questions {
394 let field = format!("questions[{}]", question.id);
395 if seen.contains(&question.id.as_str()) {
396 return Err((field, "a second question with this id".to_owned()));
397 }
398 seen.push(&question.id);
399 let mut ids = Vec::new();
400 for answer in &question.answers {
401 if ids.contains(&answer.id.as_str()) {
402 return Err((
403 format!("{field}.answers"),
404 format!("`{}` is in twice", answer.id),
405 ));
406 }
407 ids.push(&answer.id);
408 }
409 Self::check_picks(question, &question.default)
410 .map_err(|message| (format!("{field}.default"), message))?;
411 }
412 Ok(())
413 }
414
415 fn check_picks(question: &Question, picked: &[String]) -> Result<(), String> {
417 if let Some(unknown) = picked
418 .iter()
419 .find(|a| !question.answers.iter().any(|answer| &answer.id == *a))
420 {
421 return Err(format!(
422 "`{unknown}` is no answer of `{}`: its answers are {}",
423 question.id,
424 ids(question.answers.iter().map(|a| a.id.as_str()))
425 ));
426 }
427 if question.pick == Pick::One && picked.len() > 1 {
428 return Err(format!(
429 "`{}` takes one answer, not {}",
430 question.id,
431 picked.len()
432 ));
433 }
434 Ok(())
435 }
436
437 pub fn check_answers(&self, answers: &Answers) -> Result<(), String> {
440 for (question, picked) in &answers.0 {
441 let question = self.question(question).ok_or_else(|| {
442 format!(
443 "no question `{question}`: the template asks {}",
444 ids(self.questions.iter().map(|q| q.id.as_str()))
445 )
446 })?;
447 Self::check_picks(question, picked)?;
448 }
449 Ok(())
450 }
451
452 fn check_parts(&self) -> Result<(), (String, String)> {
455 let mut seen = Vec::new();
456 for part in &self.parts {
457 let field = |name: &str| format!("parts[{}].{name}", part.id);
458 if seen.contains(&part.id.as_str()) {
459 return Err((
460 format!("parts[{}]", part.id),
461 "a second part with this id".to_owned(),
462 ));
463 }
464 seen.push(&part.id);
465 let folder = folder_of(&part.files).map_err(|m| (field("files"), m))?;
466 if !self.files.keys().any(|path| path.starts_with(&folder)) {
467 return Err((
468 field("files"),
469 format!("the folder `{folder}` holds no file"),
470 ));
471 }
472 for (list, named) in [("requires", &part.requires), ("conflicts", &part.conflicts)] {
473 if let Some(missing) = named
474 .iter()
475 .find(|id| !self.parts.iter().any(|p| &p.id == *id))
476 {
477 return Err((field(list), format!("no part `{missing}`")));
478 }
479 }
480 for (question, answer) in part.when.picks() {
481 let q = self.question(question).ok_or_else(|| {
482 (
483 field("when"),
484 format!("`Picked` names no question of the template: `{question}`"),
485 )
486 })?;
487 if !q.answers.iter().any(|a| a.id == answer) {
488 return Err((
489 field("when"),
490 format!("`Picked` names no answer of `{question}`: `{answer}`"),
491 ));
492 }
493 }
494 if let Some(stage) = &part.stage
495 && !is_stage_id(stage)
496 {
497 return Err((
498 field("stage"),
499 format!(
500 "`{stage}`: a stage's id is lowercase letters and digits in words \
501 joined by `-`, starting with a letter"
502 ),
503 ));
504 }
505 }
506 Ok(())
507 }
508
509 pub fn question(&self, id: &str) -> Option<&Question> {
511 self.questions.iter().find(|q| q.id == id)
512 }
513
514 pub fn short_name(&self, name: &str) -> Option<&Answers> {
516 self.short_names.get(name)
517 }
518
519 pub fn with_defaults(&self, answers: &Answers) -> Answers {
522 let mut all = answers.clone();
523 for question in &self.questions {
524 all.0
525 .entry(question.id.clone())
526 .or_insert_with(|| question.default.clone());
527 }
528 all
529 }
530}
531
532fn ids<'a>(ids: impl Iterator<Item = &'a str>) -> String {
534 let ids: Vec<String> = ids.map(|id| format!("`{id}`")).collect();
535 if ids.is_empty() {
536 "none".to_owned()
537 } else {
538 ids.join(", ")
539 }
540}
541
542pub fn engine_version() -> Version {
544 Version::parse(ENGINE_VERSION).unwrap_or_else(|_| Version::new(0, 0, 0))
546}
547
548fn read_dir(root: &Path, dir: &Path, files: &mut Files) -> Result<(), String> {
550 let entries = fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
551 for entry in entries {
552 let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?;
553 let path = entry.path();
554 let kind = entry
555 .file_type()
556 .map_err(|e| format!("{}: {e}", path.display()))?;
557 if kind.is_dir() {
558 read_dir(root, &path, files)?;
559 } else if kind.is_file() {
560 let bytes = fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?;
561 let name = path
562 .strip_prefix(root)
563 .unwrap_or(&path)
564 .to_string_lossy()
565 .replace('\\', "/");
566 files.insert(name, bytes);
567 }
568 }
569 Ok(())
570}
571
572fn embedded_files(dir: &'static Dir<'static>) -> Files {
574 let mut files = Files::new();
575 let mut pending = vec![dir];
576 while let Some(dir) = pending.pop() {
577 for entry in dir.entries() {
578 match entry {
579 DirEntry::Dir(d) => pending.push(d),
580 DirEntry::File(f) => {
581 let name = f.path().to_string_lossy().replace('\\', "/");
582 files.insert(name, f.contents().to_vec());
583 }
584 }
585 }
586 }
587 files
588}
589
590pub fn built_in() -> Result<Template, LoadError> {
593 Template::read(
594 BUILT_IN_ORIGIN,
595 embedded_files(&BUILT_IN),
596 &engine_version(),
597 )
598}
599
600#[cfg(test)]
601mod tests;