1use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7pub const MANIFEST: &str = "yog.toml";
9pub const BUILD_DIR: &str = ".yog-build";
11pub const DEPS_SECTION: &str = "[dependencies]";
13pub const EXPORT_DEPS_SECTION: &str = "[dependencies.exports]";
15pub const DEFAULT_EDITION: &str = "2024";
17pub const DEFAULT_LICENSE: &str = "MIT OR Apache-2.0";
19const FALLBACK_API_VERSION: &str = "0.5";
21
22pub 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#[derive(Debug)]
34pub struct YogToml {
35 pub id: String,
36 pub name: String,
37 pub version: String,
38 pub description: String,
39 pub name_key: Option<String>,
42 pub description_key: Option<String>,
43 pub icon: Option<String>,
45 pub authors: Vec<String>,
46 pub license: String,
47 pub exports_license: Option<String>,
53 pub edition: Option<String>,
54 pub yog_api_path: Option<String>,
57 pub yog_api_version: Option<String>,
59 pub dependencies: Vec<(String, String)>,
62 pub mod_dependencies: Vec<(String, String)>,
69 pub dev_dependencies: Vec<(String, String)>,
71 pub run_configs: Vec<RunConfig>,
73 pub externals: Vec<External>,
77}
78
79#[derive(Debug, Default, Clone)]
81pub struct External {
82 pub namespace: String,
83 pub ids: Vec<(String, Vec<String>)>,
85}
86
87#[derive(Debug, Default, Clone)]
90pub struct RunConfig {
91 pub name: String,
92 pub display_name: Option<String>,
96 pub export_dir: Option<String>,
99 pub command: Option<String>,
101 pub args: Vec<String>,
103 pub cwd: Option<String>,
105 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 pub fn api_dep(&self) -> String {
123 if let Ok(p) = std::env::var("YOG_API_PATH") {
124 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 = §ion["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 = §ion["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 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
304fn 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
316fn 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
323fn 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
336pub 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
360pub fn workspace_rkyv_version() -> String {
362 env!("RKYV_VERSION").to_string()
363}
364
365pub 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(); if parts.next() == Some("=") {
379 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}