reinhardt_deeplink/config/
ios.rs1use serde::Serialize;
6
7use crate::error::{DeeplinkError, validate_app_id};
8
9#[derive(Debug, Clone, Serialize)]
27pub struct IosConfig {
28 pub applinks: AppLinksConfig,
30
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub webcredentials: Option<WebCredentialsConfig>,
34
35 #[serde(skip_serializing_if = "Option::is_none")]
37 pub appclips: Option<AppClipsConfig>,
38}
39
40#[derive(Debug, Clone, Serialize)]
42pub struct AppLinksConfig {
43 pub apps: Vec<String>,
45
46 pub details: Vec<AppLinkDetail>,
48}
49
50#[derive(Debug, Clone, Serialize)]
52pub struct AppLinkDetail {
53 #[serde(rename = "appIDs")]
55 pub app_ids: Vec<String>,
56
57 #[serde(skip_serializing_if = "Vec::is_empty")]
59 pub paths: Vec<String>,
60
61 #[serde(skip_serializing_if = "Vec::is_empty")]
63 pub exclude: Vec<String>,
64
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub components: Option<Vec<AppLinkComponent>>,
68}
69
70#[derive(Debug, Clone, Serialize)]
75pub struct AppLinkComponent {
76 #[serde(rename = "/")]
78 pub path: String,
79
80 #[serde(rename = "?", skip_serializing_if = "Option::is_none")]
82 pub query: Option<String>,
83
84 #[serde(rename = "#", skip_serializing_if = "Option::is_none")]
86 pub fragment: Option<String>,
87
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub exclude: Option<bool>,
91
92 #[serde(skip_serializing_if = "Option::is_none")]
94 pub comment: Option<String>,
95}
96
97#[derive(Debug, Clone, Serialize)]
99pub struct WebCredentialsConfig {
100 pub apps: Vec<String>,
102}
103
104#[derive(Debug, Clone, Serialize)]
106pub struct AppClipsConfig {
107 pub apps: Vec<String>,
109}
110
111impl IosConfig {
112 pub fn builder() -> IosConfigBuilder {
114 IosConfigBuilder::new()
115 }
116}
117
118#[derive(Debug, Default)]
120pub struct IosConfigBuilder {
121 app_ids: Vec<String>,
122 paths: Vec<String>,
123 exclude_paths: Vec<String>,
124 components: Vec<AppLinkComponent>,
125 additional_details: Vec<AppLinkDetail>,
126 web_credentials_apps: Vec<String>,
127 app_clips: Vec<String>,
128}
129
130impl IosConfigBuilder {
131 pub fn new() -> Self {
133 Self::default()
134 }
135
136 pub fn app_id(mut self, app_id: impl Into<String>) -> Self {
142 let id = app_id.into();
143 if !self.app_ids.contains(&id) {
144 self.app_ids.push(id);
145 }
146 self
147 }
148
149 pub fn additional_app_id(mut self, app_id: impl Into<String>) -> Self {
151 let id = app_id.into();
152 if !self.app_ids.contains(&id) {
153 self.app_ids.push(id);
154 }
155 self
156 }
157
158 pub fn paths(mut self, paths: &[&str]) -> Self {
175 self.paths.extend(paths.iter().map(|s| (*s).to_string()));
176 self
177 }
178
179 pub fn path(mut self, path: impl Into<String>) -> Self {
181 self.paths.push(path.into());
182 self
183 }
184
185 pub fn exclude_paths(mut self, paths: &[&str]) -> Self {
187 self.exclude_paths
188 .extend(paths.iter().map(|s| (*s).to_string()));
189 self
190 }
191
192 pub fn exclude_path(mut self, path: impl Into<String>) -> Self {
194 self.exclude_paths.push(path.into());
195 self
196 }
197
198 pub fn component(mut self, component: AppLinkComponent) -> Self {
217 self.components.push(component);
218 self
219 }
220
221 pub fn additional_app(mut self, app_id: impl Into<String>, paths: &[&str]) -> Self {
225 self.additional_details.push(AppLinkDetail {
226 app_ids: vec![app_id.into()],
227 paths: paths.iter().map(|s| (*s).to_string()).collect(),
228 exclude: Vec::new(),
229 components: None,
230 });
231 self
232 }
233
234 pub fn with_web_credentials(mut self) -> Self {
238 self.web_credentials_apps.clone_from(&self.app_ids);
239 self
240 }
241
242 pub fn app_clip(mut self, app_id: impl Into<String>) -> Self {
251 self.app_clips.push(app_id.into());
252 self
253 }
254
255 pub fn validate(&self) -> Result<(), DeeplinkError> {
264 if self.app_ids.is_empty() {
265 return Err(DeeplinkError::InvalidAppId(
266 "no app IDs configured".to_string(),
267 ));
268 }
269
270 for app_id in &self.app_ids {
271 validate_app_id(app_id)?;
272 }
273
274 if self.has_no_paths_or_components() {
275 return Err(DeeplinkError::NoPathsSpecified);
276 }
277
278 Ok(())
279 }
280
281 fn has_no_paths_or_components(&self) -> bool {
283 self.paths.is_empty() && self.components.is_empty() && self.additional_details.is_empty()
284 }
285
286 pub fn build(self) -> IosConfig {
291 let mut details = Vec::new();
292
293 if !self.paths.is_empty() || !self.components.is_empty() {
295 details.push(AppLinkDetail {
296 app_ids: self.app_ids.clone(),
297 paths: self.paths,
298 exclude: self.exclude_paths,
299 components: if self.components.is_empty() {
300 None
301 } else {
302 Some(self.components)
303 },
304 });
305 }
306
307 details.extend(self.additional_details);
309
310 let webcredentials = if self.web_credentials_apps.is_empty() {
312 None
313 } else {
314 Some(WebCredentialsConfig {
315 apps: self.web_credentials_apps,
316 })
317 };
318
319 let appclips = if self.app_clips.is_empty() {
321 None
322 } else {
323 Some(AppClipsConfig {
324 apps: self.app_clips,
325 })
326 };
327
328 IosConfig {
329 applinks: AppLinksConfig {
330 apps: Vec::new(), details,
332 },
333 webcredentials,
334 appclips,
335 }
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use rstest::rstest;
342
343 use super::*;
344
345 #[rstest]
346 fn test_basic_ios_config() {
347 let config = IosConfig::builder()
348 .app_id("TEAM123456.com.example.app")
349 .paths(&["/products/*", "/users/*"])
350 .build();
351
352 let json = serde_json::to_string_pretty(&config).unwrap();
353 assert!(json.contains("applinks"));
354 assert!(json.contains("TEAM123456.com.example.app"));
355 assert!(json.contains("/products/*"));
356 }
357
358 #[rstest]
359 fn test_ios_config_with_exclude() {
360 let config = IosConfig::builder()
361 .app_id("TEAM.com.example")
362 .paths(&["/products/*"])
363 .exclude_paths(&["/api/*"])
364 .build();
365
366 let json = serde_json::to_string_pretty(&config).unwrap();
367 assert!(json.contains("/api/*"));
368 }
369
370 #[rstest]
371 fn test_ios_config_with_components() {
372 let config = IosConfig::builder()
373 .app_id("TEAM.com.example")
374 .component(AppLinkComponent {
375 path: "/products/*".to_string(),
376 query: Some("ref=*".to_string()),
377 fragment: None,
378 exclude: None,
379 comment: Some("Product pages".to_string()),
380 })
381 .build();
382
383 let json = serde_json::to_string_pretty(&config).unwrap();
384 assert!(json.contains("components"));
385 assert!(json.contains("ref=*"));
386 }
387
388 #[rstest]
389 fn test_ios_config_with_web_credentials() {
390 let config = IosConfig::builder()
391 .app_id("TEAM.com.example")
392 .paths(&["/"])
393 .with_web_credentials()
394 .build();
395
396 let json = serde_json::to_string_pretty(&config).unwrap();
397 assert!(json.contains("webcredentials"));
398 }
399
400 #[rstest]
401 fn test_ios_config_with_app_clips() {
402 let config = IosConfig::builder()
403 .app_id("TEAM.com.example")
404 .paths(&["/"])
405 .app_clip("TEAM.com.example.Clip")
406 .build();
407
408 let json = serde_json::to_string_pretty(&config).unwrap();
409 assert!(json.contains("appclips"));
410 }
411
412 #[rstest]
413 fn test_validation_no_app_ids() {
414 let builder = IosConfigBuilder::new().paths(&["/"]);
415 assert!(builder.validate().is_err());
416 }
417
418 #[rstest]
419 fn test_validation_no_paths() {
420 let builder = IosConfigBuilder::new().app_id("TEAM.com.example");
421 assert!(builder.validate().is_err());
422 }
423
424 #[rstest]
425 fn test_validation_success() {
426 let builder = IosConfigBuilder::new()
427 .app_id("TEAM.com.example")
428 .paths(&["/"]);
429 assert!(builder.validate().is_ok());
430 }
431}