forklaunch 1.29.0

Launch faster with forklaunch
use std::{fs::create_dir_all, path::Path};

use anyhow::{Context, Result};
use include_dir::{Dir, File};
use ramhorns::Template;
use regex::Regex;

use super::{
    manifest::ManifestData,
    rendered_template::{RenderedTemplate, TEMPLATES_DIR},
};
use crate::constants::{error_failed_to_create_dir, Module};

#[derive(Debug, Clone)]
pub(crate) struct PathIO {
    pub(crate) input_path: String,
    pub(crate) output_path: String,
    pub(crate) module_id: Option<Module>,
}

pub(crate) fn get_directory_filenames(path: &'_ PathIO) -> Result<Vec<&'_ File<'_>>> {
    Ok(TEMPLATES_DIR
        .get_dir(&path.input_path)
        .unwrap()
        .files()
        .collect())
}

pub(crate) fn get_directory_subdirectory_names(path: &'_ PathIO) -> Result<Vec<&'_ Dir<'_>>> {
    Ok(TEMPLATES_DIR
        .get_dir(&path.input_path)
        .unwrap()
        .dirs()
        .collect())
}

pub(crate) fn get_file_contents(filepath: &Path) -> Result<String> {
    Ok(TEMPLATES_DIR
        .get_file(filepath)
        .unwrap()
        .contents_utf8()
        .unwrap()
        .to_string())
}

fn database_replacements(database: &String, template: String) -> String {
    match database.as_str() {
        "mongodb" => {
            let re = Regex::new(r"([^a-zA-Z0-9_$]|^)sqlBaseProperties").unwrap();
            re.replace_all(&template, "${1}nosqlBaseProperties")
                .replace("id: uuid", "id: string")
        }
        _ => template,
    }
}

fn forklaunch_replacements(app_name: &String, template: String) -> String {
    template.replace("@forklaunch/blueprint-", format!("@{app_name}/").as_str())
}

pub(crate) fn generate_with_template(
    output_prefix: Option<&String>,
    template_dir: &PathIO,
    data: &ManifestData,
    ignore_files: &Vec<String>,
    ignore_dirs: &Vec<String>,
    preserve_files: &Vec<String>,
    dryrun: bool,
) -> Result<Vec<RenderedTemplate>> {
    let mut rendered_templates = Vec::new();

    let output_dir = match output_prefix {
        Some(output_prefix) => Path::new(output_prefix).join(&template_dir.output_path),
        None => Path::new(&template_dir.output_path).to_path_buf(),
    };

    for entry in get_directory_filenames(&template_dir)? {
        let output_path_template =
            Template::new(entry.path().file_name().unwrap().to_string_lossy())?;
        let output_path = output_dir.join(match data {
            ManifestData::Application(manifest_data) => output_path_template.render(manifest_data),
            ManifestData::Service(manifest_data) => output_path_template.render(manifest_data),
            ManifestData::Library(manifest_data) => output_path_template.render(manifest_data),
            ManifestData::Router(manifest_data) => output_path_template.render(manifest_data),
            ManifestData::Worker(manifest_data) => output_path_template.render(manifest_data),
        });

        if !output_path.exists() && !dryrun {
            create_dir_all(output_path.parent().unwrap())
                .with_context(|| error_failed_to_create_dir(&output_path.parent().unwrap()))?;
        }

        let file_contents = get_file_contents(&Path::new(&entry.path())).with_context(|| {
            format!(
                "Failed to parse template file {}",
                &entry.path().to_string_lossy()
            )
        })?;
        let tpl = Template::new(file_contents.clone())?;
        let rendered = match data {
            ManifestData::Application(manifest_data) => {
                forklaunch_replacements(&manifest_data.app_name, tpl.render(&manifest_data))
            }
            ManifestData::Service(manifest_data) => database_replacements(
                &manifest_data.database,
                forklaunch_replacements(&manifest_data.app_name, tpl.render(&manifest_data)),
            ),
            ManifestData::Library(manifest_data) => {
                forklaunch_replacements(&manifest_data.app_name, tpl.render(&manifest_data))
            }
            ManifestData::Router(manifest_data) => {
                forklaunch_replacements(&manifest_data.app_name, tpl.render(&manifest_data))
            }
            ManifestData::Worker(manifest_data) => {
                forklaunch_replacements(&manifest_data.app_name, tpl.render(&manifest_data))
            }
        };
        if !output_path.exists() {
            let output_path_str = output_path.file_name().unwrap().to_str().unwrap();
            let should_ignore = ignore_files
                .iter()
                .any(|ignore_file| output_path_str == ignore_file);
            let should_preserve = preserve_files
                .iter()
                .any(|preserve_file| output_path_str == preserve_file);

            if !should_ignore {
                if should_preserve {
                    rendered_templates.push(RenderedTemplate {
                        path: output_path,
                        content: file_contents,
                        context: None,
                    });
                } else {
                    rendered_templates.push(RenderedTemplate {
                        path: output_path,
                        content: rendered,
                        context: None,
                    });
                }
            }
        }
    }

    for subdirectory in get_directory_subdirectory_names(&template_dir)? {
        let should_ignore = ignore_dirs
            .iter()
            .any(|ignore_dir| subdirectory.path().to_string_lossy().contains(ignore_dir));

        if !should_ignore {
            rendered_templates.extend(
                generate_with_template(
                    output_prefix,
                    &PathIO {
                        input_path: Path::new(&subdirectory.path())
                            .to_string_lossy()
                            .to_string(),
                        output_path: Path::new(&template_dir.output_path)
                            .join(&subdirectory.path().file_name().unwrap())
                            .to_string_lossy()
                            .to_string(),
                        module_id: None,
                    },
                    data,
                    ignore_files,
                    ignore_dirs,
                    preserve_files,
                    dryrun,
                )
                .with_context(|| {
                    format!(
                        "Failed to create templates for {}",
                        &subdirectory.path().to_string_lossy()
                    )
                })?,
            );
        }
    }

    Ok(rendered_templates)
}

pub(crate) fn get_routers_from_standard_package(package: Module) -> Option<Vec<String>> {
    match package {
        Module::BaseBilling => Some(vec![
            String::from("billingPortal"),
            String::from("checkoutSession"),
            String::from("paymentLink"),
            String::from("plan"),
            String::from("subscription"),
        ]),
        Module::StripeBilling => Some(vec![
            String::from("billingPortal"),
            String::from("checkoutSession"),
            String::from("paymentLink"),
            String::from("plan"),
            String::from("subscription"),
            String::from("webhook"),
        ]),
        Module::BaseIam | Module::BetterAuthIam => Some(vec![
            String::from("organization"),
            String::from("permission"),
            String::from("role"),
            String::from("user"),
        ]),
        Module::BaseMessaging => Some(vec![String::from("sms")]),
        Module::TwilioMessaging => Some(vec![String::from("sms"), String::from("webhook")]),
        Module::StripeEcommerce => Some(vec![
            String::from("product"),
            String::from("variant"),
            String::from("inventory"),
            String::from("cart"),
            String::from("checkout"),
            String::from("order"),
            String::from("payment"),
            String::from("webhook"),
            String::from("catalogImport"),
        ]),
        // Skeleton only so far — patient/encounter/claim/eligibility/remittance/
        // codeSet routers land in later phases (see plan/cac/), not this PR.
        Module::BaseCac => None,
        // Relay injects its router directly into an existing iam service
        // (init/relay.rs); it never goes through the standard-package manifest
        // path, so it declares no routers here.
        Module::Relay => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::rendered_template::TEMPLATES_DIR;

    /// The routers a module declares land in the scaffolded app's
    /// manifest.toml, and sync, analyze, delete and the release manifest all
    /// read that list. Nothing else compares it against the templates, so a
    /// router added to a blueprint without a matching entry here silently
    /// produces a manifest naming routes the app does not serve, and omitting
    /// ones it does. That is how "subscription" survived here with no
    /// subscription route, while checkout shipped undeclared.
    ///
    /// Only ecommerce is asserted. billing-stripe and iam-base drift the same
    /// way today on main — billing omits compliance, iam omits compliance and
    /// discovery — and correcting those belongs with those modules, not here.
    fn routers_in_template(project: &str) -> Vec<String> {
        let dir = TEMPLATES_DIR
            .get_dir(format!("project/{project}/api/routes"))
            .unwrap_or_else(|| panic!("no routes dir for {project}"));
        let mut found: Vec<String> = dir
            .files()
            .filter_map(|f| f.path().file_name()?.to_str())
            .filter_map(|n| n.strip_suffix(".routes.ts"))
            .map(str::to_string)
            .collect();
        found.sort();
        found
    }

    #[test]
    fn ecommerce_declares_exactly_the_routers_it_ships() {
        let mut declared =
            get_routers_from_standard_package(Module::StripeEcommerce).unwrap_or_default();
        declared.sort();
        assert_eq!(declared, routers_in_template("ecommerce-stripe"));
    }
    #[test]
    fn emitted_service_and_worker_templates_keep_only_used_dependencies() {
        #[derive(ramhorns::Content)]
        struct Flags {
            is_worker: bool,
            is_database_enabled: bool,
            is_iam_configured: bool,
        }
        for worker in [false, true] {
            for database in [false, true] {
                let flags = Flags {
                    is_worker: worker,
                    is_database_enabled: database,
                    is_iam_configured: true,
                };
                let render = |path: &str| {
                    ramhorns::Template::new(
                        TEMPLATES_DIR
                            .get_file(path)
                            .unwrap()
                            .contents_utf8()
                            .unwrap(),
                    )
                    .unwrap()
                    .render(&flags)
                };
                let registration = render("project/service/registrations.ts");
                for name in [
                    "ComplianceDataService",
                    "RetentionService",
                    "wrapEmWithTenantContext",
                ] {
                    assert_eq!(
                        registration.contains(name),
                        database,
                        "{name}: worker={worker}, database={database}"
                    );
                }
                let server = render("project/service/server.ts");
                assert!(!server.contains("PERMISSIONS, ROLES"));
                let controller = render("router/api/controllers/{{camel_case_name}}.controller.ts");
                assert!(!controller.contains("const openTelemetryCollector"));
                assert!(controller.contains("allowedRoles: new Set([ROLES.ADMIN])"));
                for kind in ["service", "worker"] {
                    let utils = render(&format!("project/{kind}/__test__/test-utils.ts"));
                    assert_eq!(utils.contains("import { getEnvVar }"), database);
                }
                let event = render("router/domain/types/{{camel_case_name}}EventRecord.types.ts");
                assert_eq!(event.contains("export {};"), !worker);
                if worker {
                    assert!(
                        render("router/domain/services/{{camel_case_name}}.service.ts")
                            .contains("id: _id, createdAt: _createdAt, updatedAt: _updatedAt")
                    );
                }
            }
        }
    }
}