rustferry-codegen 0.1.0

Rust-only application project and capability code generation for RustFerry
Documentation
use camino::Utf8PathBuf;
use rustferry_core::{FerryConfig, ProjectNames};

use crate::{RuntimeDependency, TemplateKind};

pub(crate) struct TemplateContext {
    pub(crate) names: ProjectNames,
    pub(crate) config: FerryConfig,
    pub(crate) kind: TemplateKind,
    pub(crate) runtime_dependency: RuntimeDependency,
}

pub(crate) struct ProjectFile {
    pub(crate) relative_path: Utf8PathBuf,
    pub(crate) contents: Vec<u8>,
}

#[allow(clippy::too_many_lines)]
pub(crate) fn project_files(
    context: &TemplateContext,
) -> Result<Vec<ProjectFile>, rustferry_core::ConfigError> {
    let mut files = vec![
        text_file("Cargo.toml", cargo_manifest(context)),
        text_file("ferry.toml", context.config.to_pretty_toml()?),
        text_file(
            "README.md",
            render(
                if context.kind == TemplateKind::Minimal {
                    include_str!("../templates/minimal/README.md.tpl")
                } else {
                    include_str!("../templates/starter/README.md.tpl")
                },
                context,
            ),
        ),
        text_file(
            ".gitignore",
            include_str!("../templates/starter/gitignore.tpl"),
        ),
        text_file(
            "src/main.rs",
            render(
                include_str!("../templates/starter/src/main.rs.tpl"),
                context,
            ),
        ),
        text_file("src/capabilities/mod.rs", ""),
        binary_file("assets/icon.png", DEFAULT_ICON_PNG),
        binary_file("assets/splash.png", DEFAULT_SPLASH_PNG),
    ];

    if context.kind == TemplateKind::Minimal {
        files.push(text_file(
            "src/lib.rs",
            render(include_str!("../templates/minimal/src/lib.rs.tpl"), context),
        ));
        files.push(text_file(
            "src/app.rs",
            render(include_str!("../templates/minimal/src/app.rs.tpl"), context),
        ));
    } else {
        files.extend([
            text_file(
                "src/lib.rs",
                render(include_str!("../templates/starter/src/lib.rs.tpl"), context),
            ),
            text_file(
                "src/app.rs",
                render(include_str!("../templates/starter/src/app.rs.tpl"), context),
            ),
            text_file(
                "src/state.rs",
                include_str!("../templates/starter/src/state.rs.tpl"),
            ),
            text_file(
                "src/services/mod.rs",
                include_str!("../templates/starter/src/services/mod.rs.tpl"),
            ),
            text_file(
                "src/services/network.rs",
                include_str!("../templates/starter/src/services/network.rs.tpl"),
            ),
            text_file(
                "tests/basic.rs",
                render(
                    include_str!("../templates/starter/tests/basic.rs.tpl"),
                    context,
                ),
            ),
        ]);
    }

    if context.config.extensions.widget.enabled {
        files.push(text_file("src/extensions/mod.rs", "pub mod widget;\n"));
        files.push(text_file(
            "src/extensions/widget.rs",
            render(
                include_str!("../templates/fragments/widget.rs.tpl"),
                context,
            ),
        ));
    }
    if context.config.extensions.live_activity.enabled {
        if !files
            .iter()
            .any(|file| file.relative_path == "src/extensions/mod.rs")
        {
            files.push(text_file(
                "src/extensions/mod.rs",
                "pub mod live_activity;\n",
            ));
        } else if let Some(module) = files
            .iter_mut()
            .find(|file| file.relative_path == "src/extensions/mod.rs")
        {
            module
                .contents
                .extend_from_slice(b"pub mod live_activity;\n");
        }
        files.push(text_file(
            "src/extensions/live_activity.rs",
            render(
                include_str!("../templates/fragments/live_activity.rs.tpl"),
                context,
            ),
        ));
    }
    Ok(files)
}

fn cargo_manifest(context: &TemplateContext) -> String {
    let (runtime_source, workspace_section) = match &context.runtime_dependency {
        RuntimeDependency::Registry(version) => (
            format!("version = {}", toml_string(&format!("={version}"))),
            "[workspace]\n\n",
        ),
        RuntimeDependency::Workspace => ("workspace = true".to_owned(), ""),
        RuntimeDependency::Path(path) => (
            format!("path = {}", toml_string(path.as_str())),
            "[workspace]\n\n",
        ),
    };
    let mut capabilities = Vec::new();
    if context.config.capabilities.storage.enabled {
        capabilities.push("storage");
    }
    if context.config.capabilities.network.mode != rustferry_core::NetworkMode::None {
        capabilities.push("network");
    }
    if context.config.capabilities.haptics.enabled {
        capabilities.push("haptics");
    }
    if context.config.capabilities.notifications.local {
        capabilities.push("notifications");
    }
    if context.config.capabilities.clipboard.enabled {
        capabilities.push("clipboard");
    }
    if context.config.capabilities.share.enabled {
        capabilities.push("share");
    }
    if !context.config.capabilities.deep_links.schemes.is_empty() {
        capabilities.push("deep-links");
    }
    if context.config.extensions.widget.enabled {
        capabilities.push("widgets");
    }
    if context.config.extensions.live_activity.enabled {
        capabilities.push("live-activity");
    }
    let features = capabilities
        .iter()
        .map(|feature| toml_string(feature))
        .collect::<Vec<_>>()
        .join(", ");

    format!(
        "[package]\nname = {crate_name}\nversion = \"0.1.0\"\nedition = \"2024\"\nrust-version = \"1.92\"\npublish = false\n\n{workspace_section}[lib]\nname = {crate_ident}\ncrate-type = [\"cdylib\", \"rlib\"]\n\n[[bin]]\nname = {crate_name}\npath = \"src/main.rs\"\n\n[dependencies]\nrustferry = {{ package = {runtime_package}, {runtime_source}, default-features = false, features = [{features}] }}\nserde = {{ version = \"1.0\", features = [\"derive\"] }}\nslint = {{ version = \"=1.17.1\", default-features = false, features = [\"std\", \"compat-1-2\", \"backend-winit\", \"backend-android-activity-06\", \"renderer-skia\"] }}\nthiserror = \"2.0\"\n\n[lints.rust]\nunsafe_code = \"deny\"\n",
        crate_name = toml_string(&context.names.crate_name),
        crate_ident = toml_string(&context.names.crate_name.replace('-', "_")),
        runtime_package = toml_string(rustferry_core::brand::RUNTIME_PACKAGE),
    )
}

fn render(template: &str, context: &TemplateContext) -> String {
    let kitchen_sink_handlers = if context.kind == TemplateKind::KitchenSink {
        include_str!("../templates/fragments/kitchen_sink_handlers.rs.tpl").replace(
            "{{display_name_literal}}",
            &rust_string(&context.names.display_name),
        )
    } else {
        String::new()
    };
    template
        .replace("{{crate_name}}", &context.names.crate_name)
        .replace(
            "{{crate_ident}}",
            &context.names.crate_name.replace('-', "_"),
        )
        .replace("{{display_name}}", &context.names.display_name)
        .replace(
            "{{display_name_literal}}",
            &rust_string(&context.names.display_name),
        )
        .replace("{{identifier}}", &context.names.application_identifier)
        .replace("{{template}}", context.kind.as_str())
        .replace(
            "{{deep_link_scheme}}",
            context
                .config
                .capabilities
                .deep_links
                .schemes
                .first()
                .map_or("app", String::as_str),
        )
        .replace(
            "{{notifications_enabled}}",
            if context.config.capabilities.notifications.local {
                "true"
            } else {
                "false"
            },
        )
        .replace(
            "{{haptics_enabled}}",
            if context.config.capabilities.haptics.enabled {
                "true"
            } else {
                "false"
            },
        )
        .replace(
            "{{network_enabled}}",
            if context.config.capabilities.network.mode == rustferry_core::NetworkMode::None {
                "false"
            } else {
                "true"
            },
        )
        .replace(
            "{{live_activity_enabled}}",
            if context.config.extensions.live_activity.enabled {
                "true"
            } else {
                "false"
            },
        )
        .replace(
            "{{widget_publish_after_save}}",
            if context.config.extensions.widget.enabled {
                ".and_then(|()| crate::extensions::widget::publish(state.count))"
            } else {
                ""
            },
        )
        .replace(
            "{{live_activity_handlers}}",
            if context.config.extensions.live_activity.enabled {
                include_str!("../templates/fragments/live_activity_handlers.rs.tpl")
            } else {
                ""
            },
        )
        .replace(
            "{{kitchen_sink_enabled}}",
            if context.kind == TemplateKind::KitchenSink {
                "true"
            } else {
                "false"
            },
        )
        .replace("{{kitchen_sink_handlers}}", &kitchen_sink_handlers)
        .replace(
            "{{extensions_module}}",
            if context.config.extensions.widget.enabled
                || context.config.extensions.live_activity.enabled
            {
                "mod extensions;"
            } else {
                ""
            },
        )
}

fn rust_string(value: &str) -> String {
    serde_json::to_string(value).expect("a string always serializes")
}

fn toml_string(value: &str) -> String {
    toml::Value::String(value.to_owned()).to_string()
}

fn text_file(path: &str, contents: impl Into<String>) -> ProjectFile {
    ProjectFile {
        relative_path: Utf8PathBuf::from(path),
        contents: contents.into().into_bytes(),
    }
}

fn binary_file(path: &str, contents: &[u8]) -> ProjectFile {
    ProjectFile {
        relative_path: Utf8PathBuf::from(path),
        contents: contents.to_vec(),
    }
}

const DEFAULT_ICON_PNG: &[u8] = include_bytes!("../assets/default-icon.png");
const DEFAULT_SPLASH_PNG: &[u8] = include_bytes!("../assets/default-splash.png");