bijux-cli 0.3.6

Command-line runtime for automation, plugin-driven tools, and interactive workflows with structured output.
Documentation
//! Plugin scaffold generation helpers.

use std::fs;
use std::path::{Component, Path, PathBuf};

use anyhow::Result;
use semver::{Prerelease, Version};

use super::{
    is_reserved_namespace, parse_manifest_v2, validate_manifest, validate_namespace_text,
    RESERVED_NAMESPACES,
};

const SCAFFOLD_PLUGIN_VERSION: &str = "0.1.0";
const RUST_SCAFFOLD_ENTRYPOINT: &str = "plugin-entrypoint";

fn is_safe_scaffold_path(path: &Path) -> bool {
    !path.components().any(|component| matches!(component, Component::ParentDir))
}

fn scaffold_compatibility_window() -> Result<(String, String)> {
    let release_line = Version::parse(env!("CARGO_PKG_VERSION"))
        .map_err(|error| anyhow::anyhow!("runtime semver is invalid: {error}"))?;

    let mut min = Version::new(release_line.major, release_line.minor, release_line.patch);
    if !release_line.pre.is_empty() {
        let channel = release_line
            .pre
            .as_str()
            .split('.')
            .next()
            .expect("non-empty prerelease has a first identifier");
        min.pre = Prerelease::new(channel)
            .map_err(|error| anyhow::anyhow!("runtime prerelease channel is invalid: {error}"))?;
    }

    let max = if release_line.major == 0 {
        Version::new(0, release_line.minor.saturating_add(1), 0)
    } else {
        Version::new(release_line.major.saturating_add(1), 0, 0)
    };
    Ok((min.to_string(), max.to_string()))
}

fn scaffold_manifest_json(plugin_kind: &str, entrypoint: &str, namespace: &str) -> Result<String> {
    let (min_inclusive, max_exclusive) = scaffold_compatibility_window()?;
    Ok(format!(
        "{{\n  \"name\": \"{}\",\n  \"version\": \"{}\",\n  \"schema_version\": \"v2\",\n  \"manifest_version\": \"v2\",\n  \"compatibility\": {{ \"min_inclusive\": \"{}\", \"max_exclusive\": \"{}\" }},\n  \"namespace\": \"{}\",\n  \"kind\": \"{}\",\n  \"aliases\": [],\n  \"entrypoint\": \"{}\",\n  \"capabilities\": []\n}}\n",
        namespace,
        SCAFFOLD_PLUGIN_VERSION,
        min_inclusive,
        max_exclusive,
        namespace,
        plugin_kind,
        entrypoint,
    ))
}

fn scaffold_manifest_contract(kind: &str) -> Result<(&'static str, &'static str)> {
    match kind {
        "python" => Ok(("python", "plugin:main")),
        "rust" => Ok(("external-exec", RUST_SCAFFOLD_ENTRYPOINT)),
        _ => anyhow::bail!("plugin scaffold kind must be one of: python, rust"),
    }
}

fn rust_entrypoint_script(binary_name: &str, namespace: &str) -> String {
    format!(
        "#!/usr/bin/env sh\nset -eu\n\nSCRIPT_DIR=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" && pwd)\ncd \"$SCRIPT_DIR\"\n\nexport CARGO_TARGET_DIR=\"$SCRIPT_DIR/target\"\nBIN_PATH=\"$CARGO_TARGET_DIR/debug/{binary_name}\"\nLOCK_PATH=\"$SCRIPT_DIR/Cargo.lock\"\nneeds_build=0\nneeds_lock_refresh=0\nif [ ! -f \"$LOCK_PATH\" ]; then\n  needs_lock_refresh=1\nelif [ \"$SCRIPT_DIR/Cargo.toml\" -nt \"$LOCK_PATH\" ]; then\n  needs_lock_refresh=1\nfi\nif [ ! -x \"$BIN_PATH\" ]; then\n  needs_build=1\nelif [ \"$SCRIPT_DIR/Cargo.toml\" -nt \"$BIN_PATH\" ]; then\n  needs_build=1\nelif [ \"$LOCK_PATH\" -nt \"$BIN_PATH\" ]; then\n  needs_build=1\nelif [ -d \"$SCRIPT_DIR/src\" ] && find \"$SCRIPT_DIR/src\" -type f -name '*.rs' -newer \"$BIN_PATH\" -print -quit | grep -q .; then\n  needs_build=1\nfi\n\nif [ \"$needs_build\" -eq 1 ] || [ \"$needs_lock_refresh\" -eq 1 ]; then\n  if ! command -v cargo >/dev/null 2>&1; then\n    echo \"cargo is required to build the {namespace} plugin binary\" >&2\n    exit 1\n  fi\nfi\nif [ \"$needs_lock_refresh\" -eq 1 ]; then\n  lock_stderr=$(mktemp)\n  if ! cargo generate-lockfile 1>/dev/null 2>\"$lock_stderr\"; then\n    cat \"$lock_stderr\" >&2\n    rm -f \"$lock_stderr\"\n    exit 1\n  fi\n  rm -f \"$lock_stderr\"\nfi\nif [ \"$needs_build\" -eq 1 ]; then\n  build_stderr=$(mktemp)\n  if ! cargo build --quiet --locked 2>\"$build_stderr\"; then\n    cat \"$build_stderr\" >&2\n    rm -f \"$build_stderr\"\n    exit 1\n  fi\n  rm -f \"$build_stderr\"\nfi\n\nexec \"$BIN_PATH\" \"$@\"\n"
    )
}

#[cfg(unix)]
fn mark_executable(path: &Path) -> Result<()> {
    use std::os::unix::fs::PermissionsExt;

    fs::set_permissions(path, fs::Permissions::from_mode(0o755))?;
    Ok(())
}

#[cfg(not(unix))]
fn mark_executable(_path: &Path) -> Result<()> {
    Ok(())
}

pub(crate) fn scaffold_plugin_layout(
    base_dir: &Path,
    kind: &str,
    namespace: &str,
    force: bool,
) -> Result<PathBuf> {
    if is_reserved_namespace(namespace, &[]) {
        anyhow::bail!("plugin namespace is reserved: {namespace}");
    }
    validate_namespace_text(namespace).map_err(anyhow::Error::from)?;
    if !is_safe_scaffold_path(base_dir) {
        anyhow::bail!("scaffold path is unsafe");
    }
    let (plugin_kind, entrypoint) = scaffold_manifest_contract(kind)?;
    if base_dir.exists() {
        if !force {
            anyhow::bail!("scaffold path already exists; pass --force to overwrite");
        }

        if base_dir.is_dir() {
            fs::remove_dir_all(base_dir)?;
        } else {
            fs::remove_file(base_dir)?;
        }
    }

    fs::create_dir_all(base_dir)?;
    let manifest_path = base_dir.join("plugin.manifest.json");
    fs::write(&manifest_path, scaffold_manifest_json(plugin_kind, entrypoint, namespace)?)?;
    if kind == "python" {
        fs::write(
            base_dir.join("plugin.py"),
            "def main(argv: list[str]) -> dict:\n    return {\"status\": \"ok\", \"argv\": argv}\n",
        )?;
    } else {
        let cargo_package_name = namespace;
        let cargo_module_name = namespace.replace('-', "_");
        fs::write(
            base_dir.join("Cargo.toml"),
            format!(
                "[package]\nname = \"{cargo_package_name}\"\nversion = \"{SCAFFOLD_PLUGIN_VERSION}\"\nedition = \"2021\"\nlicense = \"Apache-2.0\"\ndescription = \"Rust executable plugin for {namespace}\"\n\n[lib]\nname = \"{cargo_module_name}\"\npath = \"src/lib.rs\"\n\n[[bin]]\nname = \"{cargo_package_name}\"\npath = \"src/main.rs\"\n\n[dependencies]\nserde_json = \"1\"\n"
            ),
        )?;
        let entrypoint_path = base_dir.join(RUST_SCAFFOLD_ENTRYPOINT);
        fs::write(&entrypoint_path, rust_entrypoint_script(cargo_package_name, namespace))?;
        mark_executable(&entrypoint_path)?;
        fs::create_dir_all(base_dir.join("src"))?;
        fs::write(
            base_dir.join("src/lib.rs"),
            format!(
                "use serde_json::{{json, Value}};\n\npub fn run(argv: &[String]) -> Value {{\n    json!({{\"status\": \"ok\", \"namespace\": \"{namespace}\", \"argv\": argv}})\n}}\n\npub fn help_text() -> &'static str {{\n    \"Usage: {namespace} [ARGS]\\n\\nRuns the {namespace} Rust plugin entrypoint.\"\n}}\n"
            ),
        )?;
        fs::write(
            base_dir.join("src/main.rs"),
            format!(
                "use std::process::ExitCode;\n\nfn main() -> ExitCode {{\n    let argv = std::env::args().skip(1).collect::<Vec<_>>();\n    if argv.iter().any(|arg| matches!(arg.as_str(), \"--help\" | \"-h\")) {{\n        println!(\"{{}}\", {rust_module}::help_text());\n        return ExitCode::SUCCESS;\n    }}\n\n    match serde_json::to_string_pretty(&{rust_module}::run(&argv)) {{\n        Ok(rendered) => {{\n            println!(\"{{rendered}}\");\n            ExitCode::SUCCESS\n        }}\n        Err(error) => {{\n            eprintln!(\"failed to render plugin payload: {{error}}\");\n            ExitCode::from(1)\n        }}\n    }}\n}}\n",
                rust_module = cargo_module_name
            ),
        )?;
    }

    // Shared validation step: generated manifest must pass plugin parser.
    let manifest_text = fs::read_to_string(&manifest_path)?;
    let manifest = parse_manifest_v2(&manifest_text)?;
    let _ = validate_manifest(manifest, env!("CARGO_PKG_VERSION"), RESERVED_NAMESPACES)?;

    Ok(manifest_path)
}