fission-command-core 0.14.1

Shared project, manifest, target, and diagnostic model for the fission command
Documentation
use crate::{FissionProject, NativeModuleConfig, Target};
use anyhow::{bail, Result};
use serde::{de::Error as _, Deserialize, Deserializer, Serialize};
use std::fmt;
use std::path::PathBuf;
use std::str::FromStr;

#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(transparent)]
pub struct NativeVariant(String);

impl NativeVariant {
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl AsRef<str> for NativeVariant {
    fn as_ref(&self) -> &str {
        self.as_str()
    }
}

impl fmt::Display for NativeVariant {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

impl FromStr for NativeVariant {
    type Err = String;

    fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
        let valid = !value.is_empty()
            && value.len() <= 64
            && value.split('-').all(|segment| {
                !segment.is_empty()
                    && segment
                        .bytes()
                        .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
            });
        if !valid {
            return Err(
                "variant must be 1-64 lowercase ASCII letters or digits separated by single hyphens"
                    .to_string(),
            );
        }
        Ok(Self(value.to_string()))
    }
}

impl<'de> Deserialize<'de> for NativeVariant {
    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        String::deserialize(deserializer)?
            .parse()
            .map_err(D::Error::custom)
    }
}

impl NativeModuleConfig {
    pub fn is_selected_for_variant(&self, variant: Option<&NativeVariant>) -> bool {
        self.variants.is_empty() || variant.is_some_and(|variant| self.variants.contains(variant))
    }
}

impl FissionProject {
    pub fn native_modules_for_variant<'a>(
        &'a self,
        variant: Option<&'a NativeVariant>,
    ) -> impl Iterator<Item = &'a NativeModuleConfig> + 'a {
        self.native
            .modules
            .iter()
            .filter(move |module| module.is_selected_for_variant(variant))
    }
}

pub fn ensure_native_variant_target(target: Target, variant: Option<&NativeVariant>) -> Result<()> {
    if variant.is_some() && !matches!(target, Target::Linux | Target::Macos | Target::Windows) {
        bail!(
            "--variant is supported only for linux, macos, and windows desktop targets, not `{}`",
            target.as_str()
        );
    }
    Ok(())
}

pub fn variant_output_path(base: impl Into<PathBuf>, variant: Option<&NativeVariant>) -> PathBuf {
    let base = base.into();
    match variant {
        Some(variant) => base.join("variants").join(variant.as_str()),
        None => base,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn module_variant_membership_selects_restricted_and_universal_modules() {
        let project: FissionProject = toml::from_str(
            r#"
targets = ["macos"]

[app]
name = "Variant Demo"
app_id = "com.example.variant-demo"

[[native.modules]]
name = "shared"

[[native.modules]]
name = "scanner-engine"
variants = ["scanner"]

[[native.modules]]
name = "protection-engine"
variants = ["full"]
"#,
        )
        .unwrap();
        let scanner: NativeVariant = "scanner".parse().unwrap();
        let full: NativeVariant = "full".parse().unwrap();

        let names = |variant| {
            project
                .native_modules_for_variant(variant)
                .map(|module| module.name.as_str())
                .collect::<Vec<_>>()
        };
        assert_eq!(names(None), vec!["shared"]);
        assert_eq!(names(Some(&scanner)), vec!["shared", "scanner-engine"]);
        assert_eq!(names(Some(&full)), vec!["shared", "protection-engine"]);
    }

    #[test]
    fn variant_names_are_path_stable_and_lowercase() {
        assert!("scanner-v2".parse::<NativeVariant>().is_ok());
        assert!("Scanner".parse::<NativeVariant>().is_err());
        assert!("scanner--v2".parse::<NativeVariant>().is_err());
        assert!("scanner_v2".parse::<NativeVariant>().is_err());
    }

    #[test]
    fn selected_variants_get_distinct_output_paths() {
        let scanner: NativeVariant = "scanner".parse().unwrap();
        assert_eq!(
            variant_output_path("target/fission/release/macos/pkg", Some(&scanner)),
            PathBuf::from("target/fission/release/macos/pkg/variants/scanner")
        );
        assert_eq!(
            variant_output_path("target/fission/release/macos/pkg", None),
            PathBuf::from("target/fission/release/macos/pkg")
        );
    }
}