Skip to main content

reinhardt_deeplink/config/
ios.rs

1//! iOS Universal Links configuration.
2//!
3//! This module provides types and builders for generating Apple App Site Association (AASA) files.
4
5use serde::Serialize;
6
7use crate::error::{DeeplinkError, validate_app_id};
8
9/// iOS Universal Links configuration.
10///
11/// This struct represents the complete Apple App Site Association (AASA) file format.
12/// When serialized to JSON, it produces the file that should be served at
13/// `/.well-known/apple-app-site-association`.
14///
15/// # Example
16///
17/// ```rust
18/// use reinhardt_deeplink::IosConfig;
19///
20/// let config = IosConfig::builder()
21///     .app_id("TEAM123456.com.example.app")
22///     .paths(&["/products/*", "/users/*"])
23///     .exclude_paths(&["/api/*"])
24///     .build();
25/// ```
26#[derive(Debug, Clone, Serialize)]
27pub struct IosConfig {
28	/// App links configuration for Universal Links.
29	pub applinks: AppLinksConfig,
30
31	/// Web credentials configuration for password autofill.
32	#[serde(skip_serializing_if = "Option::is_none")]
33	pub webcredentials: Option<WebCredentialsConfig>,
34
35	/// App Clips configuration.
36	#[serde(skip_serializing_if = "Option::is_none")]
37	pub appclips: Option<AppClipsConfig>,
38}
39
40/// App links section of the AASA file.
41#[derive(Debug, Clone, Serialize)]
42pub struct AppLinksConfig {
43	/// Legacy field, should always be an empty array.
44	pub apps: Vec<String>,
45
46	/// Details for each app that can handle links.
47	pub details: Vec<AppLinkDetail>,
48}
49
50/// Individual app link detail entry.
51#[derive(Debug, Clone, Serialize)]
52pub struct AppLinkDetail {
53	/// App IDs that can handle these paths.
54	#[serde(rename = "appIDs")]
55	pub app_ids: Vec<String>,
56
57	/// URL paths that should open the app.
58	#[serde(skip_serializing_if = "Vec::is_empty")]
59	pub paths: Vec<String>,
60
61	/// URL paths that should NOT open the app.
62	#[serde(skip_serializing_if = "Vec::is_empty")]
63	pub exclude: Vec<String>,
64
65	/// iOS 13+ component-based URL matching.
66	#[serde(skip_serializing_if = "Option::is_none")]
67	pub components: Option<Vec<AppLinkComponent>>,
68}
69
70/// iOS 13+ component-based URL matching.
71///
72/// Allows more granular control over URL matching including query parameters
73/// and URL fragments.
74#[derive(Debug, Clone, Serialize)]
75pub struct AppLinkComponent {
76	/// Path pattern to match.
77	#[serde(rename = "/")]
78	pub path: String,
79
80	/// Query string pattern to match.
81	#[serde(rename = "?", skip_serializing_if = "Option::is_none")]
82	pub query: Option<String>,
83
84	/// URL fragment pattern to match.
85	#[serde(rename = "#", skip_serializing_if = "Option::is_none")]
86	pub fragment: Option<String>,
87
88	/// Whether to exclude this pattern.
89	#[serde(skip_serializing_if = "Option::is_none")]
90	pub exclude: Option<bool>,
91
92	/// Optional comment for documentation.
93	#[serde(skip_serializing_if = "Option::is_none")]
94	pub comment: Option<String>,
95}
96
97/// Web credentials configuration for password autofill.
98#[derive(Debug, Clone, Serialize)]
99pub struct WebCredentialsConfig {
100	/// App IDs that can use web credentials from this domain.
101	pub apps: Vec<String>,
102}
103
104/// App Clips configuration.
105#[derive(Debug, Clone, Serialize)]
106pub struct AppClipsConfig {
107	/// App Clip bundle IDs.
108	pub apps: Vec<String>,
109}
110
111impl IosConfig {
112	/// Creates a new builder for iOS configuration.
113	pub fn builder() -> IosConfigBuilder {
114		IosConfigBuilder::new()
115	}
116}
117
118/// Builder for iOS Universal Links configuration.
119#[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	/// Creates a new builder.
132	pub fn new() -> Self {
133		Self::default()
134	}
135
136	/// Sets the primary app ID.
137	///
138	/// # Arguments
139	///
140	/// * `app_id` - The app ID in format `TEAM_ID.bundle_identifier`
141	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	/// Adds additional app IDs.
150	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	/// Sets the URL paths that should open the app.
159	///
160	/// Paths support wildcards:
161	/// - `*` matches any sequence of characters
162	/// - `?` matches any single character
163	///
164	/// # Example
165	///
166	/// ```rust
167	/// use reinhardt_deeplink::IosConfig;
168	///
169	/// let config = IosConfig::builder()
170	///     .app_id("TEAM.com.example")
171	///     .paths(&["/products/*", "/users/*"])
172	///     .build();
173	/// ```
174	pub fn paths(mut self, paths: &[&str]) -> Self {
175		self.paths.extend(paths.iter().map(|s| (*s).to_string()));
176		self
177	}
178
179	/// Adds a single path.
180	pub fn path(mut self, path: impl Into<String>) -> Self {
181		self.paths.push(path.into());
182		self
183	}
184
185	/// Sets paths that should NOT open the app.
186	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	/// Adds a single exclude path.
193	pub fn exclude_path(mut self, path: impl Into<String>) -> Self {
194		self.exclude_paths.push(path.into());
195		self
196	}
197
198	/// Adds an iOS 13+ component for fine-grained URL matching.
199	///
200	/// # Example
201	///
202	/// ```rust
203	/// use reinhardt_deeplink::{IosConfig, AppLinkComponent};
204	///
205	/// let config = IosConfig::builder()
206	///     .app_id("TEAM.com.example")
207	///     .component(AppLinkComponent {
208	///         path: "/products/*".to_string(),
209	///         query: Some("ref=*".to_string()),
210	///         fragment: None,
211	///         exclude: None,
212	///         comment: Some("Product pages with referral".to_string()),
213	///     })
214	///     .build();
215	/// ```
216	pub fn component(mut self, component: AppLinkComponent) -> Self {
217		self.components.push(component);
218		self
219	}
220
221	/// Adds a detail entry for a different app.
222	///
223	/// Use this when multiple apps should handle different URL patterns.
224	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	/// Enables web credentials for the configured app IDs.
235	///
236	/// This allows password autofill to work with your app.
237	pub fn with_web_credentials(mut self) -> Self {
238		self.web_credentials_apps.clone_from(&self.app_ids);
239		self
240	}
241
242	/// Adds an App Clip configuration.
243	///
244	/// App Clip paths are configured via App Store Connect, not in the AASA file.
245	/// This method only registers the App Clip bundle ID.
246	///
247	/// # Arguments
248	///
249	/// * `app_id` - The App Clip bundle ID (usually ends with `.Clip`)
250	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	/// Validates the configuration.
256	///
257	/// # Errors
258	///
259	/// Returns an error if:
260	/// - No app IDs are configured
261	/// - Any app ID has an invalid format
262	/// - No paths or components are specified
263	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	/// Checks if the builder has no paths, components, or additional details configured.
282	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	/// Builds the iOS configuration.
287	///
288	/// This method does not validate the configuration. Use [`validate`](Self::validate)
289	/// before building if validation is needed.
290	pub fn build(self) -> IosConfig {
291		let mut details = Vec::new();
292
293		// Build primary detail if we have paths or components
294		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		// Add additional details
308		details.extend(self.additional_details);
309
310		// Build webcredentials if configured
311		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		// Build appclips if configured
320		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(), // Always empty per Apple spec
331				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}