Skip to main content

shuyi_cli/
catalog.rs

1//! The engine's modules as the tools read them (ADR-0009 §2, §4, §6),
2//! for `modules` and `add` ([`crate::contract::modules`],
3//! [`crate::contract::add`]):
4//!
5//! - [`Concerns`]: the concerns a game answers and their answers, the
6//!   engine's `docs/modules.ron`;
7//! - [`ModuleInfo`]: what a module's crate says it answers, its
8//!   `[package.metadata.shuyi]`, read with its crate into a
9//!   [`ModuleCrate`];
10//! - [`Catalog`]: the two read from an engine checkout, with its skill
11//!   pack, and [`engine_root`], which finds the checkout a game builds with;
12//! - a game's `Cargo.toml`: the `shuyi` features it has on
13//!   ([`features_on`]), and one more turned on ([`turn_on`]);
14//! - a module's package folder: the first paragraph of its README
15//!   ([`readme_paragraph`]) and its skill ([`skill_of`], [`copy_skill`]).
16//!
17//! A module's feature of the `shuyi` crate is its crate's name without
18//! `shuyi-` ([`feature_of`]).
19
20use std::fs;
21use std::path::{Path, PathBuf};
22use std::process::Command;
23
24use schemars::JsonSchema;
25use serde::{Deserialize, Serialize};
26use toml_edit::{Array, DocumentMut, InlineTable, Item, TableLike, Value};
27
28use crate::skills::{self, Pack};
29
30/// The concerns, from the engine's root.
31pub const CONCERNS: &str = "docs/modules.ron";
32
33/// Where a game keeps its agent's skills, from its folder.
34pub const SKILLS: &str = ".claude/skills";
35
36/// What the name of every engine crate starts with but the front door's.
37const PREFIX: &str = "shuyi-";
38
39/// The crates of the default engine, which are no modules.
40pub const DEFAULT_CRATES: [&str; 7] = [
41    "shuyi-core",
42    "shuyi-assets",
43    "shuyi-ui",
44    "shuyi-platform",
45    "shuyi-app",
46    "shuyi-cli",
47    "shuyi-alloc-count",
48];
49
50/// The concerns a game answers, `docs/modules.ron`.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
52#[serde(deny_unknown_fields)]
53pub struct Concerns {
54    /// Each concern, in the catalog's order.
55    pub concerns: Vec<Concern>,
56}
57
58/// A question every game answers one way or another.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
60#[serde(deny_unknown_fields)]
61pub struct Concern {
62    /// What a module's metadata names (`bodies`).
63    pub id: String,
64    /// How the catalog says it (`Bodies and motion`).
65    pub name: String,
66    /// Whether its answers are alternatives, never driving the same
67    /// thing in one stage, so that two modules answering it name each
68    /// other; `false` when they can share a stage.
69    pub alternatives: bool,
70    /// Its answers.
71    pub answers: Vec<Choice>,
72}
73
74/// One answer to a concern.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
76#[serde(deny_unknown_fields)]
77pub struct Choice {
78    /// What a module's metadata names (`rigid-body`).
79    pub id: String,
80    /// How the catalog says it (`rigid-body physics`).
81    pub name: String,
82    /// Who answers it: `default` (a kit of the default engine) or a
83    /// module's crate (`shuyi-physics`).
84    pub by: String,
85    /// Whether it is built.
86    pub status: Status,
87    /// The tickets that build it.
88    pub tickets: Vec<String>,
89}
90
91/// Whether an answer or a module is built.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
93#[serde(rename_all = "kebab-case")]
94pub enum Status {
95    /// Built, with an example passing CI.
96    Ready,
97    /// Ticketed, not built yet.
98    Planned,
99    /// Not started until the plan says.
100    Parked,
101}
102
103impl Status {
104    /// Its name (`parked`).
105    pub fn name(self) -> &'static str {
106        match self {
107            Status::Ready => "ready",
108            Status::Planned => "planned",
109            Status::Parked => "parked",
110        }
111    }
112}
113
114impl Concerns {
115    /// `docs/modules.ron`'s text, read.
116    pub fn parse(text: &str) -> Result<Self, String> {
117        ron::from_str(text).map_err(|e| format!("{CONCERNS}: {e}"))
118    }
119
120    /// The concern `concern`'s answer `answer`, if both are there.
121    pub fn choice(&self, concern: &str, answer: &str) -> Option<&Choice> {
122        self.concerns
123            .iter()
124            .find(|c| c.id == concern)?
125            .answers
126            .iter()
127            .find(|a| a.id == answer)
128    }
129
130    /// Every answer `by` gives, with its concern.
131    pub fn answered_by<'a>(
132        &'a self,
133        by: &'a str,
134    ) -> impl Iterator<Item = (&'a Concern, &'a Choice)> {
135        self.concerns.iter().flat_map(move |concern| {
136            concern
137                .answers
138                .iter()
139                .filter(move |answer| answer.by == by)
140                .map(move |answer| (concern, answer))
141        })
142    }
143}
144
145/// A module's `[package.metadata.shuyi]` (ADR-0009 §4): what it answers,
146/// what it is built for, and what it sits beside.
147#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
148#[serde(deny_unknown_fields)]
149pub struct ModuleInfo {
150    /// The concerns it answers, and how: one module may answer several
151    /// (`[{ concern = "bodies", answer = "rigid-body" }]`).
152    pub answers: Vec<Answered>,
153    /// The directions it is built for (`side-view`).
154    pub serves: Vec<String>,
155    /// The modules that answer one of its concerns another way, by crate.
156    #[serde(default)]
157    pub alternatives: Vec<String>,
158    /// The modules it has been tested beside in one stage, by crate.
159    #[serde(default)]
160    pub works_with: Vec<String>,
161    /// The function a game's `formats()` calls to add the module's
162    /// formats, if it has any (`shuyi_crawl::formats`).
163    #[serde(default)]
164    pub formats: Option<String>,
165}
166
167/// One concern a module answers, and its answer.
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
169#[serde(deny_unknown_fields)]
170pub struct Answered {
171    /// The concern's id, in `docs/modules.ron`.
172    pub concern: String,
173    /// The answer's id under it.
174    pub answer: String,
175}
176
177/// A module's crate: its name, its package folder and its metadata.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct ModuleCrate {
180    /// Its package's name (`shuyi-physics`).
181    pub name: String,
182    /// The folder of its `Cargo.toml`.
183    pub dir: PathBuf,
184    /// Its metadata.
185    pub info: ModuleInfo,
186}
187
188/// The parts of a `Cargo.toml` a module is read from.
189#[derive(Deserialize)]
190struct Manifest {
191    package: Option<ManifestPackage>,
192}
193
194#[derive(Deserialize)]
195struct ManifestPackage {
196    name: String,
197    #[serde(default)]
198    metadata: Option<ManifestMetadata>,
199}
200
201#[derive(Deserialize)]
202struct ManifestMetadata {
203    #[serde(default)]
204    shuyi: Option<ModuleInfo>,
205}
206
207impl ModuleCrate {
208    /// The module whose package folder is `dir`, or `None` for a crate
209    /// with no `[package.metadata.shuyi]`.
210    pub fn read(dir: &Path) -> Result<Option<Self>, String> {
211        let path = dir.join("Cargo.toml");
212        let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
213        let manifest: Manifest =
214            toml_edit::de::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
215        let Some(package) = manifest.package else {
216            return Ok(None);
217        };
218        Ok(package
219            .metadata
220            .and_then(|metadata| metadata.shuyi)
221            .map(|info| Self {
222                name: package.name,
223                dir: dir.to_path_buf(),
224                info,
225            }))
226    }
227
228    /// Its feature of the `shuyi` crate.
229    pub fn feature(&self) -> &str {
230        feature_of(&self.name)
231    }
232}
233
234/// The feature of the `shuyi` crate that turns on module `name`: its
235/// name without `shuyi-` (`physics`).
236pub fn feature_of(name: &str) -> &str {
237    name.strip_prefix(PREFIX).unwrap_or(name)
238}
239
240/// The module that feature `feature` of the `shuyi` crate turns on.
241pub fn module_of(feature: &str) -> String {
242    format!("{PREFIX}{feature}")
243}
244
245/// What the tools know of an engine checkout: its concerns, the modules
246/// it builds and its skill pack.
247#[derive(Debug, Clone, PartialEq, Eq)]
248pub struct Catalog {
249    /// The checkout's folder (the one holding `crates/`).
250    pub root: PathBuf,
251    /// Its `docs/modules.ron`.
252    pub concerns: Concerns,
253    /// Its modules: every crate under `crates/` with
254    /// `[package.metadata.shuyi]`, by name.
255    pub modules: Vec<ModuleCrate>,
256    /// Its skill pack, `skills/` (empty in a checkout without one).
257    pub skills: Pack,
258}
259
260impl Catalog {
261    /// The catalog of the engine checkout at `root`.
262    pub fn read(root: &Path) -> Result<Self, String> {
263        let path = root.join(CONCERNS);
264        let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
265        let concerns = Concerns::parse(&text)?;
266        let crates = root.join("crates");
267        let entries = fs::read_dir(&crates).map_err(|e| format!("{}: {e}", crates.display()))?;
268        let mut modules = Vec::new();
269        for entry in entries.flatten() {
270            let dir = entry.path();
271            if dir.join("Cargo.toml").is_file()
272                && let Some(module) = ModuleCrate::read(&dir)?
273                && !DEFAULT_CRATES.contains(&module.name.as_str())
274            {
275                modules.push(module);
276            }
277        }
278        modules.sort_by(|a, b| a.name.cmp(&b.name));
279        Ok(Self {
280            root: root.to_path_buf(),
281            concerns,
282            modules,
283            skills: Pack::read(&root.join(skills::PACK))?,
284        })
285    }
286
287    /// The catalog of the engine this `shuyi` was built from, as it embeds
288    /// it, for a game made with no checkout: its concerns, its modules by
289    /// the `shuyi` crate's features, one a module, with no metadata, and its
290    /// skill pack.
291    pub fn embedded() -> Result<Self, String> {
292        let concerns = Concerns::parse(include_str!("../../../docs/modules.ron"))?;
293        Ok(Self {
294            root: PathBuf::new(),
295            concerns,
296            modules: modules_of_features(include_str!("../../shuyi/Cargo.toml"))?,
297            skills: Pack::embedded(),
298        })
299    }
300
301    /// The module named `name`, with or without `shuyi-`.
302    pub fn module(&self, name: &str) -> Option<&ModuleCrate> {
303        self.modules
304            .iter()
305            .find(|m| m.name == name || feature_of(&m.name) == name)
306    }
307
308    /// Whether `name` is a module's crate: one the engine builds, or one a
309    /// concern names as an answer's (`shuyi-net`), built or not. A
310    /// module's skill is named after its crate, and a game gets it with
311    /// `shuyi add`, not `shuyi new`.
312    pub fn is_module(&self, name: &str) -> bool {
313        name != "default"
314            && (self.modules.iter().any(|m| m.name == name)
315                || self.concerns.answered_by(name).next().is_some())
316    }
317}
318
319/// The modules the `shuyi` crate's `Cargo.toml` (`manifest`) has a
320/// feature for, one a feature but `default`, by name, with no metadata.
321pub fn modules_of_features(manifest: &str) -> Result<Vec<ModuleCrate>, String> {
322    let doc: DocumentMut = manifest
323        .parse()
324        .map_err(|e| format!("crates/shuyi/Cargo.toml: {e}"))?;
325    let mut modules: Vec<ModuleCrate> = doc
326        .get("features")
327        .and_then(Item::as_table_like)
328        .map(|features| {
329            features
330                .iter()
331                .map(|(feature, _)| feature)
332                .filter(|feature| *feature != "default")
333                .map(|feature| ModuleCrate {
334                    name: module_of(feature),
335                    dir: PathBuf::from("crates").join(feature),
336                    info: ModuleInfo::default(),
337                })
338                .collect()
339        })
340        .unwrap_or_default();
341    modules.sort_by(|a, b| a.name.cmp(&b.name));
342    Ok(modules)
343}
344
345/// The entry of the `shuyi` crate in a `Cargo.toml`: under
346/// `[workspace.dependencies]` (a game made by `shuyi new`), or else under
347/// `[dependencies]`.
348fn shuyi_entry(doc: &mut DocumentMut) -> Option<&mut Item> {
349    // Looked at first: `Item::get_mut` adds a key that is missing.
350    let in_workspace = doc
351        .get("workspace")
352        .and_then(|w| w.get("dependencies"))
353        .and_then(|d| d.get("shuyi"))
354        .is_some();
355    let in_dependencies = doc
356        .get("dependencies")
357        .and_then(|d| d.get("shuyi"))
358        .is_some();
359    if in_workspace {
360        doc.get_mut("workspace")?
361            .get_mut("dependencies")?
362            .get_mut("shuyi")
363    } else if in_dependencies {
364        doc.get_mut("dependencies")?.get_mut("shuyi")
365    } else {
366        None
367    }
368}
369
370/// The text of a `Cargo.toml` that has no `shuyi` entry, as an error.
371fn no_entry() -> String {
372    "the game's Cargo.toml has no `shuyi` dependency (under [workspace.dependencies] or \
373     [dependencies]): a game turns modules on through the engine's `shuyi` crate"
374        .to_owned()
375}
376
377/// The features listed in an entry's table, if it is a table.
378fn features_in(entry: &Item) -> Vec<String> {
379    entry
380        .as_table_like()
381        .and_then(|table| table.get("features"))
382        .and_then(Item::as_array)
383        .map(|features| {
384            features
385                .iter()
386                .filter_map(|f| f.as_str().map(str::to_owned))
387                .collect()
388        })
389        .unwrap_or_default()
390}
391
392/// The features of the `shuyi` crate a `Cargo.toml`'s text turns on.
393pub fn features_on(manifest: &str) -> Result<Vec<String>, String> {
394    let mut doc: DocumentMut = manifest.parse().map_err(|e| format!("Cargo.toml: {e}"))?;
395    let entry = shuyi_entry(&mut doc).ok_or_else(no_entry)?;
396    Ok(features_in(entry))
397}
398
399/// A `Cargo.toml`'s text with feature `feature` of the `shuyi` crate
400/// turned on, its layout kept; `None` if it is already on.
401pub fn turn_on(manifest: &str, feature: &str) -> Result<Option<String>, String> {
402    let mut doc: DocumentMut = manifest.parse().map_err(|e| format!("Cargo.toml: {e}"))?;
403    let entry = shuyi_entry(&mut doc).ok_or_else(no_entry)?;
404    if features_in(entry).iter().any(|f| f == feature) {
405        return Ok(None);
406    }
407    // A bare version becomes a table that keeps it.
408    if let Some(version) = entry.as_str() {
409        let mut table = InlineTable::new();
410        table.insert("version", Value::from(version));
411        *entry = Item::Value(Value::InlineTable(table));
412    }
413    let table: &mut dyn TableLike = entry
414        .as_table_like_mut()
415        .ok_or("the game's `shuyi` dependency is neither a version nor a table")?;
416    if let Some(features) = table.get_mut("features").and_then(Item::as_array_mut) {
417        features.push(feature);
418    } else {
419        let mut features = Array::new();
420        features.push(feature);
421        table.insert("features", Item::Value(Value::Array(features)));
422    }
423    // An inline table laid out again, so the new key sits as the others.
424    if let Some(table) = entry.as_inline_table_mut() {
425        table.fmt();
426    }
427    Ok(Some(doc.to_string()))
428}
429
430/// The engine checkout the game at `game` builds with: the folder above
431/// the `shuyi` crate its `Cargo.toml` names by path, or else the one
432/// cargo resolved it to (`cargo metadata`, run through the `CARGO`
433/// variable).
434pub fn engine_root(game: &Path) -> Result<PathBuf, String> {
435    let path = game.join("Cargo.toml");
436    let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
437    let mut doc: DocumentMut = text.parse().map_err(|e| format!("Cargo.toml: {e}"))?;
438    let entry = shuyi_entry(&mut doc).ok_or_else(no_entry)?;
439    let by_path = entry
440        .as_table_like()
441        .and_then(|table| table.get("path"))
442        .and_then(Item::as_str)
443        .map(|dir| game.join(dir));
444    let shuyi = match by_path {
445        Some(dir) => dir,
446        None => resolved_shuyi(game)?,
447    };
448    shuyi
449        .parent()
450        .and_then(Path::parent)
451        .map(Path::to_path_buf)
452        .ok_or_else(|| format!("{}: no engine checkout above it", shuyi.display()))
453}
454
455/// The folder cargo resolved the game's `shuyi` crate to.
456fn resolved_shuyi(game: &Path) -> Result<PathBuf, String> {
457    #[derive(Deserialize)]
458    struct Metadata {
459        packages: Vec<Package>,
460    }
461    #[derive(Deserialize)]
462    struct Package {
463        name: String,
464        manifest_path: PathBuf,
465    }
466    let cargo = crate::cargo();
467    let output = Command::new(cargo)
468        .args(["metadata", "--format-version", "1"])
469        .current_dir(game)
470        .output()
471        .map_err(|e| format!("cargo metadata does not run: {e}"))?;
472    if !output.status.success() {
473        return Err(format!(
474            "cargo metadata failed: {}",
475            String::from_utf8_lossy(&output.stderr).trim()
476        ));
477    }
478    let metadata: Metadata = serde_json::from_slice(&output.stdout)
479        .map_err(|e| format!("cargo metadata's output: {e}"))?;
480    metadata
481        .packages
482        .into_iter()
483        .find(|p| p.name == "shuyi")
484        .and_then(|p| p.manifest_path.parent().map(Path::to_path_buf))
485        .ok_or_else(|| "cargo metadata names no `shuyi` package for the game".to_owned())
486}
487
488/// The first paragraph of the README in package folder `dir`, after its
489/// title: what the module is for (ADR-0009 §4).
490pub fn readme_paragraph(dir: &Path) -> Result<String, String> {
491    let path = dir.join("README.md");
492    let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
493    let lines: Vec<&str> = text
494        .lines()
495        .map(str::trim_end)
496        .skip_while(|line| line.is_empty() || line.starts_with('#'))
497        .take_while(|line| !line.is_empty())
498        .collect();
499    if lines.is_empty() {
500        return Err(format!("{}: no paragraph after its title", path.display()));
501    }
502    Ok(lines.join("\n"))
503}
504
505/// The skill of the module `name` in package folder `dir`: its own
506/// `skill/` folder, or the one named for it in the skill pack of the
507/// repository it is in (`skills/<name>`, two folders up), whichever holds
508/// a `SKILL.md`.
509pub fn skill_of(dir: &Path, name: &str) -> Option<PathBuf> {
510    let own = dir.join("skill");
511    let pack = dir.join("../../skills").join(name);
512    [own, pack]
513        .into_iter()
514        .find(|skill| skill.join("SKILL.md").is_file())
515}
516
517/// What [`copy_skill`] did.
518#[derive(Debug, Clone, PartialEq, Eq)]
519pub enum SkillCopy {
520    /// It copied the skill to this folder.
521    Copied(PathBuf),
522    /// The game already has a skill of that name, in this folder: it is
523    /// left as it is.
524    AlreadyThere(PathBuf),
525    /// The game keeps no skills ([`SKILLS`] isn't there).
526    NoSkills,
527}
528
529/// Copies the skill folder `skill` into the skills of the game at `game`
530/// as `name`: its regular files, at any depth (a link is skipped).
531pub fn copy_skill(skill: &Path, game: &Path, name: &str) -> Result<SkillCopy, String> {
532    let skills = game.join(SKILLS);
533    if !skills.is_dir() {
534        return Ok(SkillCopy::NoSkills);
535    }
536    let to = skills.join(name);
537    if to.exists() {
538        return Ok(SkillCopy::AlreadyThere(to));
539    }
540    copy_files(skill, &to)?;
541    Ok(SkillCopy::Copied(to))
542}
543
544/// Copies the regular files under `from` to `to`, folders made as needed.
545fn copy_files(from: &Path, to: &Path) -> Result<(), String> {
546    fs::create_dir_all(to).map_err(|e| format!("{}: {e}", to.display()))?;
547    let entries = fs::read_dir(from).map_err(|e| format!("{}: {e}", from.display()))?;
548    for entry in entries {
549        let entry = entry.map_err(|e| format!("{}: {e}", from.display()))?;
550        let kind = entry
551            .file_type()
552            .map_err(|e| format!("{}: {e}", entry.path().display()))?;
553        let target = to.join(entry.file_name());
554        if kind.is_dir() {
555            copy_files(&entry.path(), &target)?;
556        } else if kind.is_file() {
557            fs::copy(entry.path(), &target).map_err(|e| format!("{}: {e}", target.display()))?;
558        }
559    }
560    Ok(())
561}
562
563/// Whether a Rust file of the game at `game` (outside `target/` and
564/// hidden folders) names `shuyi::enabled_modules`, which installs the
565/// modules its features turn on.
566pub fn installs_modules(game: &Path) -> bool {
567    let mut pending = vec![game.to_path_buf()];
568    while let Some(dir) = pending.pop() {
569        let Ok(entries) = fs::read_dir(&dir) else {
570            continue;
571        };
572        for entry in entries.flatten() {
573            let path = entry.path();
574            let name = entry.file_name();
575            let name = name.to_string_lossy();
576            if path.is_dir() {
577                if name != "target" && !name.starts_with('.') {
578                    pending.push(path);
579                }
580            } else if name.ends_with(".rs")
581                && fs::read_to_string(&path).is_ok_and(|text| text.contains("enabled_modules"))
582            {
583                return true;
584            }
585        }
586    }
587    false
588}
589
590#[cfg(test)]
591mod tests;