use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub const MANIFEST: &str = "yog.toml";
pub const BUILD_DIR: &str = ".yog-build";
pub const DEPS_SECTION: &str = "[dependencies]";
pub const EXPORT_DEPS_SECTION: &str = "[dependencies.exports]";
pub const DEFAULT_EDITION: &str = "2024";
pub const DEFAULT_LICENSE: &str = "MIT OR Apache-2.0";
const FALLBACK_API_VERSION: &str = "0.5";
pub fn project_root() -> Result<PathBuf, String> {
let root = std::env::current_dir().map_err(|e| e.to_string())?;
if root.join(MANIFEST).is_file() {
Ok(root)
} else {
Err(format!("no {MANIFEST} found in the current directory"))
}
}
#[derive(Debug)]
pub struct YogToml {
pub id: String,
pub name: String,
pub version: String,
pub description: String,
pub name_key: Option<String>,
pub description_key: Option<String>,
pub icon: Option<String>,
pub authors: Vec<String>,
pub license: String,
pub exports_license: Option<String>,
pub edition: Option<String>,
pub yog_api_path: Option<String>,
pub yog_api_version: Option<String>,
pub dependencies: Vec<(String, String)>,
pub mod_dependencies: Vec<(String, String)>,
pub dev_dependencies: Vec<(String, String)>,
pub run_configs: Vec<RunConfig>,
pub externals: Vec<External>,
}
#[derive(Debug, Default, Clone)]
pub struct External {
pub namespace: String,
pub ids: Vec<(String, Vec<String>)>,
}
#[derive(Debug, Default, Clone)]
pub struct RunConfig {
pub name: String,
pub display_name: Option<String>,
pub export_dir: Option<String>,
pub command: Option<String>,
pub args: Vec<String>,
pub cwd: Option<String>,
pub env: Vec<(String, String)>,
}
impl YogToml {
pub fn read(path: &Path) -> Result<Self, String> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("reading {}: {e}", path.display()))?;
parse_yog_toml(&text)
}
pub fn api_dep(&self) -> String {
if let Ok(p) = std::env::var("YOG_API_PATH") {
let abs = PathBuf::from(&p)
.canonicalize()
.unwrap_or_else(|_| PathBuf::from(&p));
return format!("yog-api = {{ path = {:?} }}", abs.to_string_lossy());
}
if let Some(p) = &self.yog_api_path {
return format!("yog-api = {{ path = {p:?} }}");
}
let version = {
let env_version = std::env::var("YOG_API_VERSION").ok();
self.yog_api_version
.as_deref()
.or(env_version.as_deref())
.map(|v| v.to_owned())
.unwrap_or_else(latest_yog_api_version)
};
format!("yog-api = {:?}", version)
}
}
pub fn parse_yog_toml(text: &str) -> Result<YogToml, String> {
let mut section = "";
let mut id = None::<String>;
let mut name = None::<String>;
let mut version = None::<String>;
let mut description = None::<String>;
let mut icon = None::<String>;
let mut authors: Vec<String> = Vec::new();
let mut license = None::<String>;
let mut exports_license = None::<String>;
let mut edition = None::<String>;
let mut yog_api_path = None::<String>;
let mut dependencies: Vec<(String, String)> = Vec::new();
let mut mod_dependencies: Vec<(String, String)> = Vec::new();
let mut dev_dependencies: Vec<(String, String)> = Vec::new();
let mut run_configs: HashMap<String, RunConfig> = HashMap::new();
let mut externals: Vec<External> = Vec::new();
for raw in text.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if line.starts_with('[') {
section = line.trim_matches(|c| c == '[' || c == ']');
continue;
}
match section {
"mod" => {
if let Some(v) = field(line, "id") {
id = Some(v);
}
if let Some(v) = field(line, "name") {
name = Some(v);
}
if let Some(v) = field(line, "version") {
version = Some(v);
}
if let Some(v) = field(line, "description") {
description = Some(v);
}
if let Some(v) = field(line, "icon") {
icon = Some(v);
}
if let Some(v) = field(line, "license") {
license = Some(v);
}
if let Some(v) = field(line, "exports_license") {
exports_license = Some(v);
}
if let Some(v) = field(line, "edition") {
edition = Some(v);
}
if line.trim_start().starts_with("authors") {
authors = parse_string_array(line);
}
}
"dev" => {
if let Some(v) = field(line, "yog_api_path") {
yog_api_path = Some(v);
}
}
"dependencies" => {
if let Some((name, spec)) = parse_dep_line(line) {
dependencies.push((name, spec));
}
}
"dependencies.exports" => {
if let Some((name, spec)) = parse_dep_line(line) {
mod_dependencies.push((name, spec));
}
}
"dev-dependencies" => {
if let Some((name, spec)) = parse_dep_line(line) {
dev_dependencies.push((name, spec));
}
}
_ if section.starts_with("external.") => {
let ns = §ion["external.".len()..];
if externals.last().is_none_or(|e| e.namespace != ns) {
externals.push(External { namespace: ns.to_string(), ids: Vec::new() });
}
if let Some((key, _)) = line.split_once('=')
&& let Some(e) = externals.last_mut()
{
e.ids.push((key.trim().to_string(), parse_string_array(line)));
}
}
_ if section.starts_with("run.") => {
let run_name = §ion["run.".len()..];
let cfg = run_configs
.entry(run_name.to_string())
.or_insert_with(|| RunConfig {
name: run_name.to_string(),
..Default::default()
});
if let Some(v) = field(line, "name") {
cfg.display_name = Some(v);
}
if let Some(v) = field(line, "export_dir") {
cfg.export_dir = Some(v);
}
if let Some(v) = field(line, "command") {
cfg.command = Some(v);
}
if let Some(v) = field(line, "cwd") {
cfg.cwd = Some(v);
}
if line.trim_start().starts_with("args") {
cfg.args = parse_string_array(line);
}
if line.trim_start().starts_with("env") {
cfg.env = parse_string_array(line)
.into_iter()
.filter_map(|kv| {
kv.split_once('=')
.map(|(k, v)| (k.to_string(), v.to_string()))
})
.collect();
}
}
_ => {}
}
}
let run_configs: Vec<RunConfig> = run_configs.into_values().collect();
let id = id.ok_or("yog.toml: missing [mod] id")?;
let mut yog_api_version = None;
let mut filtered_deps = Vec::new();
for (name, spec) in dependencies {
if matches!(name.as_str(), "yog_api_version" | "yog-api" | "yog_api") {
yog_api_version = Some(spec.trim_matches('"').to_string());
} else {
filtered_deps.push((name, spec));
}
}
Ok(YogToml {
name: name.unwrap_or_else(|| id.clone()),
version: version.unwrap_or_else(|| "0.1.0".into()),
description: description.unwrap_or_default(),
name_key: None,
description_key: None,
icon,
authors,
license: license.unwrap_or_else(|| DEFAULT_LICENSE.into()),
exports_license,
edition,
yog_api_path,
yog_api_version,
dependencies: filtered_deps,
mod_dependencies,
dev_dependencies,
run_configs,
externals,
id,
})
}
fn parse_dep_line(line: &str) -> Option<(String, String)> {
let trimmed = line.trim();
if trimmed.starts_with('#') || trimmed.is_empty() || trimmed.starts_with('[') {
return None;
}
let eq_pos = trimmed.find('=')?;
let name = trimmed[..eq_pos].trim().to_string();
let spec = trimmed[eq_pos + 1..].trim().to_string();
Some((name, spec))
}
fn field(line: &str, key: &str) -> Option<String> {
let rest = line.strip_prefix(key)?.trim_start();
let rest = rest.strip_prefix('=')?.trim();
Some(rest.trim_matches('"').to_string())
}
fn parse_string_array(line: &str) -> Vec<String> {
let inner = line
.find('[')
.and_then(|s| line.rfind(']').map(|e| &line[s + 1..e]));
inner
.unwrap_or("")
.split(',')
.map(|s| s.trim().trim_matches('"').to_string())
.filter(|s| !s.is_empty())
.collect()
}
pub fn to_snake_name(id: &str) -> String {
let mut out = String::with_capacity(id.len());
for c in id.chars() {
if c == '-' || c == '_' {
if !out.ends_with('_') {
out.push('_');
}
} else if c.is_uppercase() {
if !out.is_empty() && !out.ends_with('_') {
out.push('_');
}
out.extend(c.to_lowercase());
} else {
out.push(c);
}
}
out
}
pub fn workspace_rkyv_version() -> String {
env!("RKYV_VERSION").to_string()
}
pub fn latest_yog_api_version() -> String {
match std::process::Command::new("cargo")
.args(["search", "yog-api", "--limit", "1"])
.output()
{
Ok(out) if out.status.success() => {
String::from_utf8_lossy(&out.stdout)
.lines()
.next()
.and_then(|l| {
let mut parts = l.split_whitespace();
parts.next(); if parts.next() == Some("=") {
parts.next().map(|v| v.trim_matches('"').to_owned())
} else {
None
}
})
.unwrap_or_else(|| FALLBACK_API_VERSION.into())
}
_ => FALLBACK_API_VERSION.into(),
}
}