r2x 0.1.0

A framework plugin manager for the r2x power systems modeling ecosystem.
Documentation
use r2x_manifest::runtime::{build_runtime_bindings, PluginRole};
use r2x_manifest::types::{Manifest, Package, Plugin};
use std::collections::HashSet;
use std::fmt;

#[derive(Debug)]
pub enum PluginRefError {
    NotFound(String),
    Ambiguous {
        plugin_ref: String,
        package: String,
        matches: Vec<String>,
    },
}

impl fmt::Display for PluginRefError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            PluginRefError::NotFound(name) => {
                write!(f, "Plugin '{}' not found in manifest", name)
            }
            PluginRefError::Ambiguous {
                plugin_ref,
                package,
                matches,
            } => {
                write!(
                    f,
                    "Plugin reference '{}' is ambiguous in package '{}': {}",
                    plugin_ref,
                    package,
                    matches.join(", ")
                )
            }
        }
    }
}

impl std::error::Error for PluginRefError {}

pub struct ResolvedPlugin<'a> {
    pub package: &'a Package,
    pub plugin: &'a Plugin,
}

pub fn resolve_plugin_ref<'a>(
    manifest: &'a Manifest,
    plugin_ref: &str,
) -> Result<ResolvedPlugin<'a>, PluginRefError> {
    if let Some(resolved) = find_plugin_by_name(manifest, plugin_ref) {
        return Ok(resolved);
    }

    if let Some((package_part, plugin_part)) = plugin_ref.split_once('.') {
        for package_name in name_variants(package_part) {
            let Some(package) = manifest.get_package(&package_name) else {
                continue;
            };

            if let Some(plugin) = find_plugin_in_package(package, plugin_part) {
                return Ok(ResolvedPlugin { package, plugin });
            }

            if let Some(role) = alias_role(plugin_part) {
                let matches: Vec<&Plugin> = package
                    .plugins
                    .iter()
                    .filter(|plugin| plugin_role(plugin) == role)
                    .collect();

                match matches.len() {
                    0 => {}
                    1 => {
                        return Ok(ResolvedPlugin {
                            package,
                            plugin: matches[0],
                        });
                    }
                    _ => {
                        let names = matches
                            .iter()
                            .map(|plugin| plugin.name.to_string())
                            .collect();
                        return Err(PluginRefError::Ambiguous {
                            plugin_ref: plugin_ref.to_string(),
                            package: package.name.to_string(),
                            matches: names,
                        });
                    }
                }
            }
        }
    }

    Err(PluginRefError::NotFound(plugin_ref.to_string()))
}

fn find_plugin_by_name<'a>(
    manifest: &'a Manifest,
    plugin_name: &str,
) -> Option<ResolvedPlugin<'a>> {
    for candidate in name_variants(plugin_name) {
        if let Some((package, plugin)) = manifest.packages.iter().find_map(|package| {
            package
                .plugins
                .iter()
                .find(|plugin| plugin.name.as_ref() == candidate)
                .map(|plugin| (package, plugin))
        }) {
            return Some(ResolvedPlugin { package, plugin });
        }
    }
    None
}

fn find_plugin_in_package<'a>(package: &'a Package, plugin_name: &str) -> Option<&'a Plugin> {
    for candidate in name_variants(plugin_name) {
        if let Some(plugin) = package
            .plugins
            .iter()
            .find(|plugin| plugin.name.as_ref() == candidate)
        {
            return Some(plugin);
        }
    }
    None
}

fn name_variants(name: &str) -> Vec<String> {
    let mut seen = HashSet::new();
    let mut variants = Vec::new();
    for candidate in [
        name.to_string(),
        name.replace('_', "-"),
        name.replace('-', "_"),
    ] {
        if seen.insert(candidate.clone()) {
            variants.push(candidate);
        }
    }
    variants
}

fn alias_role(name: &str) -> Option<PluginRole> {
    let normalized = name.replace('-', "_").to_lowercase();
    match normalized.as_str() {
        "parser" => Some(PluginRole::Parser),
        "exporter" => Some(PluginRole::Exporter),
        "upgrader" => Some(PluginRole::Upgrader),
        "modifier" | "transform" | "transformer" => Some(PluginRole::Modifier),
        "translation" | "translator" => Some(PluginRole::Translation),
        "utility" => Some(PluginRole::Utility),
        _ => None,
    }
}

fn plugin_role(plugin: &Plugin) -> PluginRole {
    build_runtime_bindings(plugin).role
}

#[cfg(test)]
mod tests {
    use crate::manifest_lookup::*;
    use r2x_manifest::types::PluginType;
    use std::sync::Arc;

    fn sample_manifest() -> Manifest {
        let mut manifest = Manifest::default();
        let mut package = Package {
            name: Arc::from("r2x-reeds"),
            ..Default::default()
        };

        package.plugins.push(Plugin {
            name: Arc::from("reeds-parser"),
            plugin_type: PluginType::Class,
            module: Arc::from("r2x_reeds"),
            class_name: Some(Arc::from("ReEDSParser")),
            ..Default::default()
        });

        package.plugins.push(Plugin {
            name: Arc::from("break-gens"),
            plugin_type: PluginType::Function,
            module: Arc::from("r2x_reeds.sysmod.break_gens"),
            function_name: Some(Arc::from("break_generators")),
            ..Default::default()
        });

        manifest.packages.push(package);
        manifest.rebuild_indexes();
        manifest
    }

    #[test]
    fn resolves_plugin_by_exact_name() {
        let manifest = sample_manifest();
        let resolved = resolve_plugin_ref(&manifest, "reeds-parser");
        assert!(resolved.is_ok_and(|r| r.plugin.name.as_ref() == "reeds-parser"));
    }

    #[test]
    fn resolves_plugin_by_package_prefix() {
        let manifest = sample_manifest();
        let resolved = resolve_plugin_ref(&manifest, "r2x-reeds.reeds-parser");
        assert!(resolved.is_ok_and(|r| r.plugin.name.as_ref() == "reeds-parser"));
    }

    #[test]
    fn resolves_plugin_with_underscore_variants() {
        let manifest = sample_manifest();
        let resolved = resolve_plugin_ref(&manifest, "r2x_reeds.break_gens");
        assert!(resolved.is_ok_and(|r| r.plugin.name.as_ref() == "break-gens"));
    }

    #[test]
    fn resolves_plugin_kind_alias() {
        let manifest = sample_manifest();
        let resolved = resolve_plugin_ref(&manifest, "r2x-reeds.parser");
        assert!(resolved.is_ok_and(|r| r.plugin.name.as_ref() == "reeds-parser"));
    }
}