Skip to main content

rustferry_core/
config.rs

1use std::fs;
2
3use camino::{Utf8Path, Utf8PathBuf};
4use schemars::{JsonSchema, schema_for};
5use semver::Version;
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9use crate::{CONFIG_SCHEMA_VERSION, validate_application_identifier};
10
11/// Complete, versioned `ferry.toml` document.
12#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
13#[serde(deny_unknown_fields)]
14pub struct FerryConfig {
15    /// Configuration schema used by this file.
16    pub schema_version: u32,
17    /// Platforms generated and validated for this project.
18    #[serde(default = "default_platforms")]
19    pub platforms: Vec<TargetPlatform>,
20    /// Application identity and presentation settings.
21    pub app: AppConfig,
22    /// Android build settings.
23    #[serde(default)]
24    pub android: AndroidConfig,
25    /// Apple platform build settings.
26    #[serde(default)]
27    pub ios: IosConfig,
28    /// Runtime capabilities compiled into the application.
29    #[serde(default)]
30    pub capabilities: CapabilitiesConfig,
31    /// Runtime permissions with required user-facing purpose text.
32    #[serde(default)]
33    pub permissions: PermissionsConfig,
34    /// Platform extension targets generated with the application.
35    #[serde(default)]
36    pub extensions: ExtensionsConfig,
37}
38
39impl FerryConfig {
40    /// Create a configuration with starter-friendly defaults.
41    pub fn starter(name: impl Into<String>, identifier: impl Into<String>) -> Self {
42        let identifier = identifier.into();
43        let default_scheme = identifier
44            .rsplit('.')
45            .next()
46            .unwrap_or("app")
47            .replace('_', "-");
48        let mut capabilities = CapabilitiesConfig::starter();
49        capabilities.deep_links.schemes.push(default_scheme);
50        Self {
51            schema_version: CONFIG_SCHEMA_VERSION,
52            platforms: default_platforms(),
53            app: AppConfig {
54                name: name.into(),
55                identifier,
56                version: Version::new(0, 1, 0),
57                display_version: "0.1.0".to_owned(),
58                window: AppWindowConfig::default(),
59            },
60            android: AndroidConfig::default(),
61            ios: IosConfig::default(),
62            capabilities,
63            permissions: PermissionsConfig::default(),
64            extensions: ExtensionsConfig::default(),
65        }
66    }
67
68    /// Parse a strict TOML document.
69    ///
70    /// # Errors
71    ///
72    /// Returns [`ConfigError::Parse`] when the document is malformed or contains unknown fields.
73    pub fn parse(source: &str) -> Result<Self, ConfigError> {
74        toml::from_str(source).map_err(ConfigError::Parse)
75    }
76
77    /// Read and validate a configuration file.
78    ///
79    /// # Errors
80    ///
81    /// Returns an I/O, parse, or semantic-validation error.
82    pub fn load(path: &Utf8Path) -> Result<Self, ConfigError> {
83        let source = fs::read_to_string(path).map_err(|source| ConfigError::Read {
84            path: path.to_owned(),
85            source,
86        })?;
87        let config = Self::parse(&source)?;
88        config.validate_or_error()?;
89        Ok(config)
90    }
91
92    /// Serialize a deterministic, human-readable TOML document.
93    ///
94    /// # Errors
95    ///
96    /// Returns [`ConfigError::Serialize`] if the configuration cannot be encoded.
97    pub fn to_pretty_toml(&self) -> Result<String, ConfigError> {
98        toml::to_string_pretty(self).map_err(ConfigError::Serialize)
99    }
100
101    /// Return the stable JSON Schema for editor integration.
102    ///
103    /// # Errors
104    ///
105    /// Returns [`ConfigError::Schema`] if schema serialization fails.
106    pub fn json_schema() -> Result<String, ConfigError> {
107        serde_json::to_string_pretty(&schema_for!(Self)).map_err(ConfigError::Schema)
108    }
109
110    /// Return every semantic validation issue rather than stopping at the first one.
111    #[allow(clippy::too_many_lines)]
112    pub fn validate(&self) -> Vec<ValidationIssue> {
113        let mut issues = Vec::new();
114        if self.schema_version != CONFIG_SCHEMA_VERSION {
115            issues.push(ValidationIssue::new(
116                "schema_version",
117                format!(
118                    "schema version {} is not supported; this cargo-ferry supports version {CONFIG_SCHEMA_VERSION}",
119                    self.schema_version
120                ),
121                "Run `cargo ferry config migrate` with a cargo-ferry version that supports the source schema.",
122            ));
123        }
124        if self.app.name.trim().is_empty() {
125            issues.push(ValidationIssue::new(
126                "app.name",
127                "the display name cannot be empty",
128                "Set a human-readable application name.",
129            ));
130        }
131        if self.platforms.is_empty() {
132            issues.push(ValidationIssue::new(
133                "platforms",
134                "at least one target platform is required",
135                "Use `platforms = [\"android\"]` or `platforms = [\"android\", \"ios\"]`.",
136            ));
137        }
138        let unique_platforms = self
139            .platforms
140            .iter()
141            .collect::<std::collections::HashSet<_>>();
142        if unique_platforms.len() != self.platforms.len() {
143            issues.push(ValidationIssue::new(
144                "platforms",
145                "the same target platform is listed more than once",
146                "Remove the duplicate platform entry.",
147            ));
148        }
149        if let Err(error) = validate_application_identifier(&self.app.identifier) {
150            issues.push(ValidationIssue::new(
151                "app.identifier",
152                error.to_string(),
153                "Use an identifier such as `com.example.weather`.",
154            ));
155        }
156        if self.app.display_version.trim().is_empty() {
157            issues.push(ValidationIssue::new(
158                "app.display_version",
159                "the display version cannot be empty",
160                "Use a value visible to users, commonly the same value as `app.version`.",
161            ));
162        }
163        if !(21..=100).contains(&self.android.min_sdk) {
164            issues.push(ValidationIssue::new(
165                "android.min_sdk",
166                "the supported range is 21 through 100",
167                "Use 26 unless the application needs an older Android release.",
168            ));
169        }
170        if self.android.abis.is_empty() {
171            issues.push(ValidationIssue::new(
172                "android.abis",
173                "at least one Android ABI is required",
174                "Add `arm64-v8a` for physical devices or `x86_64` for an Intel emulator.",
175            ));
176        }
177        if self
178            .android
179            .abis
180            .iter()
181            .collect::<std::collections::HashSet<_>>()
182            .len()
183            != self.android.abis.len()
184        {
185            issues.push(ValidationIssue::new(
186                "android.abis",
187                "the same Android ABI is listed more than once",
188                "Remove duplicate ABI entries.",
189            ));
190        }
191        if self.android.target_sdk != "installed" {
192            match self.android.target_sdk.parse::<u32>() {
193                Ok(target_sdk) if target_sdk < self.android.min_sdk => {
194                    issues.push(ValidationIssue::new(
195                        "android.target_sdk",
196                        "the target SDK cannot be lower than the minimum SDK",
197                        "Raise `android.target_sdk` or lower `android.min_sdk`.",
198                    ));
199                }
200                Ok(target_sdk) if target_sdk > 100 => {
201                    issues.push(ValidationIssue::new(
202                        "android.target_sdk",
203                        "the numeric target SDK is outside the supported range",
204                        "Use `installed` or a supported Android API number.",
205                    ));
206                }
207                Ok(_) => {}
208                Err(_) => issues.push(ValidationIssue::new(
209                    "android.target_sdk",
210                    "the target SDK must be `installed` or a numeric API level",
211                    "Use `target_sdk = \"installed\"` to select the newest compatible installed platform.",
212                )),
213            }
214        }
215        if parse_platform_version(&self.ios.min_version).is_none() {
216            issues.push(ValidationIssue::new(
217                "ios.min_version",
218                "the minimum iOS version must contain numeric major and minor components",
219                "Use a value such as `16.0`.",
220            ));
221        }
222        if self.capabilities.network.mode == NetworkMode::None
223            && self.capabilities.network.probe_url.is_some()
224        {
225            issues.push(ValidationIssue::new(
226                "capabilities.network.probe_url",
227                "a probe URL cannot be configured when network mode is `none`",
228                "Remove `probe_url` or choose the `status`, `optional`, or `required` mode.",
229            ));
230        }
231        if self.capabilities.network.probe_timeout_ms == 0 {
232            issues.push(ValidationIssue::new(
233                "capabilities.network.probe_timeout_ms",
234                "the probe timeout must be greater than zero",
235                "Use 3000 milliseconds as a practical starting point.",
236            ));
237        }
238        if self.capabilities.network.probe_timeout_ms > 120_000 {
239            issues.push(ValidationIssue::new(
240                "capabilities.network.probe_timeout_ms",
241                "the probe timeout cannot exceed 120000 milliseconds",
242                "Choose a bounded timeout appropriate for a user-facing operation.",
243            ));
244        }
245        if let Some(probe_url) = &self.capabilities.network.probe_url {
246            match url::Url::parse(probe_url) {
247                Ok(url) if matches!(url.scheme(), "http" | "https") => {}
248                Ok(_) => issues.push(ValidationIssue::new(
249                    "capabilities.network.probe_url",
250                    "only HTTP and HTTPS probe endpoints are supported",
251                    "Use an HTTPS health endpoint controlled by the application backend.",
252                )),
253                Err(error) => issues.push(ValidationIssue::new(
254                    "capabilities.network.probe_url",
255                    format!("the probe endpoint is not an absolute URL: {error}"),
256                    "Use an absolute HTTPS URL, or omit it and pass an endpoint explicitly.",
257                )),
258            }
259        }
260        if self.capabilities.notifications.push {
261            issues.push(ValidationIssue::new(
262                "capabilities.notifications.push",
263                "remote push is not implemented in schema version 1",
264                "Set `push = false`; local notifications remain available.",
265            ));
266        }
267        for scheme in &self.capabilities.deep_links.schemes {
268            if !valid_url_scheme(scheme) {
269                issues.push(ValidationIssue::new(
270                    "capabilities.deep_links.schemes",
271                    format!("`{scheme}` is not a safe custom URL scheme"),
272                    "Schemes must start with an ASCII letter and contain only letters, digits, `+`, `-`, or `.`.",
273                ));
274            }
275        }
276        if self.extensions.widget.enabled && self.extensions.widget.app_group.is_none() {
277            issues.push(ValidationIssue::new(
278                "extensions.widget.app_group",
279                "the widget extension needs an application group for shared state",
280                format!("Set `app_group = \"group.{}\"`.", self.app.identifier),
281            ));
282        }
283        if self.extensions.live_activity.enabled
284            && parse_platform_version(&self.ios.min_version)
285                .is_some_and(|version| version < (16, 1))
286        {
287            issues.push(ValidationIssue::new(
288                "ios.min_version",
289                "Live Activities require iOS 16.1 or newer",
290                "Set `ios.min_version = \"16.1\"` or disable the Live Activity extension.",
291            ));
292        }
293        validate_permission(
294            "permissions.camera",
295            &self.permissions.camera,
296            "Explain why the app needs camera access.",
297            &mut issues,
298        );
299        validate_permission(
300            "permissions.photos",
301            &self.permissions.photos,
302            "Explain why the app needs photo-library access.",
303            &mut issues,
304        );
305        validate_permission(
306            "permissions.microphone",
307            &self.permissions.microphone,
308            "Explain why the app needs microphone access.",
309            &mut issues,
310        );
311        validate_permission(
312            "permissions.location_when_in_use",
313            &self.permissions.location_when_in_use,
314            "Explain why the app needs location while in use.",
315            &mut issues,
316        );
317        validate_permission(
318            "permissions.local_network",
319            &self.permissions.local_network,
320            "Explain which local devices or services the app connects to.",
321            &mut issues,
322        );
323        issues
324    }
325
326    /// Fail when semantic validation reports any issue.
327    ///
328    /// # Errors
329    ///
330    /// Returns [`ConfigError::Validation`] containing every semantic issue.
331    pub fn validate_or_error(&self) -> Result<(), ConfigError> {
332        let issues = self.validate();
333        if issues.is_empty() {
334            Ok(())
335        } else {
336            Err(ConfigError::Validation { issues })
337        }
338    }
339}
340
341/// Permission declarations that generate platform manifests and purpose strings.
342#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
343#[serde(default, deny_unknown_fields)]
344pub struct PermissionsConfig {
345    /// Camera access.
346    pub camera: PermissionConfig,
347    /// Photo-library access.
348    pub photos: PermissionConfig,
349    /// Microphone access.
350    pub microphone: PermissionConfig,
351    /// Foreground location access.
352    pub location_when_in_use: PermissionConfig,
353    /// Apple local-network privacy permission.
354    pub local_network: PermissionConfig,
355}
356
357/// One optional permission and its operating-system prompt text.
358#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
359#[serde(default, deny_unknown_fields)]
360pub struct PermissionConfig {
361    /// Whether the platform permission is generated.
362    pub enabled: bool,
363    /// Non-empty, application-specific reason shown before or inside the system prompt.
364    pub purpose: Option<String>,
365}
366
367/// Mobile platform generated for an application.
368#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, Eq, Hash, PartialEq, Serialize)]
369#[serde(rename_all = "kebab-case")]
370pub enum TargetPlatform {
371    /// Android APK.
372    Android,
373    /// Apple iOS application.
374    Ios,
375}
376
377fn default_platforms() -> Vec<TargetPlatform> {
378    vec![TargetPlatform::Android, TargetPlatform::Ios]
379}
380
381/// Application identity and window defaults.
382#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
383#[serde(deny_unknown_fields)]
384pub struct AppConfig {
385    /// Display name shown by launchers.
386    pub name: String,
387    /// Android application ID and Apple bundle identifier.
388    pub identifier: String,
389    /// `SemVer` package version.
390    pub version: Version,
391    /// Platform-facing user-visible version.
392    pub display_version: String,
393    /// Initial window preferences.
394    #[serde(default)]
395    pub window: AppWindowConfig,
396}
397
398/// Initial application window preferences.
399#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
400#[serde(deny_unknown_fields)]
401pub struct AppWindowConfig {
402    /// Supported device orientation.
403    #[serde(default)]
404    pub orientation: Orientation,
405    /// Initial color theme.
406    #[serde(default)]
407    pub theme: Theme,
408}
409
410/// Supported screen orientation policy.
411#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
412#[serde(rename_all = "kebab-case")]
413pub enum Orientation {
414    /// Follow the current device orientation.
415    #[default]
416    Automatic,
417    /// Lock the application to portrait.
418    Portrait,
419    /// Lock the application to landscape.
420    Landscape,
421}
422
423/// Application color theme preference.
424#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
425#[serde(rename_all = "kebab-case")]
426pub enum Theme {
427    /// Follow the system setting.
428    #[default]
429    System,
430    /// Prefer a light palette.
431    Light,
432    /// Prefer a dark palette.
433    Dark,
434}
435
436/// Android build settings.
437#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
438#[serde(default, deny_unknown_fields)]
439pub struct AndroidConfig {
440    /// Minimum Android API supported by the APK.
441    pub min_sdk: u32,
442    /// Target API (`installed` selects the newest installed platform).
443    pub target_sdk: String,
444    /// Native architectures included in the artifact.
445    pub abis: Vec<AndroidAbi>,
446}
447
448impl Default for AndroidConfig {
449    fn default() -> Self {
450        Self {
451            min_sdk: 26,
452            target_sdk: "installed".to_owned(),
453            abis: vec![AndroidAbi::Arm64V8a],
454        }
455    }
456}
457
458/// Supported Android ABI.
459#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, Eq, Hash, PartialEq, Serialize)]
460pub enum AndroidAbi {
461    /// 64-bit ARM devices.
462    #[serde(rename = "arm64-v8a")]
463    Arm64V8a,
464    /// 64-bit Intel emulators.
465    #[serde(rename = "x86_64")]
466    X86_64,
467    /// 32-bit ARM devices.
468    #[serde(rename = "armeabi-v7a")]
469    ArmeabiV7a,
470}
471
472impl AndroidAbi {
473    /// Corresponding Rust compilation target.
474    pub const fn rust_target(self) -> &'static str {
475        match self {
476            Self::Arm64V8a => "aarch64-linux-android",
477            Self::X86_64 => "x86_64-linux-android",
478            Self::ArmeabiV7a => "armv7-linux-androideabi",
479        }
480    }
481
482    /// Directory name used inside an APK.
483    pub const fn apk_directory(self) -> &'static str {
484        match self {
485            Self::Arm64V8a => "arm64-v8a",
486            Self::X86_64 => "x86_64",
487            Self::ArmeabiV7a => "armeabi-v7a",
488        }
489    }
490}
491
492/// Apple platform build settings.
493#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
494#[serde(default, deny_unknown_fields)]
495pub struct IosConfig {
496    /// Minimum iOS version supported by the application.
497    pub min_version: String,
498}
499
500impl Default for IosConfig {
501    fn default() -> Self {
502        Self {
503            min_version: "16.0".to_owned(),
504        }
505    }
506}
507
508/// Runtime capability configuration.
509#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
510#[serde(default, deny_unknown_fields)]
511pub struct CapabilitiesConfig {
512    /// Network status and optional internet probing.
513    pub network: NetworkCapability,
514    /// Local and future remote notifications.
515    pub notifications: NotificationCapability,
516    /// JSON-backed local storage.
517    pub storage: BoolCapability,
518    /// Platform haptic feedback.
519    pub haptics: BoolCapability,
520    /// Clipboard access.
521    pub clipboard: BoolCapability,
522    /// System share sheets.
523    pub share: BoolCapability,
524    /// Custom URL schemes and allowlists.
525    pub deep_links: DeepLinksCapability,
526}
527
528impl CapabilitiesConfig {
529    fn starter() -> Self {
530        Self {
531            network: NetworkCapability {
532                mode: NetworkMode::Status,
533                ..NetworkCapability::default()
534            },
535            notifications: NotificationCapability {
536                local: true,
537                push: false,
538            },
539            storage: BoolCapability { enabled: true },
540            haptics: BoolCapability { enabled: true },
541            clipboard: BoolCapability::default(),
542            share: BoolCapability::default(),
543            deep_links: DeepLinksCapability::default(),
544        }
545    }
546}
547
548/// Capability represented by a single enable flag.
549#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
550#[serde(default, deny_unknown_fields)]
551pub struct BoolCapability {
552    /// Whether the capability is compiled in.
553    pub enabled: bool,
554}
555
556/// Network status and probe configuration.
557#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
558#[serde(default, deny_unknown_fields)]
559pub struct NetworkCapability {
560    /// Runtime network policy.
561    pub mode: NetworkMode,
562    /// Optional backend health endpoint, separate from OS path state.
563    pub probe_url: Option<String>,
564    /// Probe timeout in milliseconds.
565    pub probe_timeout_ms: u64,
566}
567
568impl Default for NetworkCapability {
569    fn default() -> Self {
570        Self {
571            mode: NetworkMode::None,
572            probe_url: None,
573            probe_timeout_ms: 3_000,
574        }
575    }
576}
577
578/// Application network policy.
579#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Eq, PartialEq, Serialize)]
580#[serde(rename_all = "kebab-case")]
581pub enum NetworkMode {
582    /// Omit network permissions and monitoring where possible.
583    #[default]
584    None,
585    /// Observe system network status.
586    Status,
587    /// Permit offline startup and online operations.
588    Optional,
589    /// Require callers to pass an explicit offline gate.
590    Required,
591}
592
593/// Notification capability configuration.
594#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
595#[serde(default, deny_unknown_fields)]
596pub struct NotificationCapability {
597    /// Enable local notifications.
598    pub local: bool,
599    /// Remote push placeholder; schema version 1 rejects `true`.
600    pub push: bool,
601}
602
603/// Deep-link configuration and input allowlists.
604#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
605#[serde(default, deny_unknown_fields)]
606pub struct DeepLinksCapability {
607    /// Custom URL schemes accepted by the application.
608    pub schemes: Vec<String>,
609    /// Hosts accepted by typed parsing helpers.
610    pub allowed_hosts: Vec<String>,
611    /// First path segments accepted as actions.
612    pub allowed_actions: Vec<String>,
613}
614
615/// Platform extension targets.
616#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
617#[serde(default, deny_unknown_fields)]
618pub struct ExtensionsConfig {
619    /// Home-screen widget configuration.
620    pub widget: WidgetConfig,
621    /// iOS Live Activity and Android fallback configuration.
622    pub live_activity: LiveActivityConfig,
623}
624
625/// Widget extension configuration.
626#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
627#[serde(default, deny_unknown_fields)]
628pub struct WidgetConfig {
629    /// Generate widget platform components.
630    pub enabled: bool,
631    /// Shared application-group identifier.
632    pub app_group: Option<String>,
633}
634
635/// Live Activity extension configuration.
636#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
637#[serde(default, deny_unknown_fields)]
638pub struct LiveActivityConfig {
639    /// Generate an `ActivityKit` extension.
640    pub enabled: bool,
641    /// Honest Android equivalent for the feature.
642    pub android_fallback: AndroidLiveActivityFallback,
643}
644
645/// Android presentation used when iOS Live Activities are enabled.
646#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Eq, PartialEq, Serialize)]
647#[serde(rename_all = "kebab-case")]
648pub enum AndroidLiveActivityFallback {
649    /// Do not expose Live Activity operations on Android.
650    None,
651    /// Render the constrained state as an ordinary ongoing notification.
652    #[default]
653    OngoingNotification,
654}
655
656impl Default for LiveActivityConfig {
657    fn default() -> Self {
658        Self {
659            enabled: false,
660            android_fallback: AndroidLiveActivityFallback::OngoingNotification,
661        }
662    }
663}
664
665/// One actionable configuration validation problem.
666#[derive(Clone, Debug, Deserialize, JsonSchema, Eq, PartialEq, Serialize)]
667pub struct ValidationIssue {
668    /// Dotted configuration key.
669    pub field: String,
670    /// What is invalid.
671    pub message: String,
672    /// Concrete remediation.
673    pub help: String,
674}
675
676impl ValidationIssue {
677    fn new(field: impl Into<String>, message: impl Into<String>, help: impl Into<String>) -> Self {
678        Self {
679            field: field.into(),
680            message: message.into(),
681            help: help.into(),
682        }
683    }
684}
685
686/// Configuration loading, parsing, validation, or serialization failure.
687#[derive(Debug, Error)]
688pub enum ConfigError {
689    /// The file could not be read.
690    #[error("could not read configuration at {path}: {source}")]
691    Read {
692        /// Requested path.
693        path: Utf8PathBuf,
694        /// Filesystem error.
695        #[source]
696        source: std::io::Error,
697    },
698    /// TOML syntax or a strict-schema error.
699    #[error("invalid ferry.toml: {0}")]
700    Parse(#[source] toml::de::Error),
701    /// One or more semantic constraints failed.
702    #[error("ferry.toml has invalid values")]
703    Validation {
704        /// Every detected problem.
705        issues: Vec<ValidationIssue>,
706    },
707    /// TOML serialization failed.
708    #[error("could not serialize ferry.toml: {0}")]
709    Serialize(#[source] toml::ser::Error),
710    /// JSON Schema serialization failed.
711    #[error("could not serialize ferry.toml JSON Schema: {0}")]
712    Schema(#[source] serde_json::Error),
713}
714
715fn parse_platform_version(value: &str) -> Option<(u32, u32)> {
716    let mut parts = value.split('.');
717    let major = parts.next()?.parse().ok()?;
718    let minor = parts.next()?.parse().ok()?;
719    if parts.next().is_some() {
720        return None;
721    }
722    Some((major, minor))
723}
724
725fn valid_url_scheme(scheme: &str) -> bool {
726    let mut characters = scheme.chars();
727    characters
728        .next()
729        .is_some_and(|first| first.is_ascii_alphabetic())
730        && characters.all(|character| {
731            character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.')
732        })
733}
734
735fn validate_permission(
736    field: &'static str,
737    permission: &PermissionConfig,
738    help: &'static str,
739    issues: &mut Vec<ValidationIssue>,
740) {
741    if permission.enabled
742        && permission
743            .purpose
744            .as_deref()
745            .is_none_or(|purpose| purpose.trim().is_empty())
746    {
747        issues.push(ValidationIssue::new(
748            format!("{field}.purpose"),
749            "an enabled permission requires a non-empty purpose string",
750            help,
751        ));
752    }
753}
754
755#[cfg(test)]
756mod tests {
757    use super::*;
758
759    #[test]
760    fn starter_configuration_round_trips() {
761        let config = FerryConfig::starter("Weather", "com.example.weather");
762        let encoded = config.to_pretty_toml().unwrap();
763        let decoded = FerryConfig::parse(&encoded).unwrap();
764        assert_eq!(decoded, config);
765        assert!(decoded.validate().is_empty());
766    }
767
768    #[test]
769    fn unknown_fields_are_rejected() {
770        let source = FerryConfig::starter("Weather", "com.example.weather")
771            .to_pretty_toml()
772            .unwrap()
773            + "\nunknown = true\n";
774        assert!(FerryConfig::parse(&source).is_err());
775    }
776
777    #[test]
778    fn reports_capability_conflicts_together() {
779        let mut config = FerryConfig::starter("Weather", "com.example.weather");
780        config.capabilities.network.mode = NetworkMode::None;
781        config.capabilities.network.probe_url = Some("https://example.invalid/health".to_owned());
782        config.capabilities.notifications.push = true;
783        assert_eq!(config.validate().len(), 2);
784    }
785
786    #[test]
787    fn schema_is_valid_json() {
788        let schema = FerryConfig::json_schema().unwrap();
789        let value: serde_json::Value = serde_json::from_str(&schema).unwrap();
790        assert_eq!(value["title"], "FerryConfig");
791    }
792
793    #[test]
794    fn enabled_permissions_require_purpose_text() {
795        let mut config = FerryConfig::starter("Weather", "com.example.weather");
796        config.permissions.camera.enabled = true;
797        assert!(
798            config
799                .validate()
800                .iter()
801                .any(|issue| issue.field == "permissions.camera.purpose")
802        );
803        config.permissions.camera.purpose = Some("Scan a profile photo".to_owned());
804        assert!(config.validate().is_empty());
805    }
806}