1use 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
30pub const CONCERNS: &str = "docs/modules.ron";
32
33pub const SKILLS: &str = ".claude/skills";
35
36const PREFIX: &str = "shuyi-";
38
39pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
52#[serde(deny_unknown_fields)]
53pub struct Concerns {
54 pub concerns: Vec<Concern>,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
60#[serde(deny_unknown_fields)]
61pub struct Concern {
62 pub id: String,
64 pub name: String,
66 pub alternatives: bool,
70 pub answers: Vec<Choice>,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
76#[serde(deny_unknown_fields)]
77pub struct Choice {
78 pub id: String,
80 pub name: String,
82 pub by: String,
85 pub status: Status,
87 pub tickets: Vec<String>,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
93#[serde(rename_all = "kebab-case")]
94pub enum Status {
95 Ready,
97 Planned,
99 Parked,
101}
102
103impl Status {
104 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 pub fn parse(text: &str) -> Result<Self, String> {
117 ron::from_str(text).map_err(|e| format!("{CONCERNS}: {e}"))
118 }
119
120 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 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
148#[serde(deny_unknown_fields)]
149pub struct ModuleInfo {
150 pub answers: Vec<Answered>,
153 pub serves: Vec<String>,
155 #[serde(default)]
157 pub alternatives: Vec<String>,
158 #[serde(default)]
160 pub works_with: Vec<String>,
161 #[serde(default)]
164 pub formats: Option<String>,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
169#[serde(deny_unknown_fields)]
170pub struct Answered {
171 pub concern: String,
173 pub answer: String,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct ModuleCrate {
180 pub name: String,
182 pub dir: PathBuf,
184 pub info: ModuleInfo,
186}
187
188#[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 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 pub fn feature(&self) -> &str {
230 feature_of(&self.name)
231 }
232}
233
234pub fn feature_of(name: &str) -> &str {
237 name.strip_prefix(PREFIX).unwrap_or(name)
238}
239
240pub fn module_of(feature: &str) -> String {
242 format!("{PREFIX}{feature}")
243}
244
245#[derive(Debug, Clone, PartialEq, Eq)]
248pub struct Catalog {
249 pub root: PathBuf,
251 pub concerns: Concerns,
253 pub modules: Vec<ModuleCrate>,
256 pub skills: Pack,
258}
259
260impl Catalog {
261 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 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 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 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
319pub 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
345fn shuyi_entry(doc: &mut DocumentMut) -> Option<&mut Item> {
349 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
370fn 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
377fn 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
392pub 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
399pub 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 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 if let Some(table) = entry.as_inline_table_mut() {
425 table.fmt();
426 }
427 Ok(Some(doc.to_string()))
428}
429
430pub 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
455fn 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
488pub 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
505pub 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#[derive(Debug, Clone, PartialEq, Eq)]
519pub enum SkillCopy {
520 Copied(PathBuf),
522 AlreadyThere(PathBuf),
525 NoSkills,
527}
528
529pub 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
544fn 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
563pub 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;