rusteal 0.7.0

Rust bindings for Unreal Engine 5.8.2
use std::path::Path;

use rusteal_codegen::config::{PluginLayout, find_uproject};

use crate::project_version::{self, Pins};
use crate::{build_cmd, new_cmd, setup, templates};

const RESERVED: &[&str] = &["Rusteal", "RustealGenerator", "RustealEditor"];

pub struct PluginNewOptions<'a> {
    pub name: &'a str,
    pub template: &'a str,
    pub root: &'a Path,
    pub engine: &'a Path,
    pub build: bool,
}

pub fn run_plugin_new(opts: &PluginNewOptions) {
    let next_step = create_plugin(opts.name, opts.template, opts.root, opts.engine);

    if opts.build {
        build_cmd::run_build(opts.root, opts.engine, None, 1, false, None);
    }

    eprintln!("\nrusteal plugin new: done.");

    if !opts.build {
        eprintln!("  rusteal build --all   # UE build, bindings, plugin, cargo, deploy");
    }

    eprintln!("  # {next_step}");
}

pub fn create_plugin(name: &str, template: &str, root: &Path, engine: &Path) -> String {
    validate_name(name, root);

    let (template, manifest) = templates::select_plugin(template).unwrap_or_else(|message| {
        eprint!("Error: {message}");
        std::process::exit(1);
    });

    if let Some(feature) = &manifest.feature
        && feature.plugin != name
        && templates::plugin_has_content(template)
    {
        fail(&format!(
            "the {template} feature's assets live under /{plugin}/, so its plugin is named \
             {plugin}: rusteal plugin new {plugin} --template {template}",
            plugin = feature.plugin
        ));
    }

    let dir = root.join("Plugins").join(name);

    if dir.exists() {
        fail(&format!("{} already exists.", dir.display()));
    }

    let layout = PluginLayout::new(&dir);

    eprintln!(
        "rusteal plugin new: creating {} from the {template} plugin template",
        dir.display()
    );

    let crate_name = setup::crate_name_for(name);
    let context = plugin_context(name, &crate_name, root);
    let written = templates::write_plugin_files(template, &dir, &context);
    eprintln!("rusteal plugin new: {written} files (crate {crate_name})");
    new_cmd::write_bindings_placeholder(&layout.rust_workspace().join("bindings"));

    setup::write_cpp_stubs(
        &layout.output(Default::default()).cpp_generated,
        &format!("{name}_"),
    );

    enable_in_uproject(root, name);

    if let Some(feature) = &manifest.feature {
        new_cmd::copy_shared_packs(engine, root, &feature.shared_packs);
    }

    tera::Tera::one_off(&manifest.next_step, &context, false)
        .unwrap_or_else(|e| panic!("Failed to render next_step: {e}"))
}

fn validate_name(name: &str, root: &Path) {
    let ok = (1..=32).contains(&name.len())
        && name.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
        && name.chars().all(|c| c.is_ascii_alphanumeric());

    if !ok {
        fail(&format!(
            "'{name}' is not a valid plugin name (letters and digits, starting with a letter, \
             32 characters at most)."
        ));
    }

    let project = find_uproject(root)
        .and_then(|path| path.file_stem().map(|s| s.to_string_lossy().into_owned()))
        .unwrap_or_default();

    if RESERVED.iter().any(|r| r.eq_ignore_ascii_case(name)) || project.eq_ignore_ascii_case(name) {
        fail(&format!(
            "'{name}' is taken: the plugin's module would clash with Rusteal's or the project's."
        ));
    }
}

fn plugin_context(plugin: &str, crate_name: &str, root: &Path) -> tera::Context {
    let mut ctx = tera::Context::new();
    ctx.insert("plugin", plugin);
    ctx.insert("crate_name", crate_name);
    ctx.insert("version", env!("CARGO_PKG_VERSION"));
    ctx.insert("glam_version", new_cmd::GLAM_VERSION);

    if let Ok(Pins::Checkout(checkout)) = project_version::read_pins(root) {
        let checkout = checkout.canonicalize().unwrap_or(checkout);

        ctx.insert(
            "runtime_path",
            &checkout.to_string_lossy().replace('\\', "/"),
        );
    }

    ctx
}

fn enable_in_uproject(root: &Path, plugin: &str) {
    let Some(path) = find_uproject(root) else {
        fail(&format!("no .uproject in {}", root.display()));
    };

    let text = std::fs::read_to_string(&path)
        .unwrap_or_else(|e| fail(&format!("cannot read {}: {e}", path.display())));

    let mut json: serde_json::Value = serde_json::from_str(text.trim_start_matches('\u{feff}'))
        .unwrap_or_else(|e| fail(&format!("cannot parse {}: {e}", path.display())));

    let plugins = json
        .as_object_mut()
        .unwrap_or_else(|| fail(&format!("{} is not a JSON object", path.display())))
        .entry("Plugins")
        .or_insert_with(|| serde_json::Value::Array(Vec::new()))
        .as_array_mut()
        .unwrap_or_else(|| fail(&format!("{}: Plugins is not an array", path.display())));

    if !plugins.iter().any(|p| p["Name"] == plugin) {
        plugins.push(serde_json::json!({ "Name": plugin, "Enabled": true }));
    }

    let mut out = serde_json::to_string_pretty(&json).expect("serializable uproject");
    out.push('\n');

    std::fs::write(&path, out)
        .unwrap_or_else(|e| fail(&format!("cannot write {}: {e}", path.display())));

    eprintln!("  {} lists {plugin}", path.display());
}

fn fail(message: &str) -> ! {
    eprintln!("Error: {message}");
    std::process::exit(1);
}