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}
74
75#[derive(Debug, Default, Clone)]
78pub struct RunConfig {
79 pub name: String,
80 pub display_name: Option<String>,
84 pub export_dir: Option<String>,
87 pub command: Option<String>,
89 pub args: Vec<String>,
91 pub cwd: Option<String>,
93 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 pub fn api_dep(&self) -> String {
111 if let Ok(p) = std::env::var("YOG_API_PATH") {
112 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 = §ion["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 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
279fn 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
291fn 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
298fn 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
311pub 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
335pub fn workspace_rkyv_version() -> String {
337 env!("RKYV_VERSION").to_string()
338}
339
340pub 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(); if parts.next() == Some("=") {
354 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}