forgex 0.9.0

CLI and runtime for the Forge full-stack framework
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, anyhow};
use include_dir::{Dir, File, include_dir};
use serde::Deserialize;

use super::frontend_target::FrontendTarget;

static EMBEDDED_EXAMPLES: Dir<'_> = include_dir!("$OUT_DIR/examples");

const SUPPORTED_TEMPLATE_IDS: &[&str] = &[
    "with-svelte/minimal",
    "with-svelte/demo",
    "with-svelte/realtime-todo-list",
    "with-dioxus/minimal",
    "with-dioxus/demo",
    "with-dioxus/realtime-todo-list",
];

#[derive(Debug, Clone, Deserialize)]
pub struct TemplateMetadata {
    pub template_id: String,
    pub frontend: String,
    pub canonical_internal_slug: String,
    #[serde(default)]
    pub rewrite_paths: Vec<String>,
    #[serde(default)]
    pub copy_exclude: Vec<String>,
    #[serde(default, rename = "replacement")]
    pub replacements: Vec<TemplateReplacement>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct TemplateReplacement {
    pub from: String,
    pub to: String,
    #[serde(default)]
    pub files: Vec<String>,
}

#[derive(Debug, Clone)]
pub struct TemplateDefinition {
    pub id: String,
    pub frontend: FrontendTarget,
    pub canonical_internal_slug: String,
    pub rewrite_paths: Vec<String>,
    pub copy_exclude: Vec<String>,
    pub replacements: Vec<TemplateReplacement>,
}

#[derive(Debug, Clone)]
pub struct BundledTemplateFile<'a> {
    pub relative_path: PathBuf,
    pub file: &'a File<'a>,
}

impl TemplateDefinition {
    pub fn bundled_files(&self) -> Result<Vec<BundledTemplateFile<'static>>> {
        let dir = EMBEDDED_EXAMPLES
            .get_dir(self.id.as_str())
            .ok_or_else(|| anyhow!("embedded examples missing directory '{}'", self.id))?;

        let mut files = Vec::new();
        collect_files(dir, Path::new(""), &mut files);
        Ok(files)
    }

    pub fn bundled_directories(&self) -> Result<Vec<PathBuf>> {
        let dir = EMBEDDED_EXAMPLES
            .get_dir(self.id.as_str())
            .ok_or_else(|| anyhow!("embedded examples missing directory '{}'", self.id))?;

        let mut directories = Vec::new();
        collect_directories(dir, Path::new(""), &mut directories);
        Ok(directories)
    }

    pub fn rewrite_file(&self, relative_path: &Path) -> bool {
        let relative = relative_path.to_string_lossy();
        self.rewrite_paths
            .iter()
            .any(|path| path == relative.as_ref())
    }

    pub fn should_exclude(&self, relative_path: &Path) -> bool {
        if relative_path == Path::new(".forge-template.toml") {
            return true;
        }

        self.copy_exclude
            .iter()
            .any(|entry| path_matches(relative_path, entry))
    }
}

pub fn supported_template_ids() -> &'static [&'static str] {
    SUPPORTED_TEMPLATE_IDS
}

pub fn load_template_definition(id: &str) -> Result<TemplateDefinition> {
    if !SUPPORTED_TEMPLATE_IDS.contains(&id) {
        return Err(anyhow!("unknown template '{}'", id));
    }

    let metadata_file = EMBEDDED_EXAMPLES
        .get_file(format!("{id}/.forge-template.toml").as_str())
        .ok_or_else(|| anyhow!("template metadata missing for '{}'", id))?;

    let metadata: TemplateMetadata = toml::from_str(
        metadata_file
            .contents_utf8()
            .ok_or_else(|| anyhow!("template metadata for '{}' is not valid UTF-8", id))?,
    )
    .with_context(|| format!("failed to parse template metadata for '{id}'"))?;

    if metadata.template_id != id {
        return Err(anyhow!(
            "template metadata mismatch for '{}': found '{}'",
            id,
            metadata.template_id
        ));
    }

    let frontend = metadata
        .frontend
        .parse()
        .map_err(|err: String| anyhow!("invalid frontend target for '{}': {}", id, err))?;

    Ok(TemplateDefinition {
        id: metadata.template_id,
        frontend,
        canonical_internal_slug: metadata.canonical_internal_slug,
        rewrite_paths: metadata.rewrite_paths,
        copy_exclude: metadata.copy_exclude,
        replacements: metadata.replacements,
    })
}

fn collect_files<'a>(dir: &'a Dir<'a>, prefix: &Path, files: &mut Vec<BundledTemplateFile<'a>>) {
    for file in dir.files() {
        let file_name = file
            .path()
            .file_name()
            .expect("bundled file should have a file name");
        files.push(BundledTemplateFile {
            relative_path: prefix.join(file_name),
            file,
        });
    }

    for child in dir.dirs() {
        let dir_name = child
            .path()
            .file_name()
            .expect("bundled directory should have a name");
        collect_files(child, &prefix.join(dir_name), files);
    }
}

fn collect_directories(dir: &Dir<'_>, prefix: &Path, directories: &mut Vec<PathBuf>) {
    for child in dir.dirs() {
        let dir_name = child
            .path()
            .file_name()
            .expect("bundled directory should have a name");
        let child_path = prefix.join(dir_name);
        directories.push(child_path.clone());
        collect_directories(child, &child_path, directories);
    }
}

fn path_matches(path: &Path, entry: &str) -> bool {
    if path == Path::new(entry) {
        return true;
    }

    path.components()
        .any(|component| component.as_os_str() == entry)
}