Skip to main content

yog_cli/
manifest.rs

1//! `yog.toml` — a mod project's manifest, the only build file mod authors
2//! write.
3
4use std::path::{Path, PathBuf};
5
6/// The manifest's file name, in the project root.
7pub const MANIFEST: &str = "yog.toml";
8/// Where `yog build` generates the cargo project, under the project root.
9pub const BUILD_DIR: &str = ".yog-build";
10/// Plain crates.io dependencies.
11pub const DEPS_SECTION: &str = "[dependencies]";
12/// Other Yog mods' exports crates.
13pub const EXPORT_DEPS_SECTION: &str = "[dependencies.exports]";
14/// Rust edition when `[mod] edition` is absent.
15pub const DEFAULT_EDITION: &str = "2024";
16/// License of a mod, and of its exports crate, when none is given.
17pub const DEFAULT_LICENSE: &str = "MIT OR Apache-2.0";
18/// `yog-api` version when crates.io can't be asked (offline, etc.).
19const FALLBACK_API_VERSION: &str = "0.95";
20
21/// The current directory, if it's a mod project (has a `yog.toml`).
22pub fn project_root() -> Result<PathBuf, String> {
23    let root = std::env::current_dir().map_err(|e| e.to_string())?;
24    if root.join(MANIFEST).is_file() {
25        Ok(root)
26    } else {
27        Err(format!("no {MANIFEST} found in the current directory"))
28    }
29}
30
31/// Parsed content of a `yog.toml` project file.
32#[derive(Debug)]
33pub struct YogToml {
34    pub id: String,
35    pub name: String,
36    pub version: String,
37    pub description: String,
38    /// Translation keys of `name`/`description` when yog.toml gives them
39    /// as `@<Set>.<name>` resources (see `resolve_metadata`).
40    pub name_key: Option<String>,
41    pub description_key: Option<String>,
42    /// The mod's icon, an image source (`icon = "@Icons.logo"` resolved).
43    pub icon: Option<String>,
44    pub authors: Vec<String>,
45    pub license: String,
46    /// License for the generated `yog publish exports` bindings crate.
47    /// Defaults to "MIT OR Apache-2.0" regardless of the mod's own license
48    /// (AGPL propagating onto every consumer's dependency tree would kill
49    /// adoption of the exports crate) — set `[mod] exports_license = "..."`
50    /// to opt into matching the mod's own license instead.
51    pub exports_license: Option<String>,
52    /// The Minecraft version whose vanilla content it names (`[mod] datapack`).
53    pub datapack: Option<String>,
54    pub edition: Option<String>,
55    /// Optional: path to yog-api for local/monorepo development.
56    /// Set via [dev] yog_api_path = "..."  or YOG_API_PATH env var.
57    pub yog_api_path: Option<String>,
58    /// Optional: pin yog-api version as "yog_api = \"X.Y\"" in [dependencies].
59    pub yog_api_version: Option<String>,
60    /// User-declared dependencies from [dependencies] section — plain
61    /// crates.io crates, used exactly as written.
62    pub dependencies: Vec<(String, String)>,
63    /// Other Yog mods this mod depends on, from `[dependencies.exports]`.
64    /// Each entry `name = spec` maps to that mod's generated exports crate:
65    /// `{name} = { package = "{name}-exports", ...spec }` — so mod code
66    /// imports it under its own name (`use {name}::...`), same as a direct
67    /// dependency, and two mod dependencies never collide under a shared
68    /// alias (unlike the old name-contains-hyphen heuristic this replaced).
69    pub mod_dependencies: Vec<(String, String)>,
70    /// `[dev-dependencies]` — crates only `yog bench` benchmarks use.
71    pub dev_dependencies: Vec<(String, String)>,
72    /// Named dev-instance launch configs from `[run.<name>]` sections.
73    pub run_configs: Vec<RunConfig>,
74    /// Content of other mods (original Java mods, or Yog mods) this mod
75    /// names, declared up front: `[external.<namespace>]` with `items`,
76    /// `blocks`, `entities`, `recipes` (paths: `["cutting_board"]`).
77    pub externals: Vec<External>,
78}
79
80/// One `[external.<namespace>]` section.
81#[derive(Debug, Default, Clone)]
82pub struct External {
83    pub namespace: String,
84    /// `kind` (`items`, `blocks`, `entities`, `recipes`) → paths.
85    pub ids: Vec<(String, Vec<String>)>,
86}
87
88/// A `[run.<name>]` section: where to drop the built artifact and what to
89/// launch afterwards, for a full `yog run <name>` dev-instance workflow.
90#[derive(Debug, Default, Clone)]
91pub struct RunConfig {
92    pub name: String,
93    /// Optional human-readable label (`name = "..."` inside `[run.X]`), for
94    /// editors to show/edit instead of the raw dotted section-key id. Falls
95    /// back to `name` (the id) when absent.
96    pub display_name: Option<String>,
97    /// Directory the built `.yog` gets copied into (e.g. an instance's `yog-mods/`).
98    /// Relative paths resolve against the mod project root.
99    pub export_dir: Option<String>,
100    /// Executable to launch after export (e.g. `java`, a wrapper script, `./gradlew`).
101    pub command: Option<String>,
102    /// Arguments passed to `command`, in order.
103    pub args: Vec<String>,
104    /// Working directory for `command`. Relative paths resolve against the project root.
105    pub cwd: Option<String>,
106    /// Extra environment variables as `KEY=VALUE` pairs.
107    pub env: Vec<(String, String)>,
108}
109
110impl YogToml {
111    pub fn read(path: &Path) -> Result<Self, String> {
112        let text = std::fs::read_to_string(path)
113            .map_err(|e| format!("reading {}: {e}", path.display()))?;
114        parse_yog_toml(&text)
115    }
116
117    /// Resolve where yog-api lives.  Priority:
118    ///   1. YOG_API_PATH env var
119    ///   2. [dev] yog_api_path in yog.toml
120    ///   3. yog_api_version in [dependencies] (e.g. "0.5")
121    ///   4. YOG_API_VERSION env var
122    ///   5. latest from crates.io
123    pub fn api_dep(&self) -> String {
124        if let Ok(p) = std::env::var("YOG_API_PATH") {
125            // Resolve to absolute so it works from any subdirectory
126            let abs = PathBuf::from(&p)
127                .canonicalize()
128                .unwrap_or_else(|_| PathBuf::from(&p));
129            return format!("yog-api = {{ path = {:?} }}", abs.to_string_lossy());
130        }
131        if let Some(p) = &self.yog_api_path {
132            return format!("yog-api = {{ path = {p:?} }}");
133        }
134        let version = {
135            let env_version = std::env::var("YOG_API_VERSION").ok();
136            self.yog_api_version
137                .as_deref()
138                .or(env_version.as_deref())
139                .map(|v| v.to_owned())
140                .unwrap_or_else(latest_yog_api_version)
141        };
142        format!("yog-api = {:?}", version)
143    }
144}
145
146// ── Reading ─────────────────────────────────────────────────────────────────
147
148use yog_data::{Text, Value};
149use yog_formats::manifest::ManifestFile;
150
151/// `v` as TOML's inline syntax — how a dependency is written into Cargo.toml.
152fn toml_inline(v: &Value) -> String {
153    let quote = |s: &str| format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""));
154    match v {
155        Value::Map(m) => {
156            let fields: Vec<String> = m
157                .iter()
158                .map(|(k, v)| {
159                    let key = if k.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { k.to_string() } else { quote(k) };
160                    format!("{key} = {}", toml_inline(v))
161                })
162                .collect();
163            format!("{{ {} }}", fields.join(", "))
164        }
165        Value::List(items) => format!("[{}]", items.iter().map(toml_inline).collect::<Vec<_>>().join(", ")),
166        Value::Bool(b) => b.to_string(),
167        Value::Int(n) => n.to_string(),
168        Value::Float(f) => f.to_string(),
169        other => quote(other.as_str().unwrap_or_default()),
170    }
171}
172
173/// A text of `yog.toml` as `resolve_metadata` reads it: `@Set.key` or the words (`@@` for a leading `@`).
174fn text_form(t: Text) -> Result<String, String> {
175    match t {
176        Text::Literal(s) => Ok(yog_data::typed::escape_at(&s)),
177        Text::Res(r) => Ok(format!("@{}.{}", r.set, r.key)),
178        Text::NameOf(_) | Text::Lang(_) => Err("a mod's name and description are text or a `.yres` text (`@Strings.mod_name`)".into()),
179    }
180}
181
182/// Reads `yog.toml` (TOML, through Yog Data's schema: every problem with its line).
183pub fn parse_yog_toml(text: &str) -> Result<YogToml, String> {
184    // A dotted run header is nested tables to TOML: say how to write it.
185    for (i, line) in text.lines().enumerate() {
186        let l = line.trim();
187        if let Some(name) = l.strip_prefix("[run.").and_then(|r| r.strip_suffix(']'))
188            && name.contains('.')
189            && !name.starts_with('"')
190        {
191            return Err(format!("yog.toml:{}: write the run target's name in quotes: [run.\"{name}\"]", i + 1));
192        }
193    }
194    let read = yog_data::schema::from_toml::<ManifestFile>(text).map_err(|e| {
195        e.0.iter()
196            .map(|l| match l.at {
197                Some(at) => format!("yog.toml:{}: {}{}", at.line, if l.path.is_root() { String::new() } else { format!("{}: ", l.path) }, l.message),
198                None => format!("yog.toml: {}", l.message),
199            })
200            .collect::<Vec<_>>()
201            .join("\n")
202    })?;
203    let f = read.value;
204    let m = f.module;
205    let mut dependencies = Vec::new();
206    let mut mod_dependencies = Vec::new();
207    let mut yog_api_version = None;
208    for (name, spec) in f.dependencies.0 {
209        match (name.as_str(), &spec) {
210            ("exports", Value::Map(exports)) => mod_dependencies.extend(exports.iter().map(|(k, v)| (k.to_string(), toml_inline(v)))),
211            ("yog-api" | "yog_api" | "yog_api_version", _) => yog_api_version = Some(spec.as_str().map(str::to_string).unwrap_or_else(|| toml_inline(&spec))),
212            _ => dependencies.push((name, toml_inline(&spec))),
213        }
214    }
215    let run_configs = f
216        .run
217        .0
218        .into_iter()
219        .map(|(name, r)| RunConfig {
220            name,
221            display_name: r.name,
222            export_dir: r.export_dir,
223            command: r.command,
224            args: r.args,
225            cwd: r.cwd,
226            env: r.env.into_iter().filter_map(|kv| kv.split_once('=').map(|(k, v)| (k.to_string(), v.to_string()))).collect(),
227        })
228        .collect();
229    let externals = f
230        .external
231        .0
232        .into_iter()
233        .map(|(namespace, e)| External {
234            namespace,
235            ids: [("items", e.items), ("blocks", e.blocks), ("entities", e.entities), ("recipes", e.recipes)]
236                .into_iter()
237                .filter(|(_, v)| !v.is_empty())
238                .map(|(k, v)| (k.to_string(), v))
239                .collect(),
240        })
241        .collect();
242    let name = m.name.map(text_form).transpose()?;
243    let description = m.description.map(text_form).transpose()?;
244    let icon = m.icon.map(|i| match i {
245        yog_data::Image::Source(s) => s,
246        yog_data::Image::Res(r) => format!("@{}.{}", r.set, r.key),
247    });
248    Ok(YogToml {
249        name: name.unwrap_or_else(|| m.id.clone()),
250        version: m.version.unwrap_or_else(|| "0.1.0".into()),
251        description: description.unwrap_or_default(),
252        name_key: None,
253        description_key: None,
254        icon,
255        authors: m.authors,
256        license: m.license.unwrap_or_else(|| DEFAULT_LICENSE.into()),
257        exports_license: m.exports_license,
258        datapack: m.datapack,
259        edition: m.edition,
260        yog_api_path: f.dev.and_then(|d| d.yog_api_path),
261        yog_api_version,
262        dependencies,
263        mod_dependencies,
264        dev_dependencies: f.dev_dependencies.0.into_iter().map(|(k, v)| (k, toml_inline(&v))).collect(),
265        run_configs,
266        externals,
267        id: m.id,
268    })
269}
270
271/// Cargo derives a lib crate name from the package `name` by turning `-` into
272/// `_`, but keeps case — so a mod `id` like "My-Mod" becomes the invalid
273/// `My_Mod` and rustc warns on every build. `id` is a free-form project
274/// identifier (not required to be a valid Rust ident), so the generated
275/// wrapper pins `[lib] name` to a proper snake_case form instead.
276pub fn to_snake_name(id: &str) -> String {
277    let mut out = String::with_capacity(id.len());
278    for c in id.chars() {
279        if c == '-' || c == '_' {
280            if !out.ends_with('_') {
281                out.push('_');
282            }
283        } else if c.is_uppercase() {
284            if !out.is_empty() && !out.ends_with('_') {
285                out.push('_');
286            }
287            out.extend(c.to_lowercase());
288        } else {
289            out.push(c);
290        }
291    }
292    out
293}
294
295/// rkyv version embedded at compile time via `build.rs`.
296pub fn workspace_rkyv_version() -> String {
297    env!("RKYV_VERSION").to_string()
298}
299
300/// Fetch the latest non-yanked version of `yog-api` from crates.io.
301pub fn latest_yog_api_version() -> String {
302    match std::process::Command::new("cargo")
303        .args(["search", "yog-api", "--limit", "1"])
304        .output()
305    {
306        Ok(out) if out.status.success() => {
307            String::from_utf8_lossy(&out.stdout)
308                .lines()
309                .next()
310                .and_then(|l| {
311                    let mut parts = l.split_whitespace();
312                    parts.next(); // skip crate name (e.g. "yog-api")
313                    if parts.next() == Some("=") {
314                        // skip the "=" separator
315                        parts.next().map(|v| v.trim_matches('"').to_owned())
316                    } else {
317                        None
318                    }
319                })
320                .unwrap_or_else(|| FALLBACK_API_VERSION.into())
321        }
322        _ => FALLBACK_API_VERSION.into(),
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    /// Yog-VLSI's manifest, as written — what the TOML reader that replaces
331    /// this one must read the same.
332    const VLSI: &str = r#"
333[mod]
334id          = "yog-vlsi"
335name        = "@Strings.mod_name"
336version     = "0.3.0"
337description = "@Strings.mod_description"
338authors     = ["F000NK", "Yog Team"]
339license     = "AGPL-3.0-only"
340edition     = "2024"
341
342[dependencies]
343serde      = { version = "1", features = ["derive"] }
344serde_json = "1"
345yog-api    = "0.93"
346yog-kit    = "0.4"
347
348[dependencies.exports]
349yog-pipes = "0.5"
350
351## a comment
352[run."forge-1.21.1"]
353name       = "Forge 1.21.1"
354export_dir = "../Yog-Mod-Loader/forge/run/yog-mods"
355command    = "./gradlew"
356args       = ["runClient", "-Pminecraft_version=1.21.1"]
357cwd        = "../Yog-Mod-Loader/forge"
358env        = ["JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64"]
359
360[external.farmersdelight]
361items  = ["cutting_board", "tomato"]
362blocks = ["stove"]
363"#;
364
365    #[test]
366    fn reads_a_real_manifest() {
367        let t = parse_yog_toml(VLSI).unwrap();
368        assert_eq!((t.id.as_str(), t.name.as_str(), t.version.as_str()), ("yog-vlsi", "@Strings.mod_name", "0.3.0"));
369        assert_eq!(t.authors, ["F000NK", "Yog Team"]);
370        assert_eq!(t.license, "AGPL-3.0-only");
371        assert_eq!(t.yog_api_version.as_deref(), Some("0.93"), "yog-api is taken out of the dependencies");
372        let deps: Vec<&str> = t.dependencies.iter().map(|(n, _)| n.as_str()).collect();
373        assert_eq!(deps, ["serde", "serde_json", "yog-kit"]);
374        assert_eq!(t.mod_dependencies, [("yog-pipes".to_string(), "\"0.5\"".to_string())]);
375        let run = &t.run_configs[0];
376        assert_eq!((run.name.as_str(), run.display_name.as_deref()), ("forge-1.21.1", Some("Forge 1.21.1")));
377        assert_eq!(run.args, ["runClient", "-Pminecraft_version=1.21.1"]);
378        assert_eq!(run.env, [("JAVA_HOME".to_string(), "/usr/lib/jvm/java-21-openjdk-amd64".to_string())]);
379        let ext = &t.externals[0];
380        assert_eq!(ext.namespace, "farmersdelight");
381        assert_eq!(ext.ids[0], ("items".to_string(), vec!["cutting_board".to_string(), "tomato".to_string()]));
382    }
383
384    #[test]
385    fn problems_say_where() {
386        let e = parse_yog_toml("[mod]\nid = \"m\"\n\n[run.forge-1.21.1]\nname = \"F\"\n").unwrap_err();
387        assert_eq!(e, "yog.toml:4: write the run target's name in quotes: [run.\"forge-1.21.1\"]");
388        let e = parse_yog_toml("[mod]\nid = \"m\"\nautors = [\"me\"]\n\n[external.create]\nitem = [\"wrench\"]\n").unwrap_err();
389        assert_eq!(e.lines().collect::<Vec<_>>(), [
390            "yog.toml:3: mod.autors: unknown field `autors` — did you mean `authors`?",
391            "yog.toml:6: external.create.item: unknown field `item` — did you mean `items`?",
392        ]);
393        let t = parse_yog_toml("[mod]\nid = \"m\"\nname = \"@@me\"\n").unwrap();
394        assert_eq!(t.name, "@@me", "a literal @ stays escaped for resolve_metadata");
395    }
396
397    #[test]
398    fn needs_an_id() {
399        assert!(parse_yog_toml("[mod]\nname = \"x\"").is_err());
400        let t = parse_yog_toml("[mod]\nid = \"m\"").unwrap();
401        assert_eq!((t.name.as_str(), t.version.as_str()), ("m", "0.1.0"), "defaults");
402    }
403}