Skip to main content

shuyi_cli/template/
compose.rs

1//! [`compose`]: a template and a game's answers made into a [`Plan`], the
2//! files of the new game and their bytes, with no I/O; and [`apply`],
3//! which writes a plan into a folder.
4//!
5//! The parts whose `when` holds are picked in the template's order. Each
6//! writes its folder's files, with the placeholders filled ([`fill`]).
7//! A path more than one part writes is merged when it is a `Cargo.toml`
8//! (key by key, [`toml_edit`]) or a `.ron` file (field by field, entry by
9//! entry, item by item, keeping each file's comments); any other is
10//! [`ComposeError::SameFile`]. The modules the parts turn on become the
11//! `shuyi` crate's features in the root `Cargo.toml`, through `shuyi
12//! add`'s code ([`catalog::turn_on`]). When the template has a part with a
13//! stage, `shuyi new` writes the flow's list of stages,
14//! [`GENERATED_STAGES`] ([`stages_rs`]). Every game gets the engine's skill
15//! pack (the catalog's [`Catalog::skills`]) in `.claude/skills/`, all but
16//! the modules' skills, which `shuyi add` copies when a game turns its
17//! module on.
18
19use std::collections::BTreeMap;
20use std::fmt::{self, Write as _};
21use std::fs;
22use std::path::{Path, PathBuf};
23
24use toml_edit::{DocumentMut, Item, Table, Value};
25
26use super::{Answers, Part, Template, folder_of, ron_merge};
27use crate::catalog::{self, Catalog};
28
29/// The flow's list of stages, which `shuyi new` writes from the parts'
30/// stages.
31pub const GENERATED_STAGES: &str = "crates/game/src/stages.rs";
32
33/// The files the parts write, by path, each with the parts that write it
34/// and their bytes.
35type Written = BTreeMap<String, Vec<(String, Vec<u8>)>>;
36
37/// Who writes [`GENERATED_STAGES`], as errors name it.
38const COMPOSER: &str = "shuyi new";
39
40/// What fills a template's placeholders, but the answers.
41#[derive(Debug, Clone, Default, PartialEq, Eq)]
42// It grew a field (ticket 2021) and may again: built with `default()` and
43// its fields assigned (ADR-0039).
44#[non_exhaustive]
45pub struct Fill {
46    /// `{{name}}`: the game's name, as given (`Moon Garden`).
47    pub name: String,
48    /// `{{crate_name}}`: its crate's name (`moon-garden`).
49    pub crate_name: String,
50    /// `"{{engine.<crate>}}"`: by an engine crate's folder under
51    /// `crates/` (`core`), the dependency on it, a TOML inline table.
52    pub engine: BTreeMap<String, String>,
53    /// `{{engine_ref}}`: the engine's commit the game builds with, as a
54    /// workflow's `uses: …@` names it (40 hex digits), or `main`.
55    pub engine_ref: String,
56    /// `{{engine.docs}}`: the engine's rules and docs for the game's agent,
57    /// a Markdown list.
58    pub engine_docs: String,
59}
60
61/// What a template makes for a set of answers: the new game's files and
62/// what its parts turn on.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Plan {
65    /// Every file, by its path from the game's folder (`/`-separated),
66    /// with its bytes.
67    pub files: BTreeMap<PathBuf, Vec<u8>>,
68    /// The parts picked, in the template's order.
69    pub parts: Vec<String>,
70    /// The engine's modules turned on, by crate (`shuyi-physics`).
71    pub modules: Vec<String>,
72    /// The targets the parts ship on.
73    pub targets: Vec<String>,
74    /// The stages, in order.
75    pub stages: Vec<String>,
76    /// The engine's skills it holds in `.claude/skills/`, by name: every
77    /// skill of its pack but the modules'.
78    pub skills: Vec<String>,
79    /// The answers it was made for, each question the answers left out at
80    /// its default.
81    pub answers: Answers,
82    /// What the template doesn't do for these answers.
83    pub limits: Vec<String>,
84}
85
86impl Plan {
87    /// Its files' paths, `/`-separated, in order.
88    pub fn paths(&self) -> Vec<String> {
89        self.files.keys().map(|p| shown(p)).collect()
90    }
91}
92
93/// A path as the plan names it, `/`-separated.
94fn shown(path: &Path) -> String {
95    path.to_string_lossy().replace('\\', "/")
96}
97
98/// Why a template makes no game for a set of answers.
99#[derive(Debug, Clone, PartialEq, Eq)]
100#[non_exhaustive]
101pub enum ComposeError {
102    /// An answer the template doesn't take.
103    Answer {
104        /// What is wrong.
105        message: String,
106    },
107    /// A part's folder the template can't take files from.
108    Part {
109        /// The part.
110        part: String,
111        /// What is wrong.
112        message: String,
113    },
114    /// A part picked needs another that isn't.
115    Requires {
116        /// The part.
117        part: String,
118        /// The part it needs.
119        requires: String,
120        /// Why the first is picked, in words.
121        because: String,
122    },
123    /// Two parts picked can't be together.
124    Conflict {
125        /// The two parts.
126        parts: [String; 2],
127        /// Which answers picked each, in words.
128        because: String,
129    },
130    /// Two parts write one file that isn't merged.
131    SameFile {
132        /// The file, from the game's folder.
133        path: String,
134        /// The parts that write it.
135        parts: Vec<String>,
136    },
137    /// Two parts write one merged file, with values that differ.
138    Merge {
139        /// The file, from the game's folder.
140        path: String,
141        /// The parts that write it.
142        parts: Vec<String>,
143        /// Where in it (`workspace.dependencies.serde`), or `""`.
144        at: String,
145        /// What differs.
146        message: String,
147    },
148    /// A placeholder no game fills.
149    Placeholder {
150        /// The file, from the template's folder or the game's.
151        file: String,
152        /// Its line, from 1.
153        line: usize,
154        /// The placeholder (`{{nope}}`).
155        placeholder: String,
156    },
157    /// A module a part turns on that the engine doesn't build.
158    Module {
159        /// The part.
160        part: String,
161        /// The module, as the part names it.
162        module: String,
163        /// Why it is none.
164        message: String,
165    },
166    /// A stage the flow can't take.
167    Stage {
168        /// The parts concerned.
169        parts: Vec<String>,
170        /// What is wrong.
171        message: String,
172    },
173}
174
175impl fmt::Display for ComposeError {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        let names = |parts: &[String]| {
178            let named: Vec<String> = parts.iter().map(|p| format!("`{p}`")).collect();
179            named.join(" and ")
180        };
181        match self {
182            ComposeError::Answer { message } => write!(f, "{message}"),
183            ComposeError::Part { part, message } => write!(f, "part `{part}`: {message}"),
184            ComposeError::Requires {
185                part,
186                requires,
187                because,
188            } => write!(
189                f,
190                "part `{part}` (picked because {because}) needs part `{requires}`, which no \
191                 answer picks"
192            ),
193            ComposeError::Conflict { parts, because } => write!(
194                f,
195                "parts `{}` and `{}` can't be in one game: {because}",
196                parts[0], parts[1]
197            ),
198            ComposeError::SameFile { path, parts } => write!(
199                f,
200                "{path} is written by the parts {}, and only a Cargo.toml or a .ron file is \
201                 merged",
202                names(parts)
203            ),
204            ComposeError::Merge {
205                path,
206                parts,
207                at,
208                message,
209            } => {
210                let at = if at.is_empty() {
211                    String::new()
212                } else {
213                    format!(" at `{at}`")
214                };
215                write!(
216                    f,
217                    "{path}, written by the parts {}, doesn't merge{at}: {message}",
218                    names(parts)
219                )
220            }
221            ComposeError::Placeholder {
222                file,
223                line,
224                placeholder,
225            } => write!(
226                f,
227                "the template's {file}:{line} has an unknown placeholder {placeholder}"
228            ),
229            ComposeError::Module {
230                part,
231                module,
232                message,
233            } => write!(f, "part `{part}` turns on `{module}`: {message}"),
234            ComposeError::Stage { parts, message } => {
235                write!(f, "the stages of {}: {message}", names(parts))
236            }
237        }
238    }
239}
240
241impl std::error::Error for ComposeError {}
242
243/// `text`, the file `file`, with its placeholders filled: `{{name}}`,
244/// `{{crate_name}}`, `"{{engine.<crate>}}"` (with its quotes),
245/// `{{engine_ref}}`, `{{engine.docs}}` and `{{answer.<question>}}` (the question's answers in
246/// `answers`, joined by `,`). A GitHub Actions expression, `${{ … }}`, passes through as it
247/// is. Any other `{{` is [`ComposeError::Placeholder`].
248pub fn fill(
249    file: &str,
250    text: &str,
251    with: &Fill,
252    answers: &Answers,
253) -> Result<String, ComposeError> {
254    let mut out = String::with_capacity(text.len());
255    let mut at = 0;
256    while let Some(found) = text[at..].find("{{") {
257        let open = at + found;
258        let before = &text[at..open];
259        let unknown = |shown: &str| ComposeError::Placeholder {
260            file: file.to_owned(),
261            line: text[..open].matches('\n').count() + 1,
262            placeholder: shown.to_owned(),
263        };
264        let Some(length) = text[open..].find("}}") else {
265            return Err(unknown(&text[open..]));
266        };
267        let close = open + length + 2;
268        let token = &text[open..close];
269        let inner = &text[open + 2..close - 2];
270        out.push_str(before);
271        at = close;
272        if before.ends_with('$') {
273            out.push_str(token);
274            continue;
275        }
276        if let Some(krate) = inner.strip_prefix("engine.")
277            && let Some(dependency) = with.engine.get(krate)
278            && out.ends_with('"')
279            && text[close..].starts_with('"')
280        {
281            out.pop();
282            out.push_str(dependency);
283            at += 1;
284            continue;
285        }
286        let value = match inner {
287            "name" => with.name.clone(),
288            "crate_name" => with.crate_name.clone(),
289            "engine_ref" => with.engine_ref.clone(),
290            "engine.docs" => with.engine_docs.clone(),
291            _ => match inner
292                .strip_prefix("answer.")
293                .and_then(|question| answers.0.get(question))
294            {
295                Some(picked) => picked.join(","),
296                None => return Err(unknown(token)),
297            },
298        };
299        out.push_str(&value);
300    }
301    out.push_str(&text[at..]);
302    Ok(out)
303}
304
305/// The module of stage `stage`: its id with `_` for `-`.
306fn module_of(stage: &str) -> String {
307    stage.replace('-', "_")
308}
309
310/// The flow's list of stages ([`GENERATED_STAGES`]) for `stages`, in
311/// order, each a module `stages/<id with _>.rs` of the game crate that
312/// gives its `Own` part of the game's data and its `own` reader, its
313/// `first` screen, its `screens`, its `crash_record` and its `Tools`. With none, the title is
314/// the game's one screen and its tools are `BasicTools`.
315pub fn stages_rs(stages: &[String]) -> String {
316    let head = "//! The game's stages, in the order its template's parts add them. `shuyi\n\
317                //! new` wrote this file from the parts it was made with: the title\n\
318                //! ([`crate::title`]) starts the first, and a stage's own part of the\n\
319                //! game's data is [`Own`].\n\n";
320    let mut out = head.to_owned();
321    match stages {
322        [] => out.push_str(
323            "use shuyi::prelude::*;\n\n\
324             use crate::Data;\n\n\
325             /// The stages' ids, in order: none yet.\n\
326             pub fn names() -> &'static [&'static str] {\n    &[]\n}\n\n\
327             /// The stages' own part of the game's data: none.\n\
328             pub type Own = ();\n\n\
329             /// Reads the stages' own part from the game's files.\n\
330             pub fn own(_: &Bundle) -> Result<Own, Vec<ContentError>> {\n    Ok(())\n}\n\n\
331             /// The first stage, which the title starts: none.\n\
332             pub fn first(_: &mut Ctx<Data>) -> Option<Box<dyn Screen<Data>>> {\n    None\n}\n\n\
333             /// Every screen the stages can show, for the check that the game's\n\
334             /// controls fit each target: none.\n\
335             pub fn screens(_: &mut Ctx<Data>) -> Vec<Box<dyn Screen<Data>>> {\n    Vec::new()\n}\n\n\
336             /// The game's record of its play for a crash report: none.\n\
337             pub fn crash_record() -> Option<shuyi::platform::CrashRecord<Data>> {\n    None\n}\n\n\
338             /// The game's tools: the engine's, for a game on the short path.\n\
339             #[cfg(feature = \"tools\")]\n\
340             pub fn tools() -> shuyi_cli::BasicTools<UiAction, Own> {\n    \
341             shuyi_cli::BasicTools::new(crate::game())\n}\n",
342        ),
343        [stage, ..] => {
344            let module = module_of(stage);
345            // Writing to a `String` can't fail.
346            for stage in stages {
347                let _ = writeln!(out, "pub mod {};", module_of(stage));
348            }
349            let names: Vec<String> = stages.iter().map(|s| format!("\"{s}\"")).collect();
350            let _ = write!(
351                out,
352                "\nuse shuyi::prelude::*;\n\n\
353                 use crate::Data;\n\n\
354                 /// The stages' ids, in order.\n\
355                 pub fn names() -> &'static [&'static str] {{\n    &[{names}]\n}}\n\n\
356                 /// The stages' own part of the game's data.\n\
357                 pub type Own = {module}::Own;\n\n\
358                 /// Reads the stages' own part from the game's files.\n\
359                 pub fn own(bundle: &Bundle) -> Result<Own, Vec<ContentError>> {{\n    \
360                 {module}::own(bundle)\n}}\n\n\
361                 /// The first stage, which the title starts.\n\
362                 pub fn first(ctx: &mut Ctx<Data>) -> Option<Box<dyn Screen<Data>>> {{\n    \
363                 Some({module}::first(ctx))\n}}\n\n\
364                 /// Every screen the stages can show, for the check that the game's\n\
365                 /// controls fit each target.\n\
366                 pub fn screens(ctx: &mut Ctx<Data>) -> Vec<Box<dyn Screen<Data>>> {{\n    \
367                 {module}::screens(ctx)\n}}\n\n\
368                 /// The game's record of its play for a crash report: its stage's.\n\
369                 pub fn crash_record() -> Option<shuyi::platform::CrashRecord<Data>> {{\n    \
370                 Some({module}::crash_record)\n}}\n\n\
371                 /// The game's tools: its stage's, which give the engine's for the rest.\n\
372                 #[cfg(feature = \"tools\")]\n\
373                 pub fn tools() -> {module}::Tools {{\n    \
374                 {module}::Tools::new(crate::game())\n}}\n",
375                names = names.join(", "),
376            );
377        }
378    }
379    out
380}
381
382/// What a file merges as, by its path; `None` if it doesn't.
383#[derive(Debug, Clone, Copy, PartialEq, Eq)]
384enum Merged {
385    Toml,
386    Ron,
387}
388
389impl Merged {
390    fn of(path: &str) -> Option<Self> {
391        if path == "Cargo.toml" || path.ends_with("/Cargo.toml") {
392            Some(Merged::Toml)
393        } else if Path::new(path)
394            .extension()
395            .is_some_and(|e| e.eq_ignore_ascii_case("ron"))
396        {
397            Some(Merged::Ron)
398        } else {
399            None
400        }
401    }
402}
403
404/// The one file that `writers` (each a part and its bytes) write at
405/// `path`: the only one's, or theirs merged.
406fn merged(path: &str, writers: Vec<(String, Vec<u8>)>) -> Result<Vec<u8>, ComposeError> {
407    let parts: Vec<String> = writers.iter().map(|(part, _)| part.clone()).collect();
408    if writers.len() == 1 {
409        return Ok(writers
410            .into_iter()
411            .next()
412            .map(|(_, b)| b)
413            .unwrap_or_default());
414    }
415    let Some(kind) = Merged::of(path) else {
416        return Err(ComposeError::SameFile {
417            path: path.to_owned(),
418            parts,
419        });
420    };
421    let failed = |at: String, message: String| ComposeError::Merge {
422        path: path.to_owned(),
423        parts: parts.clone(),
424        at,
425        message,
426    };
427    let mut texts = Vec::new();
428    for (part, bytes) in &writers {
429        let text = std::str::from_utf8(bytes)
430            .map_err(|e| failed(String::new(), format!("part `{part}`'s is not UTF-8: {e}")))?;
431        texts.push(text);
432    }
433    let mut out = texts[0].to_owned();
434    for text in &texts[1..] {
435        out = match kind {
436            Merged::Toml => merge_toml(&out, text),
437            Merged::Ron => ron_merge::merge(&out, text),
438        }
439        .map_err(|(at, message)| failed(at, message))?;
440    }
441    Ok(out.into_bytes())
442}
443
444/// The TOML `base` with `other`'s keys added, each with its comments;
445/// tables merged key by key, arrays item by item. A key both have with
446/// different values fails, with its path and both values.
447pub(super) fn merge_toml(base: &str, other: &str) -> Result<String, (String, String)> {
448    let mut doc: DocumentMut = base.parse().map_err(|e| (String::new(), format!("{e}")))?;
449    let other: DocumentMut = other.parse().map_err(|e| (String::new(), format!("{e}")))?;
450    merge_tables(doc.as_table_mut(), other.as_table(), "")?;
451    Ok(doc.to_string())
452}
453
454/// `from`'s keys merged into `into` ([`merge_toml`]); `at` is their path.
455fn merge_tables(into: &mut Table, from: &Table, at: &str) -> Result<(), (String, String)> {
456    for (key, item) in from {
457        let here = if at.is_empty() {
458            key.to_owned()
459        } else {
460            format!("{at}.{key}")
461        };
462        if let Some(existing) = into.get_mut(key) {
463            merge_items(existing, item, &here)?;
464        } else {
465            let formatted = from.key(key).cloned().unwrap_or_else(|| key.into());
466            into.insert_formatted(&formatted, item.clone());
467        }
468    }
469    Ok(())
470}
471
472/// A value with no comments or spaces round it, to compare.
473fn bare(value: &Value) -> String {
474    let mut value = value.clone();
475    value.decor_mut().clear();
476    value.to_string()
477}
478
479/// `from` merged into `into`, both at `at`.
480fn merge_items(into: &mut Item, from: &Item, at: &str) -> Result<(), (String, String)> {
481    match (into, from) {
482        (Item::Table(into), Item::Table(from)) => merge_tables(into, from, at),
483        (Item::Value(Value::Array(into)), Item::Value(Value::Array(from))) => {
484            for value in from {
485                if !into.iter().any(|v| bare(v) == bare(value)) {
486                    into.push_formatted(value.clone());
487                }
488            }
489            Ok(())
490        }
491        (Item::ArrayOfTables(into), Item::ArrayOfTables(from)) => {
492            for table in from {
493                if !into.iter().any(|t| t.to_string() == table.to_string()) {
494                    into.push(table.clone());
495                }
496            }
497            Ok(())
498        }
499        (into, from) if into.is_table_like() && from.is_table_like() => {
500            let (Some(into), Some(from)) = (into.as_table_like_mut(), from.as_table_like()) else {
501                return Ok(());
502            };
503            for (key, item) in from.iter() {
504                let here = format!("{at}.{key}");
505                match into.get_mut(key) {
506                    Some(existing) => merge_items(existing, item, &here)?,
507                    None => {
508                        into.insert(key, item.clone());
509                    }
510                }
511            }
512            Ok(())
513        }
514        (into, from) => {
515            let shown = |item: &Item| match item.as_value() {
516                Some(value) => bare(value),
517                None => item.to_string().trim().to_owned(),
518            };
519            let (a, b) = (shown(into), shown(from));
520            if a == b {
521                Ok(())
522            } else {
523                Err((at.to_owned(), format!("`{a}` in one, `{b}` in another")))
524            }
525        }
526    }
527}
528
529/// The parts of `template` picked by `answers`, checked against each
530/// other's `requires` and `conflicts`.
531fn picked<'a>(template: &'a Template, answers: &Answers) -> Result<Vec<&'a Part>, ComposeError> {
532    let parts: Vec<&Part> = template
533        .parts
534        .iter()
535        .filter(|part| part.when.holds(answers))
536        .collect();
537    for part in &parts {
538        if let Some(missing) = part
539            .requires
540            .iter()
541            .find(|id| !parts.iter().any(|p| &p.id == *id))
542        {
543            return Err(ComposeError::Requires {
544                part: part.id.clone(),
545                requires: missing.clone(),
546                because: part.when.because(answers),
547            });
548        }
549        if let Some(other) = parts.iter().find(|p| part.conflicts.contains(&p.id)) {
550            return Err(ComposeError::Conflict {
551                parts: [part.id.clone(), other.id.clone()],
552                because: format!(
553                    "`{}` is picked because {}, and `{}` because {}",
554                    part.id,
555                    part.when.because(answers),
556                    other.id,
557                    other.when.because(answers)
558                ),
559            });
560        }
561    }
562    Ok(parts)
563}
564
565/// Every file the parts write, by its path from the game's folder, with
566/// each part that writes it and its bytes, the placeholders filled.
567fn written(
568    template: &Template,
569    parts: &[&Part],
570    with: &Fill,
571    answers: &Answers,
572) -> Result<Written, ComposeError> {
573    let mut written = Written::new();
574    for part in parts {
575        let folder = folder_of(&part.files).map_err(|message| ComposeError::Part {
576            part: part.id.clone(),
577            message,
578        })?;
579        let files = template
580            .files
581            .range(folder.clone()..)
582            .take_while(|(path, _)| path.starts_with(&folder));
583        for (path, bytes) in files {
584            let source = format!("{}{}", folder, &path[folder.len()..]);
585            let to = fill(&source, &path[folder.len()..], with, answers)?;
586            let bytes = match std::str::from_utf8(bytes) {
587                Ok(text) => fill(&source, text, with, answers)?.into_bytes(),
588                Err(_) => bytes.clone(),
589            };
590            written
591                .entry(to)
592                .or_default()
593                .push((part.id.clone(), bytes));
594        }
595    }
596    Ok(written)
597}
598
599/// The stages the parts add, with the part of each, checked for the flow;
600/// and the list of them written, if the template has a part with a stage.
601fn stages(
602    template: &Template,
603    parts: &[&Part],
604    written: &mut Written,
605) -> Result<Vec<String>, ComposeError> {
606    let with_part: Vec<(&str, &str)> = parts
607        .iter()
608        .filter_map(|p| p.stage.as_deref().map(|stage| (p.id.as_str(), stage)))
609        .collect();
610    if !template.parts.iter().any(|p| p.stage.is_some()) {
611        return Ok(Vec::new());
612    }
613    if let Some(writers) = written.get(GENERATED_STAGES) {
614        return Err(ComposeError::Stage {
615            parts: writers.iter().map(|(p, _)| p.clone()).collect(),
616            message: format!(
617                "{GENERATED_STAGES} is written by {COMPOSER} from the parts' stages, not by a part"
618            ),
619        });
620    }
621    if with_part.len() > 1 {
622        return Err(ComposeError::Stage {
623            parts: with_part.iter().map(|(p, _)| (*p).to_owned()).collect(),
624            message: "each adds a stage, and a game has one stage for now: pick one".to_owned(),
625        });
626    }
627    for (part, stage) in &with_part {
628        let module = format!("crates/game/src/stages/{}.rs", module_of(stage));
629        if !written.contains_key(&module) {
630            return Err(ComposeError::Stage {
631                parts: vec![(*part).to_owned()],
632                message: format!("the part adds the stage `{stage}` but writes no {module}"),
633            });
634        }
635    }
636    let stages: Vec<String> = with_part.iter().map(|(_, s)| (*s).to_owned()).collect();
637    written.insert(
638        GENERATED_STAGES.to_owned(),
639        vec![(COMPOSER.to_owned(), stages_rs(&stages).into_bytes())],
640    );
641    Ok(stages)
642}
643
644/// The game `template` makes for `answers` (each question they leave out
645/// at its default), with the engine's modules in `catalog` and the
646/// placeholders filled `with`: its files and their bytes, and what its
647/// parts turn on. The same input always gives the same plan.
648pub fn compose(
649    template: &Template,
650    answers: &Answers,
651    catalog: &Catalog,
652    with: &Fill,
653) -> Result<Plan, ComposeError> {
654    template
655        .check_answers(answers)
656        .map_err(|message| ComposeError::Answer { message })?;
657    let answers = template.with_defaults(answers);
658    let parts = picked(template, &answers)?;
659    let mut written = written(template, &parts, with, &answers)?;
660    let stages = stages(template, &parts, &mut written)?;
661
662    let mut files = BTreeMap::new();
663    for (path, writers) in written {
664        let bytes = merged(&path, writers)?;
665        files.insert(PathBuf::from(path), bytes);
666    }
667    let skills = with_skills(catalog, &mut files);
668
669    let mut modules = Vec::new();
670    let mut features = Vec::new();
671    for part in &parts {
672        for name in &part.modules {
673            let module = catalog.module(name).ok_or_else(|| ComposeError::Module {
674                part: part.id.clone(),
675                module: name.clone(),
676                message: crate::contract::add::unknown(catalog, name),
677            })?;
678            if !modules.contains(&module.name) {
679                modules.push(module.name.clone());
680                features.push((part.id.clone(), module.feature().to_owned()));
681            }
682        }
683    }
684    let root = PathBuf::from("Cargo.toml");
685    for (part, feature) in &features {
686        let failed = |message: String| ComposeError::Module {
687            part: part.clone(),
688            module: catalog::module_of(feature),
689            message,
690        };
691        let manifest = files
692            .get(&root)
693            .ok_or_else(|| failed("the game has no Cargo.toml to turn it on in".to_owned()))?;
694        let text = String::from_utf8_lossy(manifest).into_owned();
695        if let Some(edited) = catalog::turn_on(&text, feature).map_err(failed)? {
696            files.insert(root.clone(), edited.into_bytes());
697        }
698    }
699
700    let mut targets: Vec<String> = Vec::new();
701    for target in parts.iter().flat_map(|p| &p.targets) {
702        if !targets.contains(target) {
703            targets.push(target.clone());
704        }
705    }
706    let mut limits = Vec::new();
707    for (question, picked) in &answers.0 {
708        for answer in picked {
709            let served = template.parts.iter().any(|p| {
710                p.when
711                    .picks()
712                    .contains(&(question.as_str(), answer.as_str()))
713            });
714            if !served {
715                limits.push(format!(
716                    "no part of the template for `{question}` = `{answer}`: nothing added"
717                ));
718            }
719        }
720    }
721    Ok(Plan {
722        files,
723        parts: parts.iter().map(|p| p.id.clone()).collect(),
724        modules,
725        targets,
726        stages,
727        skills,
728        answers,
729        limits,
730    })
731}
732
733/// The skills of `catalog`'s pack added to `files` under `.claude/skills/`,
734/// each with every file of its folder, and their names: all but a module's
735/// skill, and a skill whose folder a part already writes.
736fn with_skills(catalog: &Catalog, files: &mut BTreeMap<PathBuf, Vec<u8>>) -> Vec<String> {
737    let mut added = Vec::new();
738    for skill in &catalog.skills.skills {
739        let folder = format!("{}/{}/", catalog::SKILLS, skill.name);
740        let written = files.keys().any(|p| shown(p).starts_with(&folder));
741        if catalog.is_module(&skill.name) || written {
742            continue;
743        }
744        for (path, bytes) in &skill.files {
745            files.insert(PathBuf::from(format!("{folder}{path}")), bytes.clone());
746        }
747        added.push(skill.name.clone());
748    }
749    added
750}
751
752/// What [`apply`] wrote.
753#[derive(Debug, Clone, Default, PartialEq, Eq)]
754pub struct Applied {
755    /// Every file written, from the folder, `/`-separated, in order.
756    pub written: Vec<String>,
757    /// Those that were there already, and were written over.
758    pub overwritten: Vec<String>,
759}
760
761/// Writes `plan`'s files into `dir`, made if missing. A folder that isn't
762/// empty is refused unless `force`, with the files it would write over;
763/// with `force`, [`Applied::overwritten`] lists them.
764pub fn apply(plan: &Plan, dir: &Path, force: bool) -> Result<Applied, String> {
765    let overwritten: Vec<String> = plan
766        .files
767        .keys()
768        .filter(|path| dir.join(path).exists())
769        .map(|p| shown(p))
770        .collect();
771    let empty = fs::read_dir(dir).map_or(true, |mut entries| entries.next().is_none());
772    if !empty && !force {
773        let over = if overwritten.is_empty() {
774            String::new()
775        } else {
776            format!(" (it would write over {})", overwritten.join(", "))
777        };
778        return Err(format!("is not empty{over}: pass --force to write into it"));
779    }
780    for (path, bytes) in &plan.files {
781        let to = dir.join(path);
782        if let Some(parent) = to.parent() {
783            fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
784        }
785        fs::write(&to, bytes).map_err(|e| format!("{}: {e}", to.display()))?;
786    }
787    Ok(Applied {
788        written: plan.paths(),
789        overwritten,
790    })
791}