Skip to main content

fission_command_core/
native_variant.rs

1use crate::{FissionProject, NativeModuleConfig, Target};
2use anyhow::{bail, Result};
3use serde::{de::Error as _, Deserialize, Deserializer, Serialize};
4use std::fmt;
5use std::path::PathBuf;
6use std::str::FromStr;
7
8#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
9#[serde(transparent)]
10pub struct NativeVariant(String);
11
12impl NativeVariant {
13    pub fn as_str(&self) -> &str {
14        &self.0
15    }
16}
17
18impl AsRef<str> for NativeVariant {
19    fn as_ref(&self) -> &str {
20        self.as_str()
21    }
22}
23
24impl fmt::Display for NativeVariant {
25    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
26        formatter.write_str(self.as_str())
27    }
28}
29
30impl FromStr for NativeVariant {
31    type Err = String;
32
33    fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
34        let valid = !value.is_empty()
35            && value.len() <= 64
36            && value.split('-').all(|segment| {
37                !segment.is_empty()
38                    && segment
39                        .bytes()
40                        .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
41            });
42        if !valid {
43            return Err(
44                "variant must be 1-64 lowercase ASCII letters or digits separated by single hyphens"
45                    .to_string(),
46            );
47        }
48        Ok(Self(value.to_string()))
49    }
50}
51
52impl<'de> Deserialize<'de> for NativeVariant {
53    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
54    where
55        D: Deserializer<'de>,
56    {
57        String::deserialize(deserializer)?
58            .parse()
59            .map_err(D::Error::custom)
60    }
61}
62
63impl NativeModuleConfig {
64    pub fn is_selected_for_variant(&self, variant: Option<&NativeVariant>) -> bool {
65        self.variants.is_empty() || variant.is_some_and(|variant| self.variants.contains(variant))
66    }
67}
68
69impl FissionProject {
70    pub fn native_modules_for_variant<'a>(
71        &'a self,
72        variant: Option<&'a NativeVariant>,
73    ) -> impl Iterator<Item = &'a NativeModuleConfig> + 'a {
74        self.native
75            .modules
76            .iter()
77            .filter(move |module| module.is_selected_for_variant(variant))
78    }
79}
80
81pub fn ensure_native_variant_target(target: Target, variant: Option<&NativeVariant>) -> Result<()> {
82    if variant.is_some() && !matches!(target, Target::Linux | Target::Macos | Target::Windows) {
83        bail!(
84            "--variant is supported only for linux, macos, and windows desktop targets, not `{}`",
85            target.as_str()
86        );
87    }
88    Ok(())
89}
90
91pub fn variant_output_path(base: impl Into<PathBuf>, variant: Option<&NativeVariant>) -> PathBuf {
92    let base = base.into();
93    match variant {
94        Some(variant) => base.join("variants").join(variant.as_str()),
95        None => base,
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn module_variant_membership_selects_restricted_and_universal_modules() {
105        let project: FissionProject = toml::from_str(
106            r#"
107targets = ["macos"]
108
109[app]
110name = "Variant Demo"
111app_id = "com.example.variant-demo"
112
113[[native.modules]]
114name = "shared"
115
116[[native.modules]]
117name = "scanner-engine"
118variants = ["scanner"]
119
120[[native.modules]]
121name = "protection-engine"
122variants = ["full"]
123"#,
124        )
125        .unwrap();
126        let scanner: NativeVariant = "scanner".parse().unwrap();
127        let full: NativeVariant = "full".parse().unwrap();
128
129        let names = |variant| {
130            project
131                .native_modules_for_variant(variant)
132                .map(|module| module.name.as_str())
133                .collect::<Vec<_>>()
134        };
135        assert_eq!(names(None), vec!["shared"]);
136        assert_eq!(names(Some(&scanner)), vec!["shared", "scanner-engine"]);
137        assert_eq!(names(Some(&full)), vec!["shared", "protection-engine"]);
138    }
139
140    #[test]
141    fn variant_names_are_path_stable_and_lowercase() {
142        assert!("scanner-v2".parse::<NativeVariant>().is_ok());
143        assert!("Scanner".parse::<NativeVariant>().is_err());
144        assert!("scanner--v2".parse::<NativeVariant>().is_err());
145        assert!("scanner_v2".parse::<NativeVariant>().is_err());
146    }
147
148    #[test]
149    fn selected_variants_get_distinct_output_paths() {
150        let scanner: NativeVariant = "scanner".parse().unwrap();
151        assert_eq!(
152            variant_output_path("target/fission/release/macos/pkg", Some(&scanner)),
153            PathBuf::from("target/fission/release/macos/pkg/variants/scanner")
154        );
155        assert_eq!(
156            variant_output_path("target/fission/release/macos/pkg", None),
157            PathBuf::from("target/fission/release/macos/pkg")
158        );
159    }
160}