fission_command_core/
native_variant.rs1use 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}