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 use yog_ids::file::MANIFEST;
8/// Where `yog build` generates the cargo project, under the project root.
9pub const BUILD_DIR: &str = yog_ids::project::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.98";
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    /// Content of other mods (original Java mods, or Yog mods) this mod
73    /// names, declared up front: `[external.<namespace>]` with `items`,
74    /// `blocks`, `entities`, `recipes` (paths: `["cutting_board"]`).
75    pub externals: Vec<External>,
76}
77
78/// One `[external.<namespace>]` section.
79#[derive(Debug, Default, Clone)]
80pub struct External {
81    pub namespace: String,
82    /// `kind` (`items`, `blocks`, `entities`, `recipes`) → paths.
83    pub ids: Vec<(String, Vec<String>)>,
84}
85
86impl YogToml {
87    pub fn read(path: &Path) -> Result<Self, String> {
88        let text = std::fs::read_to_string(path)
89            .map_err(|e| format!("reading {}: {e}", path.display()))?;
90        parse_yog_toml(&text)
91    }
92
93    /// Resolve where yog-api lives.  Priority:
94    ///   1. YOG_API_PATH env var
95    ///   2. [dev] yog_api_path in yog.toml
96    ///   3. yog_api_version in [dependencies] (e.g. "0.5")
97    ///   4. YOG_API_VERSION env var
98    ///   5. latest from crates.io
99    pub fn api_dep(&self) -> String {
100        if let Ok(p) = std::env::var("YOG_API_PATH") {
101            // Resolve to absolute so it works from any subdirectory
102            let abs = PathBuf::from(&p)
103                .canonicalize()
104                .unwrap_or_else(|_| PathBuf::from(&p));
105            return format!("yog-api = {{ path = {:?} }}", abs.to_string_lossy());
106        }
107        if let Some(p) = &self.yog_api_path {
108            return format!("yog-api = {{ path = {p:?} }}");
109        }
110        let version = {
111            let env_version = std::env::var("YOG_API_VERSION").ok();
112            self.yog_api_version
113                .as_deref()
114                .or(env_version.as_deref())
115                .map(|v| v.to_owned())
116                .unwrap_or_else(latest_yog_api_version)
117        };
118        format!("yog-api = {:?}", version)
119    }
120}
121
122// ── Reading ─────────────────────────────────────────────────────────────────
123
124use yog_data::{Text, Value};
125use yog_formats::manifest::ManifestFile;
126
127/// `v` as TOML's inline syntax — how a dependency is written into Cargo.toml.
128fn toml_inline(v: &Value) -> String {
129    let quote = |s: &str| format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""));
130    match v {
131        Value::Map(m) => {
132            let fields: Vec<String> = m
133                .iter()
134                .map(|(k, v)| {
135                    let key = if k.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { k.to_string() } else { quote(k) };
136                    format!("{key} = {}", toml_inline(v))
137                })
138                .collect();
139            format!("{{ {} }}", fields.join(", "))
140        }
141        Value::List(items) => format!("[{}]", items.iter().map(toml_inline).collect::<Vec<_>>().join(", ")),
142        Value::Bool(b) => b.to_string(),
143        Value::Int(n) => n.to_string(),
144        Value::Float(f) => f.to_string(),
145        other => quote(other.as_str().unwrap_or_default()),
146    }
147}
148
149/// A text of `yog.toml` as `resolve_metadata` reads it: `@Set.key` or the words (`@@` for a leading `@`).
150fn text_form(t: Text) -> Result<String, String> {
151    match t {
152        Text::Literal(s) => Ok(yog_data::typed::escape_at(&s)),
153        Text::Res(r) => Ok(format!("@{}.{}", r.set, r.key)),
154        Text::NameOf(_) | Text::Lang(_) => Err("a mod's name and description are text or a `.yres` text (`@Strings.mod_name`)".into()),
155    }
156}
157
158/// Reads `yog.toml` (TOML, through Yog Data's schema: every problem with its line).
159pub fn parse_yog_toml(text: &str) -> Result<YogToml, String> {
160    let read = yog_data::schema::from_toml::<ManifestFile>(text).map_err(|e| {
161        e.0.iter()
162            .map(|l| match l.at {
163                Some(at) => format!("yog.toml:{}: {}{}", at.line, if l.path.is_root() { String::new() } else { format!("{}: ", l.path) }, l.message),
164                None => format!("yog.toml: {}", l.message),
165            })
166            .collect::<Vec<_>>()
167            .join("\n")
168    })?;
169    let f = read.value;
170    let m = f.module;
171    let mut dependencies = Vec::new();
172    let mut mod_dependencies = Vec::new();
173    let mut yog_api_version = None;
174    for (name, spec) in f.dependencies.0 {
175        match (name.as_str(), &spec) {
176            ("exports", Value::Map(exports)) => mod_dependencies.extend(exports.iter().map(|(k, v)| (k.to_string(), toml_inline(v)))),
177            ("yog-api" | "yog_api" | "yog_api_version", _) => yog_api_version = Some(spec.as_str().map(str::to_string).unwrap_or_else(|| toml_inline(&spec))),
178            _ => dependencies.push((name, toml_inline(&spec))),
179        }
180    }
181    let externals = f
182        .external
183        .0
184        .into_iter()
185        .map(|(namespace, e)| External {
186            namespace,
187            ids: [("items", e.items), ("blocks", e.blocks), ("entities", e.entities), ("recipes", e.recipes)]
188                .into_iter()
189                .filter(|(_, v)| !v.is_empty())
190                .map(|(k, v)| (k.to_string(), v))
191                .collect(),
192        })
193        .collect();
194    let name = m.name.map(text_form).transpose()?;
195    let description = m.description.map(text_form).transpose()?;
196    let icon = m.icon.map(|i| match i {
197        yog_data::Image::Source(s) => s,
198        yog_data::Image::Res(r) => format!("@{}.{}", r.set, r.key),
199    });
200    Ok(YogToml {
201        name: name.unwrap_or_else(|| m.id.clone()),
202        version: m.version.unwrap_or_else(|| "0.1.0".into()),
203        description: description.unwrap_or_default(),
204        name_key: None,
205        description_key: None,
206        icon,
207        authors: m.authors,
208        license: m.license.unwrap_or_else(|| DEFAULT_LICENSE.into()),
209        exports_license: m.exports_license,
210        datapack: m.datapack,
211        edition: m.edition,
212        yog_api_path: f.dev.and_then(|d| d.yog_api_path),
213        yog_api_version,
214        dependencies,
215        mod_dependencies,
216        dev_dependencies: f.dev_dependencies.0.into_iter().map(|(k, v)| (k, toml_inline(&v))).collect(),
217        externals,
218        id: m.id,
219    })
220}
221
222/// Cargo derives a lib crate name from the package `name` by turning `-` into
223/// `_`, but keeps case — so a mod `id` like "My-Mod" becomes the invalid
224/// `My_Mod` and rustc warns on every build. `id` is a free-form project
225/// identifier (not required to be a valid Rust ident), so the generated
226/// wrapper pins `[lib] name` to a proper snake_case form instead.
227pub fn to_snake_name(id: &str) -> String {
228    let mut out = String::with_capacity(id.len());
229    for c in id.chars() {
230        if c == '-' || c == '_' {
231            if !out.ends_with('_') {
232                out.push('_');
233            }
234        } else if c.is_uppercase() {
235            if !out.is_empty() && !out.ends_with('_') {
236                out.push('_');
237            }
238            out.extend(c.to_lowercase());
239        } else {
240            out.push(c);
241        }
242    }
243    out
244}
245
246/// rkyv version embedded at compile time via `build.rs`.
247pub fn workspace_rkyv_version() -> String {
248    env!("RKYV_VERSION").to_string()
249}
250
251/// Fetch the latest non-yanked version of `yog-api` from crates.io.
252pub fn latest_yog_api_version() -> String {
253    match std::process::Command::new("cargo")
254        .args(["search", "yog-api", "--limit", "1"])
255        .output()
256    {
257        Ok(out) if out.status.success() => {
258            String::from_utf8_lossy(&out.stdout)
259                .lines()
260                .next()
261                .and_then(|l| {
262                    let mut parts = l.split_whitespace();
263                    parts.next(); // skip crate name (e.g. "yog-api")
264                    if parts.next() == Some("=") {
265                        // skip the "=" separator
266                        parts.next().map(|v| v.trim_matches('"').to_owned())
267                    } else {
268                        None
269                    }
270                })
271                .unwrap_or_else(|| FALLBACK_API_VERSION.into())
272        }
273        _ => FALLBACK_API_VERSION.into(),
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    /// Yog-VLSI's manifest, as written — what the TOML reader that replaces
282    /// this one must read the same.
283    const VLSI: &str = r#"
284[mod]
285id          = "yog-vlsi"
286name        = "@Strings.mod_name"
287version     = "0.3.0"
288description = "@Strings.mod_description"
289authors     = ["F000NK", "Yog Team"]
290license     = "AGPL-3.0-only"
291edition     = "2024"
292
293[dependencies]
294serde      = { version = "1", features = ["derive"] }
295serde_json = "1"
296yog-api    = "0.93"
297yog-kit    = "0.4"
298
299[dependencies.exports]
300yog-pipes = "0.5"
301
302## a comment
303[external.farmersdelight]
304items  = ["cutting_board", "tomato"]
305blocks = ["stove"]
306"#;
307
308    #[test]
309    fn reads_a_real_manifest() {
310        let t = parse_yog_toml(VLSI).unwrap();
311        assert_eq!((t.id.as_str(), t.name.as_str(), t.version.as_str()), ("yog-vlsi", "@Strings.mod_name", "0.3.0"));
312        assert_eq!(t.authors, ["F000NK", "Yog Team"]);
313        assert_eq!(t.license, "AGPL-3.0-only");
314        assert_eq!(t.yog_api_version.as_deref(), Some("0.93"), "yog-api is taken out of the dependencies");
315        let deps: Vec<&str> = t.dependencies.iter().map(|(n, _)| n.as_str()).collect();
316        assert_eq!(deps, ["serde", "serde_json", "yog-kit"]);
317        assert_eq!(t.mod_dependencies, [("yog-pipes".to_string(), "\"0.5\"".to_string())]);
318        let ext = &t.externals[0];
319        assert_eq!(ext.namespace, "farmersdelight");
320        assert_eq!(ext.ids[0], ("items".to_string(), vec!["cutting_board".to_string(), "tomato".to_string()]));
321    }
322
323    #[test]
324    fn problems_say_where() {
325        let e = parse_yog_toml("[mod]\nid = \"m\"\nautors = [\"me\"]\n\n[external.create]\nitem = [\"wrench\"]\n").unwrap_err();
326        assert_eq!(e.lines().collect::<Vec<_>>(), [
327            "yog.toml:3: mod.autors: unknown field `autors` — did you mean `authors`?",
328            "yog.toml:6: external.create.item: unknown field `item` — did you mean `items`?",
329        ]);
330        let t = parse_yog_toml("[mod]\nid = \"m\"\nname = \"@@me\"\n").unwrap();
331        assert_eq!(t.name, "@@me", "a literal @ stays escaped for resolve_metadata");
332    }
333
334    #[test]
335    fn needs_an_id() {
336        assert!(parse_yog_toml("[mod]\nname = \"x\"").is_err());
337        let t = parse_yog_toml("[mod]\nid = \"m\"").unwrap();
338        assert_eq!((t.name.as_str(), t.version.as_str()), ("m", "0.1.0"), "defaults");
339    }
340}