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