Skip to main content

shuyi_cli/contract/
add.rs

1//! `add <module>`: a module of the game's engine turned on (ADR-0009
2//! §6). It:
3//!
4//! 1. turns on the module's feature of the `shuyi` crate in the game's
5//!    `Cargo.toml`, keeping its layout ([`catalog::turn_on`]);
6//! 2. copies the module's skill into the game's skills (`.claude/skills`),
7//!    if the game keeps them ([`catalog::copy_skill`]);
8//! 3. gives the first paragraph of the module's README: what it is for;
9//! 4. says so when the game already has one of its alternatives on, with
10//!    the README's section on using both ([`BESIDE`]);
11//! 5. gives the line to add when the game's code never installs the
12//!    modules it turns on (`shuyi::enabled_modules`), and the line for the
13//!    game's `formats()` when the module adds formats.
14//!
15//! Run twice, the second run changes nothing and says so (`changed:
16//! false`). A name that is no module of the engine fails with
17//! [`codes::UNKNOWN_MODULE`] and the names there are.
18
19use std::fmt::Write as _;
20use std::fs;
21use std::path::Path;
22
23use schemars::JsonSchema;
24use serde::{Deserialize, Serialize};
25
26use super::{Answer, Op, codes};
27use crate::catalog::{self, Catalog, SkillCopy};
28use crate::project::Project;
29
30/// The anchor of a module README's section on sitting beside its
31/// alternatives, and moving between them (its fifth, ADR-0009 §4):
32/// `## Beside its alternatives`.
33pub const BESIDE: &str = "beside-its-alternatives";
34
35/// What `add` turns on.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
37#[serde(deny_unknown_fields)]
38// An input: built with `AddInput::new` (a module has no default) and its
39// fields assigned, it may grow (ADR-0039).
40#[non_exhaustive]
41pub struct AddInput {
42    /// The module, by its crate's name with or without `shuyi-`
43    /// (`physics` or `shuyi-physics`).
44    pub module: String,
45}
46
47impl AddInput {
48    /// The module named `module`.
49    pub fn new(module: impl Into<String>) -> Self {
50        Self {
51            module: module.into(),
52        }
53    }
54}
55
56/// What `add` did, and what is left to the game.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
58#[serde(deny_unknown_fields)]
59pub struct AddOutput {
60    /// The module's crate (`shuyi-physics`).
61    pub module: String,
62    /// Its feature of the `shuyi` crate (`physics`).
63    pub feature: String,
64    /// Whether the game's `Cargo.toml` changed; `false` when the module
65    /// was on already.
66    pub changed: bool,
67    /// Where its skill was copied, from the game's folder; absent when
68    /// none was (the limits say why).
69    pub skill: Option<String>,
70    /// What it is for: its README's first paragraph.
71    pub about: String,
72    /// Its alternatives the game already has on.
73    pub alternatives_on: Vec<String>,
74    /// Its README's section on sitting beside them, when one is on.
75    pub beside: Option<String>,
76    /// The line to add to the game's code, when it never installs the
77    /// modules it turns on.
78    pub install: Option<String>,
79    /// The line for the game's `formats()`, when the module adds formats.
80    pub formats: Option<String>,
81}
82
83/// `add` on the game of `project`, in the engine it builds with.
84pub fn add(project: &Project, input: &AddInput) -> Answer<AddOutput> {
85    match catalog::engine_root(&project.root).and_then(|root| Catalog::read(&root)) {
86        Ok(catalog) => add_in(&project.root, &catalog, input),
87        Err(e) => Answer::failed(Op::Add, codes::ENGINE_DEPENDENCY, e),
88    }
89}
90
91/// `add` on the game at `game`, with the engine's `catalog`.
92pub fn add_in(game: &Path, catalog: &Catalog, input: &AddInput) -> Answer<AddOutput> {
93    match added(game, catalog, input) {
94        Ok((output, limits)) => Answer::new(Op::Add, true, output, limits),
95        Err((code, e)) => Answer::failed(Op::Add, code, e),
96    }
97}
98
99/// Why `name` is no module of `catalog`: one it plans, or the names there
100/// are.
101pub(crate) fn unknown(catalog: &Catalog, name: &str) -> String {
102    let module = if name.starts_with("shuyi-") {
103        name.to_owned()
104    } else {
105        catalog::module_of(name)
106    };
107    let planned: Vec<String> = catalog
108        .concerns
109        .answered_by(&module)
110        .map(|(_, answer)| {
111            format!(
112                "{} is {} ({})",
113                answer.name,
114                answer.status.name(),
115                answer.tickets.join(", ")
116            )
117        })
118        .collect();
119    if !planned.is_empty() {
120        return format!(
121            "{module} is in the catalog but not built yet: {}",
122            planned.join("; ")
123        );
124    }
125    let known: Vec<&str> = catalog.modules.iter().map(|m| m.name.as_str()).collect();
126    if known.is_empty() {
127        format!("no module {name}: the game's engine builds no module yet")
128    } else {
129        format!(
130            "no module {name}: the game's engine builds {}",
131            known.join(", ")
132        )
133    }
134}
135
136/// What `add` did to the game at `game`, with its limits; or the problem
137/// code and why it couldn't.
138fn added(
139    game: &Path,
140    catalog: &Catalog,
141    input: &AddInput,
142) -> Result<(AddOutput, Vec<String>), (codes::Code, String)> {
143    let module = catalog
144        .module(&input.module)
145        .ok_or_else(|| (codes::UNKNOWN_MODULE, unknown(catalog, &input.module)))?;
146    let about = catalog::readme_paragraph(&module.dir).map_err(|e| (codes::FILE, e))?;
147    let on = super::modules::on_in(game)?;
148    let manifest = game.join("Cargo.toml");
149    let text = fs::read_to_string(&manifest)
150        .map_err(|e| (codes::FILE, format!("{}: {e}", manifest.display())))?;
151    let edited =
152        catalog::turn_on(&text, module.feature()).map_err(|e| (codes::ENGINE_DEPENDENCY, e))?;
153    if let Some(edited) = &edited {
154        fs::write(&manifest, edited)
155            .map_err(|e| (codes::FILE, format!("{}: {e}", manifest.display())))?;
156    }
157
158    let mut limits =
159        vec!["only the game's root Cargo.toml was read for the modules it has on".to_owned()];
160    let skill = match catalog::skill_of(&module.dir, &module.name) {
161        None => {
162            limits.push(format!("{} has no skill to copy", module.name));
163            None
164        }
165        Some(skill) => match catalog::copy_skill(&skill, game, &module.name) {
166            Ok(SkillCopy::Copied(_)) => Some(format!("{}/{}", catalog::SKILLS, module.name)),
167            Ok(SkillCopy::AlreadyThere(_)) => {
168                limits.push(format!(
169                    "the game already has a skill {} in {}: it was left as it is",
170                    module.name,
171                    catalog::SKILLS
172                ));
173                None
174            }
175            Ok(SkillCopy::NoSkills) => {
176                limits.push(format!(
177                    "the game keeps no skills ({} isn't there): {}'s skill wasn't copied",
178                    catalog::SKILLS,
179                    module.name
180                ));
181                None
182            }
183            Err(e) => return Err((codes::FILE, e)),
184        },
185    };
186
187    let alternatives_on: Vec<String> = on
188        .iter()
189        .map(|feature| catalog::module_of(feature))
190        .filter(|other| module.info.alternatives.contains(other))
191        .collect();
192    let beside = (!alternatives_on.is_empty()).then(|| {
193        let readme = module.dir.join("README.md");
194        format!("{}#{BESIDE}", readme.to_string_lossy().replace('\\', "/"))
195    });
196    let install = (!catalog::installs_modules(game)).then(|| {
197        "add `.modules(shuyi::enabled_modules)` to the game's `BasicGame`, or \
198         `.with_modules(shuyi::enabled_modules())` to its `Game`: every module it turns on then \
199         joins it"
200            .to_owned()
201    });
202    let formats = module.info.formats.as_ref().map(|function| {
203        format!(
204            "call `{function}` in the game's `formats()`: its formats are read with the game's \
205             data, before its `Game` exists"
206        )
207    });
208    let output = AddOutput {
209        module: module.name.clone(),
210        feature: module.feature().to_owned(),
211        changed: edited.is_some(),
212        skill,
213        about,
214        alternatives_on,
215        beside,
216        install,
217        formats,
218    };
219    Ok((output, limits))
220}
221
222/// `add`'s usage.
223pub fn usage(command: &str) -> String {
224    format!(
225        "usage: {command} add <module> [--json]\n\n\
226         Turns on a module of the game's engine (`physics` or `shuyi-physics`): its feature\n\
227         of `shuyi` in Cargo.toml, its skill copied, what it is for. `{command} modules`\n\
228         lists them."
229    )
230}
231
232/// Reads `add`'s arguments (not `--json`).
233pub fn parse_args(args: &[String]) -> Result<AddInput, String> {
234    match args {
235        [module] if !module.starts_with("--") => Ok(AddInput::new(module.clone())),
236        _ => Err("one <module>, nothing else".to_owned()),
237    }
238}
239
240/// What `add` did, for people.
241pub fn text(output: &AddOutput) -> String {
242    let mut out = if output.changed {
243        format!(
244            "add: {} on (the feature `{}` of shuyi in Cargo.toml)\n",
245            output.module, output.feature
246        )
247    } else {
248        format!("add: {} was already on: nothing changed\n", output.module)
249    };
250    for line in output.about.lines() {
251        // Writing to a `String` can't fail.
252        let _ = writeln!(out, "  {line}");
253    }
254    if let Some(skill) = &output.skill {
255        let _ = writeln!(out, "skill: copied to {skill}");
256    }
257    if !output.alternatives_on.is_empty() {
258        let _ = writeln!(
259            out,
260            "warning: the game already has {} on, an alternative: two alternatives never drive \
261             the same thing in one stage{}",
262            output.alternatives_on.join(", "),
263            output
264                .beside
265                .as_ref()
266                .map(|beside| format!("; see {beside}"))
267                .unwrap_or_default()
268        );
269    }
270    for to_do in output.install.iter().chain(&output.formats) {
271        let _ = writeln!(out, "to do: {to_do}");
272    }
273    out
274}
275
276#[cfg(test)]
277mod tests;