folk-builder 0.2.0

Custom binary builder for Folk — generates and compiles a Folk server with selected plugins
Documentation
use crate::config::{BuildConfig, PluginEntry};

pub fn generate_cargo_toml(cfg: &BuildConfig) -> String {
    let mut deps = String::new();

    if let Some(path) = &cfg.build.folk_ext_path {
        deps.push_str(&format!(
            "folk-ext = {{ path = \"{}\", default-features = false }}\n",
            path
        ));
    } else {
        deps.push_str("folk-ext = { version = \"0.2\", default-features = false }\n");
    }

    deps.push_str(
        r#"folk-api = "0.2"
serde_json = "1"
toml = "0.8"
ext-php-rs = "0.15"
bytes = "1"
anyhow = "1"
"#,
    );

    for plugin in &cfg.plugin {
        deps.push_str(&generate_plugin_dep(plugin));
    }

    let patch_section = if let Some(path) = &cfg.build.folk_api_path {
        format!(
            "\n[patch.crates-io]\nfolk-api = {{ path = \"{}\" }}\n",
            path
        )
    } else {
        String::new()
    };

    format!(
        r#"[package]
name = "{output}"
version = "0.1.0"
edition = "2024"

[lib]
name = "{output}"
crate-type = ["cdylib"]

[dependencies]
{deps}
[build-dependencies]
ext-php-rs-build = "0.1"
{patch}"#,
        output = cfg.build.output,
        deps = deps,
        patch = patch_section
    )
}

fn generate_plugin_dep(p: &PluginEntry) -> String {
    if let Some(path) = &p.path {
        format!("{} = {{ path = \"{}\" }}\n", p.crate_name, path)
    } else if let Some(git) = &p.git {
        let ver = p.version.as_deref().unwrap_or("0.1");
        format!(
            "{} = {{ git = \"{}\", version = \"{}\" }}\n",
            p.crate_name, git, ver
        )
    } else {
        let ver = p.version.as_deref().unwrap_or("0.1");
        format!("{} = \"{}\"\n", p.crate_name, ver)
    }
}

pub fn generate_lib_rs(cfg: &BuildConfig) -> String {
    let imports: Vec<String> = cfg
        .plugin
        .iter()
        .map(|p| {
            let ident = p.crate_name.replace('-', "_");
            format!("use {}::folk_plugin_factory as {}_factory;", ident, ident)
        })
        .collect();

    let registrations: Vec<String> = cfg
        .plugin
        .iter()
        .map(|p| {
            let ident = p.crate_name.replace('-', "_");
            let key = if p.config_key.is_empty() {
                &p.crate_name
            } else {
                &p.config_key
            };
            format!(
                r#"    let cfg_{ident} = raw_cfg.get("{key}").cloned().unwrap_or(toml::Value::Table(Default::default()));
    let cfg_{ident}_json = serde_json::to_value(&cfg_{ident}).unwrap_or_default();
    plugins.push({ident}_factory().create(cfg_{ident}_json)?);
"#,
            )
        })
        .collect();

    format!(
        r#"//! Generated by folk-builder. Do not edit.
//! Plugins: {plugin_list}

use ext_php_rs::binary::Binary;
use ext_php_rs::prelude::*;
{imports}

fn create_plugins(config_path: &str) -> anyhow::Result<(folk_ext::folk_core::config::FolkConfig, Vec<Box<dyn folk_api::Plugin>>)> {{
    let config = folk_ext::folk_core::config::FolkConfig::load_from(config_path)?;
    let raw_cfg: toml::Table = {{
        let content = std::fs::read_to_string(config_path).unwrap_or_default();
        content.parse().unwrap_or_default()
    }};
    let mut plugins: Vec<Box<dyn folk_api::Plugin>> = Vec::new();
{registrations}
    Ok((config, plugins))
}}

// --- PHP wrappers ---

#[php_class]
#[php(name = "Folk\\Server")]
#[derive(Debug)]
pub struct FolkServer {{
    config_path: String,
}}

#[php_impl]
impl FolkServer {{
    pub fn __construct(config_path: String) -> Self {{
        Self {{ config_path }}
    }}

    pub fn start(&self) -> PhpResult<()> {{
        let (config, plugins) = create_plugins(&self.config_path)
            .map_err(|e| PhpException::default(format!("Config error: {{e}}")))?;
        folk_ext::start_server(config, plugins)
            .map_err(|e| PhpException::default(format!("Start error: {{e}}")))?;
        Ok(())
    }}
}}

#[php_function]
pub fn folk_version() -> String {{
    folk_ext::version()
}}

#[php_function]
pub fn folk_call(method: String, payload: Binary<u8>) -> PhpResult<Binary<u8>> {{
    let data: Vec<u8> = payload.into();
    let result = folk_ext::call_method(&method, bytes::Bytes::from(data))
        .map_err(|e| PhpException::default(format!("folk_call({{method}}): {{e}}")))?;
    Ok(Binary::new(result.to_vec()))
}}

#[php_function]
pub fn folk_worker_ready() -> PhpResult<bool> {{
    folk_ext::bridge::do_ready()
        .map_err(|e| PhpException::default(format!("folk_worker_ready: {{e}}")))
}}

#[php_function]
pub fn folk_worker_recv() -> PhpResult<Option<Vec<Binary<u8>>>> {{
    match folk_ext::bridge::do_recv() {{
        Ok(Some((method, payload))) => {{
            Ok(Some(vec![Binary::new(method.into_bytes()), Binary::new(payload)]))
        }},
        Ok(None) => Ok(None),
        Err(e) => Err(PhpException::default(format!("folk_worker_recv: {{e}}"))),
    }}
}}

#[php_function]
pub fn folk_worker_send(result: Binary<u8>) -> PhpResult<()> {{
    let data: Vec<u8> = result.into();
    folk_ext::bridge::do_send(&data)
        .map_err(|e| PhpException::default(format!("folk_worker_send: {{e}}")))
}}

#[php_function]
pub fn folk_worker_send_error(message: String) -> PhpResult<()> {{
    folk_ext::bridge::do_send_error(&message)
        .map_err(|e| PhpException::default(format!("folk_worker_send_error: {{e}}")))
}}

#[php_function]
pub fn folk_is_worker_thread() -> bool {{
    folk_ext::bridge::has_worker_state()
}}

#[php_function]
pub fn folk_worker_run(dispatch_fn: String) -> PhpResult<()> {{
    folk_ext::bridge::run_dispatch_loop(&dispatch_fn)
        .map_err(|e| PhpException::default(format!("folk_worker_run: {{e}}")))
}}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {{
    module
        .class::<FolkServer>()
        .function(wrap_function!(folk_version))
        .function(wrap_function!(folk_call))
        .function(wrap_function!(folk_worker_ready))
        .function(wrap_function!(folk_worker_recv))
        .function(wrap_function!(folk_worker_send))
        .function(wrap_function!(folk_worker_send_error))
        .function(wrap_function!(folk_is_worker_thread))
        .function(wrap_function!(folk_worker_run))
}}
"#,
        imports = imports.join("\n"),
        registrations = registrations.join(""),
        plugin_list = cfg
            .plugin
            .iter()
            .map(|p| p.crate_name.as_str())
            .collect::<Vec<_>>()
            .join(", ")
    )
}

pub fn generate_build_rs() -> String {
    r#"use ext_php_rs_build::{ApiVersion, PHPInfo, emit_check_cfg, emit_php_cfg_flags, find_php};

fn main() {
    let php = find_php().expect("Failed to find PHP");
    let info = PHPInfo::get(&php).expect("Failed to get PHP info");
    let version: ApiVersion = info
        .zend_version()
        .expect("Failed to get Zend version")
        .try_into()
        .expect("Unsupported PHP version");
    emit_php_cfg_flags(version);
    emit_check_cfg();

    let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
    if target_os == "macos" {
        println!("cargo:rustc-cdylib-link-arg=-undefined");
        println!("cargo:rustc-cdylib-link-arg=dynamic_lookup");
    }
}
"#
    .to_string()
}