forklaunch 1.15.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::{Module, error_failed_to_create_dir};

#[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"));
    }
}