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}
74
75/// A `[run.<name>]` section: where to drop the built artifact and what to
76/// launch afterwards, for a full `yog run <name>` dev-instance workflow.
77#[derive(Debug, Default, Clone)]
78pub struct RunConfig {
79    pub name: String,
80    /// Optional human-readable label (`name = "..."` inside `[run.X]`), for
81    /// editors to show/edit instead of the raw dotted section-key id. Falls
82    /// back to `name` (the id) when absent.
83    pub display_name: Option<String>,
84    /// Directory the built `.yog` gets copied into (e.g. an instance's `yog-mods/`).
85    /// Relative paths resolve against the mod project root.
86    pub export_dir: Option<String>,
87    /// Executable to launch after export (e.g. `java`, a wrapper script, `./gradlew`).
88    pub command: Option<String>,
89    /// Arguments passed to `command`, in order.
90    pub args: Vec<String>,
91    /// Working directory for `command`. Relative paths resolve against the project root.
92    pub cwd: Option<String>,
93    /// Extra environment variables as `KEY=VALUE` pairs.
94    pub env: Vec<(String, String)>,
95}
96
97impl YogToml {
98    pub fn read(path: &Path) -> Result<Self, String> {
99        let text = std::fs::read_to_string(path)
100            .map_err(|e| format!("reading {}: {e}", path.display()))?;
101        parse_yog_toml(&text)
102    }
103
104    /// Resolve where yog-api lives.  Priority:
105    ///   1. YOG_API_PATH env var
106    ///   2. [dev] yog_api_path in yog.toml
107    ///   3. yog_api_version in [dependencies] (e.g. "0.5")
108    ///   4. YOG_API_VERSION env var
109    ///   5. latest from crates.io
110    pub fn api_dep(&self) -> String {
111        if let Ok(p) = std::env::var("YOG_API_PATH") {
112            // Resolve to absolute so it works from any subdirectory
113            let abs = PathBuf::from(&p)
114                .canonicalize()
115                .unwrap_or_else(|_| PathBuf::from(&p));
116            return format!("yog-api = {{ path = {:?} }}", abs.to_string_lossy());
117        }
118        if let Some(p) = &self.yog_api_path {
119            return format!("yog-api = {{ path = {p:?} }}");
120        }
121        let version = {
122            let env_version = std::env::var("YOG_API_VERSION").ok();
123            self.yog_api_version
124                .as_deref()
125                .or(env_version.as_deref())
126                .map(|v| v.to_owned())
127                .unwrap_or_else(latest_yog_api_version)
128        };
129        format!("yog-api = {:?}", version)
130    }
131}
132
133pub fn parse_yog_toml(text: &str) -> Result<YogToml, String> {
134    let mut section = "";
135    let mut id = None::<String>;
136    let mut name = None::<String>;
137    let mut version = None::<String>;
138    let mut description = None::<String>;
139    let mut icon = None::<String>;
140    let mut authors: Vec<String> = Vec::new();
141    let mut license = None::<String>;
142    let mut exports_license = None::<String>;
143    let mut edition = None::<String>;
144    let mut yog_api_path = None::<String>;
145    let mut dependencies: Vec<(String, String)> = Vec::new();
146    let mut mod_dependencies: Vec<(String, String)> = Vec::new();
147    let mut dev_dependencies: Vec<(String, String)> = Vec::new();
148    let mut run_configs: HashMap<String, RunConfig> = HashMap::new();
149
150    for raw in text.lines() {
151        let line = raw.trim();
152        if line.is_empty() || line.starts_with('#') {
153            continue;
154        }
155        if line.starts_with('[') {
156            section = line.trim_matches(|c| c == '[' || c == ']');
157            continue;
158        }
159        match section {
160            "mod" => {
161                if let Some(v) = field(line, "id") {
162                    id = Some(v);
163                }
164                if let Some(v) = field(line, "name") {
165                    name = Some(v);
166                }
167                if let Some(v) = field(line, "version") {
168                    version = Some(v);
169                }
170                if let Some(v) = field(line, "description") {
171                    description = Some(v);
172                }
173                if let Some(v) = field(line, "icon") {
174                    icon = Some(v);
175                }
176                if let Some(v) = field(line, "license") {
177                    license = Some(v);
178                }
179                if let Some(v) = field(line, "exports_license") {
180                    exports_license = Some(v);
181                }
182                if let Some(v) = field(line, "edition") {
183                    edition = Some(v);
184                }
185                if line.trim_start().starts_with("authors") {
186                    authors = parse_string_array(line);
187                }
188            }
189            "dev" => {
190                if let Some(v) = field(line, "yog_api_path") {
191                    yog_api_path = Some(v);
192                }
193            }
194            "dependencies" => {
195                if let Some((name, spec)) = parse_dep_line(line) {
196                    dependencies.push((name, spec));
197                }
198            }
199            "dependencies.exports" => {
200                if let Some((name, spec)) = parse_dep_line(line) {
201                    mod_dependencies.push((name, spec));
202                }
203            }
204            "dev-dependencies" => {
205                if let Some((name, spec)) = parse_dep_line(line) {
206                    dev_dependencies.push((name, spec));
207                }
208            }
209            _ if section.starts_with("run.") => {
210                let run_name = &section["run.".len()..];
211                let cfg = run_configs
212                    .entry(run_name.to_string())
213                    .or_insert_with(|| RunConfig {
214                        name: run_name.to_string(),
215                        ..Default::default()
216                    });
217                if let Some(v) = field(line, "name") {
218                    cfg.display_name = Some(v);
219                }
220                if let Some(v) = field(line, "export_dir") {
221                    cfg.export_dir = Some(v);
222                }
223                if let Some(v) = field(line, "command") {
224                    cfg.command = Some(v);
225                }
226                if let Some(v) = field(line, "cwd") {
227                    cfg.cwd = Some(v);
228                }
229                if line.trim_start().starts_with("args") {
230                    cfg.args = parse_string_array(line);
231                }
232                if line.trim_start().starts_with("env") {
233                    cfg.env = parse_string_array(line)
234                        .into_iter()
235                        .filter_map(|kv| {
236                            kv.split_once('=')
237                                .map(|(k, v)| (k.to_string(), v.to_string()))
238                        })
239                        .collect();
240                }
241            }
242            _ => {}
243        }
244    }
245    let run_configs: Vec<RunConfig> = run_configs.into_values().collect();
246
247    let id = id.ok_or("yog.toml: missing [mod] id")?;
248    // Extract yog_api_version / yog-api from dependencies if there
249    let mut yog_api_version = None;
250    let mut filtered_deps = Vec::new();
251    for (name, spec) in dependencies {
252        if matches!(name.as_str(), "yog_api_version" | "yog-api" | "yog_api") {
253            yog_api_version = Some(spec.trim_matches('"').to_string());
254        } else {
255            filtered_deps.push((name, spec));
256        }
257    }
258    Ok(YogToml {
259        name: name.unwrap_or_else(|| id.clone()),
260        version: version.unwrap_or_else(|| "0.1.0".into()),
261        description: description.unwrap_or_default(),
262        name_key: None,
263        description_key: None,
264        icon,
265        authors,
266        license: license.unwrap_or_else(|| DEFAULT_LICENSE.into()),
267        exports_license,
268        edition,
269        yog_api_path,
270        yog_api_version,
271        dependencies: filtered_deps,
272        mod_dependencies,
273        dev_dependencies,
274        run_configs,
275        id,
276    })
277}
278
279/// Parse a dependency line like `foo = "1.2"` or `bar = { version = "1", features = ["x"] }`
280fn parse_dep_line(line: &str) -> Option<(String, String)> {
281    let trimmed = line.trim();
282    if trimmed.starts_with('#') || trimmed.is_empty() || trimmed.starts_with('[') {
283        return None;
284    }
285    let eq_pos = trimmed.find('=')?;
286    let name = trimmed[..eq_pos].trim().to_string();
287    let spec = trimmed[eq_pos + 1..].trim().to_string();
288    Some((name, spec))
289}
290
291/// Parse `key = "value"` returning the unquoted value.
292fn field(line: &str, key: &str) -> Option<String> {
293    let rest = line.strip_prefix(key)?.trim_start();
294    let rest = rest.strip_prefix('=')?.trim();
295    Some(rest.trim_matches('"').to_string())
296}
297
298/// Parse `key = ["a", "b"]` into a Vec<String>.
299fn parse_string_array(line: &str) -> Vec<String> {
300    let inner = line
301        .find('[')
302        .and_then(|s| line.rfind(']').map(|e| &line[s + 1..e]));
303    inner
304        .unwrap_or("")
305        .split(',')
306        .map(|s| s.trim().trim_matches('"').to_string())
307        .filter(|s| !s.is_empty())
308        .collect()
309}
310
311/// Cargo derives a lib crate name from the package `name` by turning `-` into
312/// `_`, but keeps case — so a mod `id` like "My-Mod" becomes the invalid
313/// `My_Mod` and rustc warns on every build. `id` is a free-form project
314/// identifier (not required to be a valid Rust ident), so the generated
315/// wrapper pins `[lib] name` to a proper snake_case form instead.
316pub fn to_snake_name(id: &str) -> String {
317    let mut out = String::with_capacity(id.len());
318    for c in id.chars() {
319        if c == '-' || c == '_' {
320            if !out.ends_with('_') {
321                out.push('_');
322            }
323        } else if c.is_uppercase() {
324            if !out.is_empty() && !out.ends_with('_') {
325                out.push('_');
326            }
327            out.extend(c.to_lowercase());
328        } else {
329            out.push(c);
330        }
331    }
332    out
333}
334
335/// rkyv version embedded at compile time via `build.rs`.
336pub fn workspace_rkyv_version() -> String {
337    env!("RKYV_VERSION").to_string()
338}
339
340/// Fetch the latest non-yanked version of `yog-api` from crates.io.
341pub fn latest_yog_api_version() -> String {
342    match std::process::Command::new("cargo")
343        .args(["search", "yog-api", "--limit", "1"])
344        .output()
345    {
346        Ok(out) if out.status.success() => {
347            String::from_utf8_lossy(&out.stdout)
348                .lines()
349                .next()
350                .and_then(|l| {
351                    let mut parts = l.split_whitespace();
352                    parts.next(); // skip crate name (e.g. "yog-api")
353                    if parts.next() == Some("=") {
354                        // skip the "=" separator
355                        parts.next().map(|v| v.trim_matches('"').to_owned())
356                    } else {
357                        None
358                    }
359                })
360                .unwrap_or_else(|| FALLBACK_API_VERSION.into())
361        }
362        _ => FALLBACK_API_VERSION.into(),
363    }
364}