rusteal 0.6.1

Rust bindings for Unreal Engine 5.8.2
use std::fmt;
use std::path::{Path, PathBuf};

pub const PINNED: [&str; 3] = ["rusteal-runtime", "rusteal-core", "rusteal-ffi"];

pub const PLUGINS: [&str; 2] = ["Rusteal", "RustealGenerator"];

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Version(u32, u32, u32);

impl Version {
    pub fn parse(text: &str) -> Option<Self> {
        let mut parts = text.split('.');

        let mut next = || -> Option<u32> {
            let part = parts.next()?;

            if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) {
                return None;
            }

            part.parse().ok()
        };

        let version = Version(next()?, next()?, next()?);

        parts.next().is_none().then_some(version)
    }

    pub fn cli() -> Self {
        Self::parse(env!("CARGO_PKG_VERSION")).expect("CARGO_PKG_VERSION is major.minor.patch")
    }
}

impl fmt::Display for Version {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}.{}.{}", self.0, self.1, self.2)
    }
}

#[derive(Debug, PartialEq, Eq)]
pub enum Pins {
    Exact(Version),
    Checkout(PathBuf),
}

pub fn parse_pins(manifest: &str) -> Result<Pins, String> {
    let manifest: toml::Table =
        toml::from_str(manifest).map_err(|e| format!("cannot parse it: {e}"))?;

    let deps = manifest
        .get("workspace")
        .and_then(|w| w.get("dependencies"))
        .and_then(|d| d.as_table())
        .ok_or("it has no [workspace.dependencies]")?;

    let mut pins = Vec::new();

    for name in PINNED {
        let value = deps.get(name).ok_or(format!("{name} is missing"))?;

        let requirement = match value {
            toml::Value::String(s) => Some(s.as_str()),
            toml::Value::Table(t) => t.get("version").and_then(|v| v.as_str()),
            _ => None,
        };

        let path = value
            .as_table()
            .and_then(|t| t.get("path"))
            .and_then(|p| p.as_str());

        let pin = match (requirement, path) {
            (_, Some(path)) => {
                let crate_dir = Path::new(path);

                let checkout = crate_dir
                    .parent()
                    .ok_or(format!("{name}: path {path} has no parent"))?;

                Pins::Checkout(checkout.to_path_buf())
            }
            (Some(req), None) => {
                let version = req
                    .strip_prefix('=')
                    .and_then(Version::parse)
                    .ok_or(format!("{name} = \"{req}\" is not an exact \"=X.Y.Z\""))?;

                Pins::Exact(version)
            }
            (None, None) => return Err(format!("{name} has neither a version nor a path")),
        };

        pins.push(pin);
    }

    let first = pins.remove(0);

    if pins.iter().any(|p| *p != first) {
        return Err(format!(
            "{} do not all point at the same version",
            PINNED.join(", ")
        ));
    }

    Ok(first)
}

#[derive(Debug, PartialEq, Eq)]
pub enum Verdict {
    Matches,
    ProjectOlder(Version),
    ProjectNewer(Version),
    OtherCheckout(PathBuf),
}

pub fn compare(pins: &Pins, cli: Version, cli_checkout: Option<&Path>) -> Verdict {
    match pins {
        Pins::Exact(v) if *v == cli => Verdict::Matches,
        Pins::Exact(v) if *v < cli => Verdict::ProjectOlder(*v),
        Pins::Exact(v) => Verdict::ProjectNewer(*v),
        Pins::Checkout(dir) => {
            let same = cli_checkout
                .zip(dir.canonicalize().ok())
                .is_some_and(|(cli, dir)| cli == dir);

            if same {
                Verdict::Matches
            } else {
                Verdict::OtherCheckout(dir.clone())
            }
        }
    }
}

fn cli_checkout() -> Option<PathBuf> {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()?
        .canonicalize()
        .ok()
}

fn explain(verdict: &Verdict, cli: Version) -> Option<String> {
    Some(match verdict {
        Verdict::Matches => return None,
        Verdict::ProjectOlder(p) => format!(
            "this project is at Rusteal {p} and the CLI at {cli}.\n  \
             Move the project to {cli}:       rusteal upgrade\n  \
             or use the CLI it was made with: cargo install rusteal@{p}"
        ),
        Verdict::ProjectNewer(p) => format!(
            "this project is at Rusteal {p}, newer than this CLI ({cli}).\n  \
             Install its version: cargo install rusteal@{p}"
        ),
        Verdict::OtherCheckout(dir) => format!(
            "this project depends on the Rusteal checkout at {dir}.\n  \
             Run the CLI built from it: cargo run --manifest-path {manifest} -p rusteal -- <command>",
            dir = dir.display(),
            manifest = dir.join("Cargo.toml").display(),
        ),
    })
}

pub fn read_pins(root: &Path) -> Result<Pins, String> {
    let path = root.join("Rust/Cargo.toml");

    let text = std::fs::read_to_string(&path).map_err(|e| {
        format!(
            "cannot read {}: {e}. The project needs the Rust workspace `rusteal new` writes.",
            path.display()
        )
    })?;

    let pins = parse_pins(&text).map_err(|e| {
        format!(
            "{}: {e}.\n  rusteal-runtime, rusteal-core and rusteal-ffi must all be pinned to one \
             exact version (\"=X.Y.Z\") or all point at one Rusteal checkout by path.",
            path.display()
        )
    })?;

    Ok(match pins {
        Pins::Checkout(dir) if dir.is_relative() => Pins::Checkout(root.join("Rust").join(dir)),
        pins => pins,
    })
}

pub enum Scope {
    Pins,
    PinsAndPlugins,
}

pub fn check(root: &Path, scope: Scope) -> Result<(), String> {
    let cli = Version::cli();
    let pins = read_pins(root)?;

    if let Some(message) = explain(&compare(&pins, cli, cli_checkout().as_deref()), cli) {
        return Err(message);
    }

    for plugin in rusteal_codegen::config::find_plugins(root) {
        let plugin_pins = read_pins(&plugin.dir)?;

        if let Some(message) = explain(&compare(&plugin_pins, cli, cli_checkout().as_deref()), cli)
        {
            return Err(format!("the {} plugin: {message}", plugin.name));
        }
    }

    if let Scope::PinsAndPlugins = scope {
        for plugin in PLUGINS {
            let path = root
                .join("Plugins")
                .join(plugin)
                .join(format!("{plugin}.uplugin"));

            let installed = std::fs::read_to_string(&path)
                .ok()
                .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
                .and_then(|d| d["VersionName"].as_str().map(str::to_string));

            if installed.as_deref() != Some(&cli.to_string()) {
                return Err(format!(
                    "Plugins/{plugin} is at {}, the project at {cli}.\n  \
                     Reinstall the plugins: rusteal setup {}",
                    installed.as_deref().unwrap_or("an unknown version"),
                    root.display()
                ));
            }
        }
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn v(text: &str) -> Version {
        Version::parse(text).unwrap()
    }

    const EXACT: &str = r#"# comment
[workspace]
members = ["game", "bindings"]

[workspace.dependencies]
rusteal-runtime = "=0.2.1"
rusteal-core = "=0.2.1"
rusteal-ffi = "=0.2.1"
glam = "0.33.8"
"#;

    #[test]
    fn versions_are_three_numbers() {
        assert_eq!(Version::parse("0.2.1"), Some(Version(0, 2, 1)));
        assert_eq!(Version::parse("12.0.345"), Some(Version(12, 0, 345)));

        for bad in [
            "0.2",
            "0.2.1.0",
            "0.2.1-alpha",
            "^0.2.1",
            "a.b.c",
            "0..1",
            "",
        ] {
            assert_eq!(Version::parse(bad), None, "{bad}");
        }

        assert!(v("0.10.0") > v("0.9.9"));
    }

    #[test]
    fn exact_pins() {
        assert_eq!(parse_pins(EXACT), Ok(Pins::Exact(v("0.2.1"))));

        let table = EXACT.replace(
            "rusteal-core = \"=0.2.1\"",
            "rusteal-core = { version = \"=0.2.1\" }",
        );

        assert_eq!(parse_pins(&table), Ok(Pins::Exact(v("0.2.1"))));
    }

    #[test]
    fn checkout_pins() {
        let manifest = r#"[workspace.dependencies]
rusteal-runtime = { path = "/src/rusteal/rusteal-runtime" }
rusteal-core = { path = "/src/rusteal/rusteal-core" }
rusteal-ffi = { path = "/src/rusteal/rusteal-ffi" }
"#;

        assert_eq!(
            parse_pins(manifest),
            Ok(Pins::Checkout(PathBuf::from("/src/rusteal")))
        );
    }

    #[test]
    fn rejected_pins() {
        for (from, to) in [
            ("rusteal-ffi = \"=0.2.1\"", "rusteal-ffi = \"0.2.1\""),
            ("rusteal-ffi = \"=0.2.1\"", "rusteal-ffi = \"^0.2.1\""),
            ("rusteal-ffi = \"=0.2.1\"", "rusteal-ffi = \"=0.2\""),
            ("rusteal-ffi = \"=0.2.1\"", "rusteal-ffi = \"=0.2.0\""),
            ("rusteal-ffi = \"=0.2.1\"", ""),
            (
                "rusteal-ffi = \"=0.2.1\"",
                "rusteal-ffi = { path = \"/src/rusteal/rusteal-ffi\" }",
            ),
        ] {
            let manifest = EXACT.replace(from, to);
            assert!(parse_pins(&manifest).is_err(), "{to:?} accepted");
        }

        assert!(parse_pins("[workspace]\nmembers = []\n").is_err());
    }

    #[test]
    fn compare_exact() {
        let cli = v("0.3.0");

        assert_eq!(
            compare(&Pins::Exact(v("0.3.0")), cli, None),
            Verdict::Matches
        );

        assert_eq!(
            compare(&Pins::Exact(v("0.2.1")), cli, None),
            Verdict::ProjectOlder(v("0.2.1"))
        );

        assert_eq!(
            compare(&Pins::Exact(v("0.3.1")), cli, None),
            Verdict::ProjectNewer(v("0.3.1"))
        );
    }

    #[test]
    fn compare_checkout() {
        let here = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .unwrap()
            .canonicalize()
            .unwrap();

        let pins = Pins::Checkout(here.clone());
        let cli = v("0.2.1");
        assert_eq!(compare(&pins, cli, Some(&here)), Verdict::Matches);

        assert_eq!(
            compare(&pins, cli, None),
            Verdict::OtherCheckout(here.clone())
        );

        assert_eq!(
            compare(&pins, cli, Some(Path::new("/elsewhere"))),
            Verdict::OtherCheckout(here)
        );
    }
}