Skip to main content

tauri_plugin_background_service/
validator.rs

1//! Setup validation for background service prerequisites.
2//!
3//! [`SetupValidator`] checks platform-specific prerequisites (permissions,
4//! manifest entries, service manager availability) and returns a
5//! [`SetupValidationReport`] with errors (blocking) and warnings (non-blocking).
6//!
7//! This module is available on all platforms. Platform-specific checks are
8//! gated by `cfg` attributes so they only run on the target platform.
9
10use crate::models::{Platform, SetupIssue, SetupValidationReport, Severity};
11
12#[cfg(test)]
13use crate::models::ValidationIssue;
14
15/// Validates background service setup prerequisites for the current platform.
16///
17/// Returns a [`SetupValidationReport`] containing errors (blocking issues that
18/// prevent the service from working) and warnings (non-blocking issues that
19/// may cause degraded behavior).
20pub struct SetupValidator;
21
22impl SetupValidator {
23    /// Run all applicable checks for the current platform.
24    ///
25    /// The `platform` parameter is typically obtained from
26    /// [`crate::capabilities::CapabilityProvider::detect_platform`].
27    pub fn validate(platform: Platform) -> SetupValidationReport {
28        match platform {
29            Platform::Android => Self::android_checks(),
30            Platform::Ios => Self::ios_checks(),
31            Platform::Linux | Platform::Macos | Platform::Windows | Platform::Unknown => {
32                Self::desktop_checks(platform)
33            }
34        }
35    }
36
37    fn android_checks() -> SetupValidationReport {
38        let warnings = vec![
39            SetupIssue {
40                code: "android_fgs_type".into(),
41                message: "Ensure the foreground service type is declared in AndroidManifest.xml \
42                          with the matching permission"
43                    .into(),
44                platform: Platform::Android,
45                fix: Some(
46                    "Add <foregroundServiceType> to your <service> element and the \
47                     corresponding <uses-permission> to the manifest"
48                        .into(),
49                ),
50            },
51            SetupIssue {
52                code: "android_post_notifications".into(),
53                message: "Android 13+ requires POST_NOTIFICATIONS runtime permission for \
54                          foreground service notifications"
55                    .into(),
56                platform: Platform::Android,
57                fix: Some(
58                    "Request android.permission.POST_NOTIFICATIONS at runtime before \
59                     starting the service on Android 13+"
60                        .into(),
61                ),
62            },
63            SetupIssue {
64                code: "android_boot_receiver".into(),
65                message: "Boot recovery requires a registered BroadcastReceiver for \
66                          BOOT_COMPLETED"
67                    .into(),
68                platform: Platform::Android,
69                fix: Some(
70                    "Add RECEIVE_BOOT_COMPLETED permission and a <receiver> element for \
71                     BOOT_COMPLETED in AndroidManifest.xml"
72                        .into(),
73                ),
74            },
75            SetupIssue {
76                code: "android_special_use_subtype".into(),
77                message: "When using specialUse FGS type, PROPERTY_SPECIAL_USE_FGS_SUBTYPE \
78                          must be declared in the manifest"
79                    .into(),
80                platform: Platform::Android,
81                fix: Some(
82                    "Add <property android:name=\"android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE\" \
83                     android:value=\"your_reason\" /> to the <service> element"
84                        .into(),
85                ),
86            },
87            SetupIssue {
88                code: "android_api35_boot_blocked_type".into(),
89                message:
90                    "Android 15 (API 35) blocks certain FGS types from starting in \
91                          BOOT_COMPLETED receivers: dataSync, camera, mediaPlayback, phoneCall, \
92                          mediaProjection, microphone. Boot recovery will not work with these types"
93                        .into(),
94                platform: Platform::Android,
95                fix: Some(
96                    "Use a non-blocked FGS type (e.g. connectedDevice, health, location, \
97                     mediaProcessing) for boot recovery, or handle re-launch via user interaction"
98                        .into(),
99                ),
100            },
101        ];
102
103        let issues: Vec<_> = warnings
104            .iter()
105            .map(|w| w.to_validation_issue(Severity::Warning))
106            .collect();
107
108        SetupValidationReport {
109            ok: true,
110            errors: vec![],
111            warnings,
112            issues,
113        }
114    }
115
116    fn ios_checks() -> SetupValidationReport {
117        Self::ios_checks_for_plist(read_runtime_ios_plist().as_deref())
118    }
119
120    /// Host-testable core of [`Self::ios_checks`].
121    ///
122    /// When `plist` is `None` (no runtime bundle plist available — e.g. the
123    /// macOS host gate) only advisory warnings are returned. When a plist is
124    /// supplied, the **required** keys are probed and any missing background
125    /// modes / scheduler identifiers / bundle-id mismatch becomes a hard
126    /// `Severity::Error` so misconfiguration cannot be reported as `ok: true`.
127    fn ios_checks_for_plist(plist: Option<&str>) -> SetupValidationReport {
128        let warnings = vec![
129            SetupIssue {
130                code: "ios_ui_background_modes".into(),
131                message: "UIBackgroundModes must include 'fetch' and \
132                          'processing' in Info.plist"
133                    .into(),
134                platform: Platform::Ios,
135                fix: Some(
136                    "Add UIBackgroundModes array with 'fetch' and \
137                     'processing' to Info.plist"
138                        .into(),
139                ),
140            },
141            SetupIssue {
142                code: "ios_bg_task_identifiers".into(),
143                message: "BGTaskSchedulerPermittedIdentifiers must list your task \
144                          identifiers in Info.plist"
145                    .into(),
146                platform: Platform::Ios,
147                fix: Some(
148                    "Add BGTaskSchedulerPermittedIdentifiers array with \
149                     '$(BUNDLE_ID).bg-refresh' and '$(BUNDLE_ID).bg-processing' to Info.plist"
150                        .into(),
151                ),
152            },
153            SetupIssue {
154                code: "ios_background_refresh".into(),
155                message: "Background App Refresh must be enabled in iOS Settings for \
156                          BGTaskScheduler to work"
157                    .into(),
158                platform: Platform::Ios,
159                fix: Some(
160                    "Instruct users to enable Background App Refresh in Settings > General > \
161                     Background App Refresh"
162                        .into(),
163                ),
164            },
165        ];
166
167        let mut errors: Vec<SetupIssue> = vec![];
168
169        if let Some(plist) = plist {
170            // Scope the mode checks to the UIBackgroundModes array so a value
171            // appearing elsewhere in the plist (e.g. the always-present
172            // `app.example.bg-processing` scheduler identifier, which contains
173            // the substring "processing") cannot mask a background mode that is
174            // actually absent.
175            let bg_modes_array = plist_array_after_key(plist, "UIBackgroundModes");
176            let has_fetch = bg_modes_array.is_some_and(|a| a.contains("fetch"));
177            let has_processing = bg_modes_array.is_some_and(|a| a.contains("processing"));
178            let has_bg_task = plist.contains("BGTaskSchedulerPermittedIdentifiers");
179
180            if !(has_fetch && has_processing) {
181                errors.push(SetupIssue {
182                    code: "ios_ui_background_modes_missing".into(),
183                    message: "Built Info.plist is missing UIBackgroundModes 'fetch' and/or \
184                              'processing' — BGTaskScheduler will not run"
185                        .into(),
186                    platform: Platform::Ios,
187                    fix: Some(
188                        "Add 'fetch' and 'processing' to the UIBackgroundModes array in \
189                         Info.ios.plist and rebuild"
190                            .into(),
191                    ),
192                });
193            }
194
195            if !has_bg_task {
196                errors.push(SetupIssue {
197                    code: "ios_bg_task_identifiers_missing".into(),
198                    message: "Built Info.plist is missing BGTaskSchedulerPermittedIdentifiers \
199                              — background tasks cannot be scheduled"
200                        .into(),
201                    platform: Platform::Ios,
202                    fix: Some(
203                        "Add BGTaskSchedulerPermittedIdentifiers with your bg-refresh and \
204                         bg-processing identifiers to Info.ios.plist and rebuild"
205                            .into(),
206                    ),
207                });
208            } else if let Some(bundle_id) = extract_bundle_identifier(plist) {
209                // The permitted identifiers are namespaced under the bundle id
210                // (e.g. `<bundle-id>.bg-refresh`); if none carry that prefix the
211                // identifiers belong to a different app and registration fails.
212                // (`bundle_id` alone always appears in CFBundleIdentifier, so the
213                // trailing dot is what distinguishes a namespaced identifier.)
214                if !plist.contains(&format!("{bundle_id}.")) {
215                    errors.push(SetupIssue {
216                        code: "ios_bundle_id_mismatch".into(),
217                        message: "BGTaskSchedulerPermittedIdentifiers do not match the bundle \
218                                  identifier — task registration will fail"
219                            .into(),
220                        platform: Platform::Ios,
221                        fix: Some(
222                            "Namespace the scheduler identifiers under the app bundle id \
223                             (e.g. '<bundle-id>.bg-refresh')"
224                                .into(),
225                        ),
226                    });
227                }
228            }
229        }
230
231        let issues: Vec<_> = errors
232            .iter()
233            .map(|e| e.to_validation_issue(Severity::Error))
234            .chain(
235                warnings
236                    .iter()
237                    .map(|w| w.to_validation_issue(Severity::Warning)),
238            )
239            .collect();
240
241        SetupValidationReport {
242            ok: errors.is_empty(),
243            errors,
244            warnings,
245            issues,
246        }
247    }
248
249    #[allow(unused_mut)]
250    fn desktop_checks(platform: Platform) -> SetupValidationReport {
251        let mut errors: Vec<SetupIssue> = vec![];
252        let mut warnings: Vec<SetupIssue> = vec![];
253
254        #[cfg(feature = "desktop-service")]
255        {
256            // Host probes for systemd (binaries, linger state, `libc::getuid`)
257            // only compile and only make sense on a Linux host; `platform` is
258            // only ever `Linux` there.
259            #[cfg(target_os = "linux")]
260            if matches!(platform, Platform::Linux) {
261                let systemctl = std::path::Path::new("/usr/bin/systemctl").exists()
262                    || std::path::Path::new("/bin/systemctl").exists()
263                    || which_exists("systemctl");
264
265                if !systemctl {
266                    errors.push(SetupIssue {
267                        code: "desktop_systemd_missing".into(),
268                        message: "systemctl not found — OS service mode requires systemd".into(),
269                        platform: Platform::Linux,
270                        fix: Some("Install systemd or use inProcess mode".into()),
271                    });
272                } else {
273                    let uid = unsafe { libc::getuid() };
274                    let linger_path = format!("/var/lib/systemd/linger/{uid}");
275                    let linger_ok = std::path::Path::new(&linger_path).exists()
276                        || std::env::var("USER")
277                            .ok()
278                            .map(|u| {
279                                std::path::Path::new(&format!("/var/lib/systemd/linger/{u}"))
280                                    .exists()
281                            })
282                            .unwrap_or(false);
283
284                    if !linger_ok {
285                        warnings.push(SetupIssue {
286                            code: "desktop_systemd_no_linger".into(),
287                            message: "systemd lingering is not enabled — user services \
288                                      will stop when you log out"
289                                .into(),
290                            platform: Platform::Linux,
291                            fix: Some(
292                                "Run 'loginctl enable-linger' to keep user services alive \
293                                 after logout"
294                                    .into(),
295                            ),
296                        });
297                    }
298                }
299            }
300
301            if matches!(platform, Platform::Macos) {
302                warnings.push(SetupIssue {
303                    code: "desktop_macos_sandbox".into(),
304                    message: "OS service mode is incompatible with macOS App Sandbox. \
305                              Ensure your app is not sandboxed or use inProcess mode"
306                        .into(),
307                    platform: Platform::Macos,
308                    fix: Some(
309                        "Disable App Sandbox in your app's entitlements, or use \
310                         desktopServiceMode: 'inProcess'"
311                            .into(),
312                    ),
313                });
314            }
315        }
316
317        #[cfg(not(feature = "desktop-service"))]
318        {
319            let _ = platform;
320        }
321
322        let issues: Vec<_> = errors
323            .iter()
324            .map(|e| e.to_validation_issue(Severity::Error))
325            .chain(
326                warnings
327                    .iter()
328                    .map(|w| w.to_validation_issue(Severity::Warning)),
329            )
330            .collect();
331
332        SetupValidationReport {
333            ok: errors.is_empty(),
334            errors,
335            warnings,
336            issues,
337        }
338    }
339}
340
341/// Read the built app's `Info.plist` at runtime.
342///
343/// An iOS `.app` bundle is flat: the executable and `Info.plist` live side by
344/// side. Resolving relative to the current executable reflects the **actually
345/// shipped** plist rather than a compile-time snapshot. Off iOS there is no
346/// bundle plist to probe, so the setup report falls back to warnings only.
347#[cfg(target_os = "ios")]
348fn read_runtime_ios_plist() -> Option<String> {
349    let exe = std::env::current_exe().ok()?;
350    let plist_path = exe.parent()?.join("Info.plist");
351    std::fs::read_to_string(plist_path).ok()
352}
353
354#[cfg(not(target_os = "ios"))]
355fn read_runtime_ios_plist() -> Option<String> {
356    None
357}
358
359/// Return the contents of the `<array>…</array>` that immediately follows the
360/// given `<key>` in a plist, if present.
361///
362/// Used to scope substring checks to one specific array (e.g.
363/// `UIBackgroundModes`) so values appearing elsewhere in the plist cannot be
364/// mistaken for array members. The search is bounded to before the next `<key>`
365/// so a key whose value is not an array does not accidentally match a later
366/// array.
367fn plist_array_after_key<'a>(plist_xml: &'a str, key: &str) -> Option<&'a str> {
368    let key_tag = format!("<key>{key}</key>");
369    let key_pos = plist_xml.find(&key_tag)?;
370    let after = &plist_xml[key_pos + key_tag.len()..];
371    let value_region = match after.find("<key>") {
372        Some(next_key) => &after[..next_key],
373        None => after,
374    };
375    let arr_start = value_region.find("<array>")? + "<array>".len();
376    let arr_end = value_region[arr_start..].find("</array>")?;
377    Some(&value_region[arr_start..arr_start + arr_end])
378}
379
380/// Extract the `CFBundleIdentifier` value from a plist XML string.
381///
382/// Returns `None` when the value is absent, empty, or still an unexpanded build
383/// variable (e.g. `$(PRODUCT_BUNDLE_IDENTIFIER)` in the pre-build template).
384fn extract_bundle_identifier(plist_xml: &str) -> Option<String> {
385    let key_pos = plist_xml.find("CFBundleIdentifier")?;
386    let after = &plist_xml[key_pos..];
387    let start = after.find("<string>")? + "<string>".len();
388    let end = after[start..].find("</string>")?;
389    let value = after[start..start + end].trim();
390    if value.is_empty() || value.starts_with("$(") {
391        None
392    } else {
393        Some(value.to_string())
394    }
395}
396
397/// Check if a command exists in PATH (via the Unix `which` utility).
398#[cfg(all(feature = "desktop-service", target_os = "linux"))]
399fn which_exists(cmd: &str) -> bool {
400    std::process::Command::new("which")
401        .arg(cmd)
402        .stdout(std::process::Stdio::null())
403        .stderr(std::process::Stdio::null())
404        .status()
405        .map(|s| s.success())
406        .unwrap_or(false)
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    #[test]
414    fn android_returns_no_errors() {
415        let report = SetupValidator::validate(Platform::Android);
416        assert!(
417            report.errors.is_empty(),
418            "Android should have no hard errors (checks happen at build/Kotlin level)"
419        );
420        assert!(!report.warnings.is_empty(), "Android should have warnings");
421        assert!(report.ok, "ok should be true when errors is empty");
422    }
423
424    #[test]
425    fn android_has_fgs_type_warning() {
426        let report = SetupValidator::validate(Platform::Android);
427        let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
428        assert!(
429            codes.contains(&"android_fgs_type"),
430            "Should warn about FGS type: {codes:?}"
431        );
432    }
433
434    #[test]
435    fn android_has_post_notifications_warning() {
436        let report = SetupValidator::validate(Platform::Android);
437        let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
438        assert!(
439            codes.contains(&"android_post_notifications"),
440            "Should warn about POST_NOTIFICATIONS: {codes:?}"
441        );
442    }
443
444    #[test]
445    fn android_has_boot_receiver_warning() {
446        let report = SetupValidator::validate(Platform::Android);
447        let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
448        assert!(
449            codes.contains(&"android_boot_receiver"),
450            "Should warn about boot receiver: {codes:?}"
451        );
452    }
453
454    #[test]
455    fn android_has_special_use_subtype_warning() {
456        let report = SetupValidator::validate(Platform::Android);
457        let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
458        assert!(
459            codes.contains(&"android_special_use_subtype"),
460            "Should warn about specialUse subtype: {codes:?}"
461        );
462    }
463
464    #[test]
465    fn android_has_api35_boot_blocked_type_warning() {
466        let report = SetupValidator::validate(Platform::Android);
467        let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
468        assert!(
469            codes.contains(&"android_api35_boot_blocked_type"),
470            "Should warn about API 35+ boot-blocked FGS types: {codes:?}"
471        );
472    }
473
474    #[test]
475    fn android_api35_boot_blocked_warning_lists_types() {
476        let report = SetupValidator::validate(Platform::Android);
477        let warning = report
478            .warnings
479            .iter()
480            .find(|w| w.code == "android_api35_boot_blocked_type")
481            .expect("Should have android_api35_boot_blocked_type warning");
482        for ty in &[
483            "dataSync",
484            "camera",
485            "mediaPlayback",
486            "phoneCall",
487            "mediaProjection",
488            "microphone",
489        ] {
490            assert!(
491                warning.message.contains(ty),
492                "Warning message should mention '{}': {}",
493                ty,
494                warning.message
495            );
496        }
497        assert!(warning.fix.is_some(), "Should have a fix suggestion");
498    }
499
500    #[test]
501    fn android_all_warnings_have_fix() {
502        let report = SetupValidator::validate(Platform::Android);
503        for w in &report.warnings {
504            assert!(
505                w.fix.is_some(),
506                "Warning '{}' should have a fix suggestion",
507                w.code
508            );
509        }
510    }
511
512    #[test]
513    fn android_all_warnings_are_android_platform() {
514        let report = SetupValidator::validate(Platform::Android);
515        for w in &report.warnings {
516            assert_eq!(
517                w.platform,
518                Platform::Android,
519                "Warning '{}' should be Android platform",
520                w.code
521            );
522        }
523    }
524
525    const VALID_IOS_PLIST: &str = r#"<plist><dict>
526    <key>CFBundleIdentifier</key>
527    <string>app.example</string>
528    <key>UIBackgroundModes</key>
529    <array><string>fetch</string><string>processing</string></array>
530    <key>BGTaskSchedulerPermittedIdentifiers</key>
531    <array><string>app.example.bg-refresh</string><string>app.example.bg-processing</string></array>
532</dict></plist>"#;
533
534    #[test]
535    fn ios_returns_no_errors() {
536        // On the macOS host gate there is no runtime bundle plist to probe, so
537        // `ios_checks` cannot prove misconfiguration and returns warnings only.
538        // The hard-error path is exercised by `ios_checks_for_plist` below.
539        let report = SetupValidator::validate(Platform::Ios);
540        assert!(
541            report.errors.is_empty(),
542            "iOS validate() has no hard errors when no runtime plist is available"
543        );
544        assert!(!report.warnings.is_empty(), "iOS should have warnings");
545        assert!(report.ok, "ok should be true when errors is empty");
546    }
547
548    #[test]
549    fn ios_checks_no_plist_warnings_only() {
550        let report = SetupValidator::ios_checks_for_plist(None);
551        assert!(report.ok);
552        assert!(report.errors.is_empty());
553        assert!(!report.warnings.is_empty());
554    }
555
556    #[test]
557    fn ios_checks_valid_plist_has_no_errors() {
558        let report = SetupValidator::ios_checks_for_plist(Some(VALID_IOS_PLIST));
559        assert!(report.ok, "valid plist should produce no hard errors");
560        assert!(report.errors.is_empty());
561        assert!(
562            !report.warnings.is_empty(),
563            "warnings still advise the user"
564        );
565    }
566
567    #[test]
568    fn ios_checks_missing_background_modes_emits_error() {
569        let bad = r#"<plist><dict>
570    <key>CFBundleIdentifier</key><string>app.example</string>
571    <key>BGTaskSchedulerPermittedIdentifiers</key>
572    <array><string>app.example.bg-refresh</string></array>
573</dict></plist>"#;
574        let report = SetupValidator::ios_checks_for_plist(Some(bad));
575        assert!(!report.ok);
576        assert!(
577            report
578                .errors
579                .iter()
580                .any(|e| e.code.contains("background_modes")),
581            "missing UIBackgroundModes must be a hard error: {:?}",
582            report.errors.iter().map(|e| &e.code).collect::<Vec<_>>()
583        );
584    }
585
586    #[test]
587    fn ios_checks_missing_processing_mode_masked_by_identifier_emits_error() {
588        // Regression: `processing` is absent from UIBackgroundModes, but the
589        // always-present `app.example.bg-processing` scheduler identifier
590        // contains the substring "processing". A whole-plist substring search
591        // would falsely treat the mode as present and report ok:true. The mode
592        // check must be scoped to the UIBackgroundModes array.
593        let bad = r#"<plist><dict>
594    <key>CFBundleIdentifier</key><string>app.example</string>
595    <key>UIBackgroundModes</key>
596    <array><string>fetch</string></array>
597    <key>BGTaskSchedulerPermittedIdentifiers</key>
598    <array><string>app.example.bg-refresh</string><string>app.example.bg-processing</string></array>
599</dict></plist>"#;
600        let report = SetupValidator::ios_checks_for_plist(Some(bad));
601        assert!(
602            !report.ok,
603            "missing 'processing' UIBackgroundMode must be a hard error even when \
604             a bg-processing scheduler identifier is present"
605        );
606        assert!(
607            report
608                .errors
609                .iter()
610                .any(|e| e.code.contains("background_modes")),
611            "missing UIBackgroundModes mode must be a hard error: {:?}",
612            report.errors.iter().map(|e| &e.code).collect::<Vec<_>>()
613        );
614    }
615
616    #[test]
617    fn ios_checks_missing_bg_identifiers_emits_error() {
618        let bad = r#"<plist><dict>
619    <key>CFBundleIdentifier</key><string>app.example</string>
620    <key>UIBackgroundModes</key>
621    <array><string>fetch</string><string>processing</string></array>
622</dict></plist>"#;
623        let report = SetupValidator::ios_checks_for_plist(Some(bad));
624        assert!(!report.ok);
625        assert!(
626            report
627                .errors
628                .iter()
629                .any(|e| e.code.contains("bg_task_identifiers")),
630            "missing BGTaskSchedulerPermittedIdentifiers must be a hard error"
631        );
632    }
633
634    #[test]
635    fn ios_checks_bundle_id_mismatch_emits_error() {
636        let mismatch = r#"<plist><dict>
637    <key>CFBundleIdentifier</key><string>com.other.app</string>
638    <key>UIBackgroundModes</key>
639    <array><string>fetch</string><string>processing</string></array>
640    <key>BGTaskSchedulerPermittedIdentifiers</key>
641    <array><string>app.example.bg-refresh</string></array>
642</dict></plist>"#;
643        let report = SetupValidator::ios_checks_for_plist(Some(mismatch));
644        assert!(!report.ok);
645        assert!(
646            report.errors.iter().any(|e| e.code.contains("bundle_id")),
647            "bundle-id mismatch must be a hard error"
648        );
649    }
650
651    #[test]
652    fn ios_checks_errors_have_error_severity_in_issues() {
653        let bad = r#"<plist><dict>
654    <key>CFBundleIdentifier</key><string>app.example</string>
655    <key>UIBackgroundModes</key><array><string>fetch</string></array>
656</dict></plist>"#;
657        let report = SetupValidator::ios_checks_for_plist(Some(bad));
658        let error_issues = report
659            .issues
660            .iter()
661            .filter(|vi| vi.severity == Severity::Error)
662            .count();
663        assert_eq!(
664            error_issues,
665            report.errors.len(),
666            "every hard error must appear in issues with Error severity"
667        );
668        assert!(error_issues > 0);
669    }
670
671    #[test]
672    fn ios_has_background_modes_warning() {
673        let report = SetupValidator::validate(Platform::Ios);
674        let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
675        assert!(
676            codes.contains(&"ios_ui_background_modes"),
677            "Should warn about UIBackgroundModes: {codes:?}"
678        );
679    }
680
681    #[test]
682    fn ios_has_task_identifiers_warning() {
683        let report = SetupValidator::validate(Platform::Ios);
684        let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
685        assert!(
686            codes.contains(&"ios_bg_task_identifiers"),
687            "Should warn about BGTaskSchedulerPermittedIdentifiers: {codes:?}"
688        );
689    }
690
691    #[test]
692    fn ios_has_background_refresh_warning() {
693        let report = SetupValidator::validate(Platform::Ios);
694        let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
695        assert!(
696            codes.contains(&"ios_background_refresh"),
697            "Should warn about background refresh: {codes:?}"
698        );
699    }
700
701    #[test]
702    fn ios_all_warnings_have_fix() {
703        let report = SetupValidator::validate(Platform::Ios);
704        for w in &report.warnings {
705            assert!(
706                w.fix.is_some(),
707                "Warning '{}' should have a fix suggestion",
708                w.code
709            );
710        }
711    }
712
713    #[test]
714    fn ios_all_warnings_are_ios_platform() {
715        let report = SetupValidator::validate(Platform::Ios);
716        for w in &report.warnings {
717            assert_eq!(
718                w.platform,
719                Platform::Ios,
720                "Warning '{}' should be iOS platform",
721                w.code
722            );
723        }
724    }
725
726    #[test]
727    fn desktop_linux_no_errors_by_default() {
728        let report = SetupValidator::validate(Platform::Linux);
729        assert!(
730            report.ok || !report.errors.is_empty(),
731            "Report should be consistent: ok == errors.is_empty()"
732        );
733        assert_eq!(report.ok, report.errors.is_empty());
734    }
735
736    #[test]
737    fn desktop_macos_no_errors_by_default() {
738        let report = SetupValidator::validate(Platform::Macos);
739        assert_eq!(report.ok, report.errors.is_empty());
740    }
741
742    #[test]
743    fn desktop_windows_no_errors() {
744        let report = SetupValidator::validate(Platform::Windows);
745        assert!(
746            report.errors.is_empty(),
747            "Windows should have no desktop-service errors (not yet supported)"
748        );
749        assert!(report.ok);
750    }
751
752    #[test]
753    fn desktop_unknown_no_errors() {
754        let report = SetupValidator::validate(Platform::Unknown);
755        assert!(report.errors.is_empty());
756        assert!(report.ok);
757    }
758
759    #[test]
760    fn all_issues_have_non_empty_message() {
761        for platform in [
762            Platform::Android,
763            Platform::Ios,
764            Platform::Linux,
765            Platform::Macos,
766            Platform::Windows,
767        ] {
768            let report = SetupValidator::validate(platform);
769            for issue in report.errors.iter().chain(report.warnings.iter()) {
770                assert!(
771                    !issue.message.is_empty(),
772                    "Issue '{}' on {:?} should have a non-empty message",
773                    issue.code,
774                    platform
775                );
776                assert!(
777                    !issue.code.is_empty(),
778                    "Found an issue with an empty code on {:?}",
779                    platform
780                );
781            }
782        }
783    }
784
785    #[test]
786    fn setup_issue_serde_roundtrip() {
787        let issue = SetupIssue {
788            code: "test_code".into(),
789            message: "Test message".into(),
790            platform: Platform::Android,
791            fix: Some("Do something".into()),
792        };
793        let json = serde_json::to_string(&issue).unwrap();
794        let de: SetupIssue = serde_json::from_str(&json).unwrap();
795        assert_eq!(de.code, "test_code");
796        assert_eq!(de.message, "Test message");
797        assert_eq!(de.platform, Platform::Android);
798        assert_eq!(de.fix, Some("Do something".into()));
799    }
800
801    #[test]
802    fn setup_issue_json_keys_camel_case() {
803        let issue = SetupIssue {
804            code: "c".into(),
805            message: "m".into(),
806            platform: Platform::Linux,
807            fix: Some("f".into()),
808        };
809        let json = serde_json::to_string(&issue).unwrap();
810        assert!(json.contains("\"code\":"), "{json}");
811        assert!(json.contains("\"message\":"), "{json}");
812        assert!(json.contains("\"platform\":"), "{json}");
813        assert!(json.contains("\"fix\":"), "{json}");
814    }
815
816    #[test]
817    fn setup_issue_fix_absent_when_none() {
818        let issue = SetupIssue {
819            code: "c".into(),
820            message: "m".into(),
821            platform: Platform::Linux,
822            fix: None,
823        };
824        let json = serde_json::to_string(&issue).unwrap();
825        assert!(
826            !json.contains("\"fix\""),
827            "fix should be absent when None: {json}"
828        );
829    }
830
831    #[test]
832    fn setup_validation_report_serde_roundtrip() {
833        let report = SetupValidationReport {
834            ok: true,
835            errors: vec![],
836            warnings: vec![SetupIssue {
837                code: "w1".into(),
838                message: "Warning 1".into(),
839                platform: Platform::Android,
840                fix: Some("Fix it".into()),
841            }],
842            issues: vec![],
843        };
844        let json = serde_json::to_string(&report).unwrap();
845        let de: SetupValidationReport = serde_json::from_str(&json).unwrap();
846        assert!(de.ok);
847        assert!(de.errors.is_empty());
848        assert_eq!(de.warnings.len(), 1);
849        assert_eq!(de.warnings[0].code, "w1");
850    }
851
852    #[test]
853    fn setup_validation_report_json_keys_camel_case() {
854        let report = SetupValidationReport {
855            ok: false,
856            errors: vec![SetupIssue {
857                code: "e1".into(),
858                message: "Error".into(),
859                platform: Platform::Ios,
860                fix: None,
861            }],
862            warnings: vec![],
863            issues: vec![],
864        };
865        let json = serde_json::to_string(&report).unwrap();
866        assert!(json.contains("\"ok\":"), "{json}");
867        assert!(json.contains("\"errors\":"), "{json}");
868        assert!(json.contains("\"warnings\":"), "{json}");
869    }
870
871    #[test]
872    fn setup_validation_report_ok_true_when_no_errors() {
873        let report = SetupValidationReport {
874            ok: true,
875            errors: vec![],
876            warnings: vec![SetupIssue {
877                code: "w".into(),
878                message: "warn".into(),
879                platform: Platform::Linux,
880                fix: None,
881            }],
882            issues: vec![],
883        };
884        assert!(report.ok);
885    }
886
887    #[test]
888    fn setup_validation_report_ok_false_with_errors() {
889        let report = SetupValidationReport {
890            ok: false,
891            errors: vec![SetupIssue {
892                code: "e".into(),
893                message: "err".into(),
894                platform: Platform::Linux,
895                fix: None,
896            }],
897            warnings: vec![],
898            issues: vec![],
899        };
900        assert!(!report.ok);
901    }
902
903    #[cfg(all(feature = "desktop-service", target_os = "linux"))]
904    #[test]
905    fn which_exists_true_for_ls() {
906        assert!(which_exists("ls"), "ls should exist in PATH");
907    }
908
909    #[cfg(all(feature = "desktop-service", target_os = "linux"))]
910    #[test]
911    fn which_exists_false_for_nonsense() {
912        assert!(
913            !which_exists("definitely_not_a_real_command_xyz_123"),
914            "nonsense command should not exist"
915        );
916    }
917
918    #[test]
919    fn android_error_prevents_ok() {
920        let report = SetupValidationReport {
921            ok: false,
922            errors: vec![SetupIssue {
923                code: "test_error".into(),
924                message: "test".into(),
925                platform: Platform::Android,
926                fix: None,
927            }],
928            warnings: vec![],
929            issues: vec![],
930        };
931        assert!(!report.ok);
932        assert_eq!(report.errors.len(), 1);
933    }
934
935    #[test]
936    fn warnings_do_not_affect_ok() {
937        let report = SetupValidationReport {
938            ok: true,
939            errors: vec![],
940            warnings: vec![SetupIssue {
941                code: "w".into(),
942                message: "just a warning".into(),
943                platform: Platform::Linux,
944                fix: None,
945            }],
946            issues: vec![],
947        };
948        assert!(report.ok);
949        assert!(!report.warnings.is_empty());
950    }
951
952    // ── Structured issues tests ──────────────────────────────────────
953
954    #[test]
955    fn setup_issue_to_validation_issue_error_severity() {
956        let issue = SetupIssue {
957            code: "test".into(),
958            message: "msg".into(),
959            platform: Platform::Linux,
960            fix: Some("fix it".into()),
961        };
962        let vi = issue.to_validation_issue(Severity::Error);
963        assert_eq!(vi.severity, Severity::Error);
964        assert_eq!(vi.code, "test");
965        assert_eq!(vi.message, "msg");
966        assert_eq!(vi.platform, Platform::Linux);
967        assert_eq!(vi.fix, Some("fix it".into()));
968    }
969
970    #[test]
971    fn setup_issue_to_validation_issue_warning_severity() {
972        let issue = SetupIssue {
973            code: "w".into(),
974            message: "warn msg".into(),
975            platform: Platform::Android,
976            fix: None,
977        };
978        let vi = issue.to_validation_issue(Severity::Warning);
979        assert_eq!(vi.severity, Severity::Warning);
980        assert_eq!(vi.code, "w");
981        assert!(vi.fix.is_none());
982    }
983
984    #[test]
985    fn android_issues_populated_with_warning_severity() {
986        let report = SetupValidator::validate(Platform::Android);
987        assert!(
988            !report.issues.is_empty(),
989            "Android should have structured issues"
990        );
991        assert_eq!(
992            report.issues.len(),
993            report.warnings.len(),
994            "issues count should match warnings count (no errors on Android)"
995        );
996        for vi in &report.issues {
997            assert_eq!(
998                vi.severity,
999                Severity::Warning,
1000                "All Android issues should be warnings: {:?}",
1001                vi.code
1002            );
1003        }
1004    }
1005
1006    #[test]
1007    fn ios_issues_populated_with_warning_severity() {
1008        let report = SetupValidator::validate(Platform::Ios);
1009        assert!(
1010            !report.issues.is_empty(),
1011            "iOS should have structured issues"
1012        );
1013        assert_eq!(
1014            report.issues.len(),
1015            report.warnings.len(),
1016            "issues count should match warnings count (no errors on iOS)"
1017        );
1018        for vi in &report.issues {
1019            assert_eq!(
1020                vi.severity,
1021                Severity::Warning,
1022                "All iOS issues should be warnings: {:?}",
1023                vi.code
1024            );
1025        }
1026    }
1027
1028    #[test]
1029    fn windows_issues_empty() {
1030        let report = SetupValidator::validate(Platform::Windows);
1031        assert!(report.issues.is_empty(), "Windows has no validation issues");
1032    }
1033
1034    #[test]
1035    fn unknown_issues_empty() {
1036        let report = SetupValidator::validate(Platform::Unknown);
1037        assert!(report.issues.is_empty(), "Unknown has no validation issues");
1038    }
1039
1040    #[test]
1041    fn desktop_issues_include_errors_and_warnings() {
1042        let report = SetupValidator::validate(Platform::Linux);
1043        let error_count = report
1044            .issues
1045            .iter()
1046            .filter(|vi| vi.severity == Severity::Error)
1047            .count();
1048        let warning_count = report
1049            .issues
1050            .iter()
1051            .filter(|vi| vi.severity == Severity::Warning)
1052            .count();
1053        assert_eq!(
1054            error_count,
1055            report.errors.len(),
1056            "Error issues should match errors count"
1057        );
1058        assert_eq!(
1059            warning_count,
1060            report.warnings.len(),
1061            "Warning issues should match warnings count"
1062        );
1063        assert_eq!(
1064            report.issues.len(),
1065            report.errors.len() + report.warnings.len(),
1066            "Total issues = errors + warnings"
1067        );
1068    }
1069
1070    #[test]
1071    fn all_platforms_issues_match_errors_plus_warnings() {
1072        for platform in [
1073            Platform::Android,
1074            Platform::Ios,
1075            Platform::Linux,
1076            Platform::Macos,
1077            Platform::Windows,
1078        ] {
1079            let report = SetupValidator::validate(platform);
1080            assert_eq!(
1081                report.issues.len(),
1082                report.errors.len() + report.warnings.len(),
1083                "issues count should equal errors + warnings for {:?}",
1084                platform
1085            );
1086        }
1087    }
1088
1089    #[test]
1090    fn issues_preserve_codes_from_errors_and_warnings() {
1091        let report = SetupValidator::validate(Platform::Android);
1092        let error_codes: Vec<&str> = report.errors.iter().map(|e| e.code.as_str()).collect();
1093        let warning_codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
1094        let issue_codes: Vec<&str> = report.issues.iter().map(|i| i.code.as_str()).collect();
1095        for code in error_codes.iter().chain(warning_codes.iter()) {
1096            assert!(
1097                issue_codes.contains(code),
1098                "issues should contain code '{}'",
1099                code
1100            );
1101        }
1102    }
1103
1104    #[test]
1105    fn validation_issue_serde_roundtrip() {
1106        let vi = ValidationIssue {
1107            severity: Severity::Error,
1108            code: "test_code".into(),
1109            message: "test message".into(),
1110            fix: Some("fix it".into()),
1111            platform: Platform::Linux,
1112        };
1113        let json = serde_json::to_string(&vi).unwrap();
1114        let de: ValidationIssue = serde_json::from_str(&json).unwrap();
1115        assert_eq!(de.severity, Severity::Error);
1116        assert_eq!(de.code, "test_code");
1117        assert_eq!(de.message, "test message");
1118    }
1119
1120    #[test]
1121    fn report_issues_default_empty_on_deserialize() {
1122        let json = r#"{"ok":true,"errors":[],"warnings":[]}"#;
1123        let de: SetupValidationReport = serde_json::from_str(json).unwrap();
1124        assert!(de.issues.is_empty(), "issues should default to empty");
1125    }
1126}