yog-cli 0.49.2

Yog build tool — `yog build` compiles a mod and packages it as .yog.
Documentation
//! The generated `.yog-build/Cargo.toml`.

use crate::manifest::{DEFAULT_EDITION, DEPS_SECTION, YogToml, to_snake_name};
use std::fmt::Write as _;
use std::path::{Path, PathBuf};

/// Generate the hidden Cargo.toml from yog.toml metadata.
/// `benches`: the mod's `yog bench` files — they add `[dev-dependencies]`,
/// `[[bench]]` targets and an `rlib` for them to link.
pub fn generate_cargo_toml(meta: &YogToml, benches: &[crate::bench::BenchFile]) -> String {
    let authors_toml = authors_line(meta);
    let edition = meta.edition.as_deref().unwrap_or(DEFAULT_EDITION);
    let patch = local_patch(meta);
    let deps_lines = dependency_lines(meta);
    format!(
        r#"# Generated by yog from yog.toml — do not edit.
[package]
name         = "{id}"
version      = "{version}"
edition      = "{edition}"
description  = {description:?}
{authors_line}license      = {license:?}

[lib]
name       = "{lib_name}"
crate-type = {crate_types}
path       = "../src/lib.rs"

[dependencies]
{api_dep}
{deps}
{bench}{patch}"#,
        id = meta.id,
        lib_name = to_snake_name(&meta.id),
        version = meta.version,
        edition = edition,
        description = meta.description,
        authors_line = authors_toml,
        license = meta.license,
        api_dep = meta.api_dep(),
        deps = deps_lines.join("\n"),
        patch = patch,
        crate_types = if benches.is_empty() {
            r#"["cdylib"]"#
        } else {
            r#"["cdylib", "rlib"]"#
        },
        bench = crate::bench::cargo_sections(meta, benches, &test_support(meta)),
    )
}

/// `authors = [...]`, when the mod names any.
fn authors_line(meta: &YogToml) -> String {
    if meta.authors.is_empty() {
        return String::new();
    }
    let list = meta
        .authors
        .iter()
        .map(|a| format!("{a:?}"))
        .collect::<Vec<_>>()
        .join(", ");
    format!("authors      = [{list}]\n")
}

/// With a local yog-api, its sibling yog-* crates (yog-ui, yog-gfx, ...)
/// come from the same checkout too — the local API may need versions of
/// them that aren't on crates.io yet; so do the mod's own yog-*
/// dependencies (yog-kit, ...), and yog-api for them.
fn local_patch(meta: &YogToml) -> String {
    let env_path = std::env::var(yog_ids::env::API_PATH).ok();
    let Some(api_path) = meta.yog_api_path.as_deref().or(env_path.as_deref()) else {
        return String::new();
    };
    let mut extra: Vec<&str> = meta
        .dependencies
        .iter()
        .map(|(name, _)| name.as_str())
        .filter(|n| n.starts_with(YOG_CRATE))
        .collect();
    if !extra.is_empty() {
        extra.push(YOG_API);
    }
    local_api_patch(Path::new(api_path), &extra)
}

use yog_ids::rust_crate::{API as YOG_API, PREFIX as YOG_CRATE};

/// `[dependencies]`: plain crates.io ones exactly as written, then other
/// Yog mods' (`[dependencies.exports]`) — each its exports crate under the
/// mod's own name, so mod code imports it as a direct dependency
/// (`use {name}::...`) and two never collide.
fn dependency_lines(meta: &YogToml) -> Vec<String> {
    let mut lines: Vec<String> = meta
        .dependencies
        .iter()
        .map(|(name, spec)| format!("{name} = {spec}"))
        .collect();
    for (name, spec) in &meta.mod_dependencies {
        let exports_name = crate::exports::crate_name(name);
        let inner = spec
            .strip_prefix('{')
            .map(|s| s.trim_end_matches('}').trim());
        lines.push(match inner {
            Some("") => format!("{name} = {{ package = \"{exports_name}\" }}"),
            Some(inner) => format!("{name} = {{ package = \"{exports_name}\", {inner} }}"),
            None => format!("{name} = {{ package = \"{exports_name}\", version = {spec} }}"),
        });
    }
    lines
}

/// Yog's crates with test support behind a feature: a mod's tests get it.
const TEST_SUPPORT: &[&str] = &["yog-kit"];
const TESTING_FEATURE: &str = "testing";

/// The mod's dependencies that have test support, again for its tests,
/// with the feature on (`yog-kit = { version = "0.11", features = ["testing"] }`).
fn test_support(meta: &YogToml) -> Vec<String> {
    meta.dependencies
        .iter()
        .filter(|(name, _)| TEST_SUPPORT.contains(&name.as_str()))
        .map(|(name, spec)| format!("{name} = {}", with_feature(spec, TESTING_FEATURE)))
        .collect()
}

/// A dependency's spec (`"0.11"` or `{ version = .. }`) with `feature` on.
fn with_feature(spec: &str, feature: &str) -> String {
    let spec = spec.trim();
    let features = format!("features = [{feature:?}]");
    match spec.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
        Some(inner) if inner.trim().is_empty() => format!("{{ {features} }}"),
        Some(inner) => format!("{{ {}, {features} }}", inner.trim()),
        None => format!("{{ version = {spec}, {features} }}"),
    }
}

/// The `yog-*` crates a yog-api `Cargo.toml` depends on, in order.
pub fn yog_dependencies(cargo_toml: &str) -> Vec<String> {
    let mut in_deps = false;
    let mut out = Vec::new();
    for line in cargo_toml.lines() {
        let line = line.trim();
        if line.starts_with('[') {
            in_deps = line == DEPS_SECTION;
            continue;
        }
        if !in_deps {
            continue;
        }
        let Some((name, _)) = line.split_once('=') else {
            continue;
        };
        // `yog-x = { workspace = true }` or the dotted `yog-x.workspace = true`.
        let name = name.split('.').next().unwrap_or(name).trim();
        if name.starts_with("yog-") && !out.iter().any(|n| n == name) {
            out.push(name.to_string());
        }
    }
    out
}

/// `[patch.crates-io]` pointing every yog-* dependency of the local yog-api
/// at `api_path`'s sibling crate directory (always including yog-interop,
/// whose proc-macros generated code must match).
/// `extra` adds the mod's own yog-* dependencies.
pub fn local_api_patch(api_path: &Path, extra: &[&str]) -> String {
    let crates_dir = api_path
        .parent()
        .map(Path::to_path_buf)
        .unwrap_or_else(|| PathBuf::from("crates"));
    let mut names = std::fs::read_to_string(api_path.join("Cargo.toml"))
        .map(|t| yog_dependencies(&t))
        .unwrap_or_default();
    if !names.iter().any(|n| n == "yog-interop") {
        names.push("yog-interop".into());
    }
    for name in extra {
        if !names.iter().any(|n| n == name) {
            names.push((*name).to_string());
        }
    }
    // Transitively: a local yog-ui may need a yog-graphics that crates.io
    // hasn't got yet.
    let mut i = 0;
    while i < names.len() {
        let deps = std::fs::read_to_string(crates_dir.join(&names[i]).join("Cargo.toml"))
            .map(|t| yog_dependencies(&t))
            .unwrap_or_default();
        for dep in deps {
            if !names.contains(&dep) {
                names.push(dep);
            }
        }
        i += 1;
    }
    let mut out = String::from("\n[patch.crates-io]\n");
    for name in names {
        let dir = crates_dir.join(&name);
        if !dir.join("Cargo.toml").is_file() && name != "yog-interop" {
            continue;
        }
        let abs = dir.canonicalize().unwrap_or(dir);
        let _ = writeln!(out, "{name} = {{ path = {:?} }}", abs.to_string_lossy());
    }
    out
}

#[cfg(test)]
mod tests {
    #[test]
    fn yog_dependencies_reads_the_dependencies_table() {
        let toml = "[package]\nname = \"yog-api\"\n\n[dependencies]\nyog-abi  = \"0.25\"\nyog-ui = { version = \"0.35\" }\nserde = \"1\"\n\n[dev-dependencies]\nyog-test = \"1\"\n";
        assert_eq!(super::yog_dependencies(toml), ["yog-abi", "yog-ui"]);
    }

    #[test]
    fn yog_dependencies_reads_workspace_inherited_lines() {
        let toml = "[dependencies]\nyog-abi = { workspace = true }\nyog-ui = { workspace = true }\nlibc = { workspace = true }\n";
        assert_eq!(super::yog_dependencies(toml), ["yog-abi", "yog-ui"]);
    }
}