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