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        let warnings = vec![
118            SetupIssue {
119                code: "ios_ui_background_modes".into(),
120                message: "UIBackgroundModes must include 'background-fetch' and \
121                          'background-processing' in Info.plist"
122                    .into(),
123                platform: Platform::Ios,
124                fix: Some(
125                    "Add UIBackgroundModes array with 'background-fetch' and \
126                     'background-processing' to Info.plist"
127                        .into(),
128                ),
129            },
130            SetupIssue {
131                code: "ios_bg_task_identifiers".into(),
132                message: "BGTaskSchedulerPermittedIdentifiers must list your task \
133                          identifiers in Info.plist"
134                    .into(),
135                platform: Platform::Ios,
136                fix: Some(
137                    "Add BGTaskSchedulerPermittedIdentifiers array with \
138                     '$(BUNDLE_ID).bg-refresh' and '$(BUNDLE_ID).bg-processing' to Info.plist"
139                        .into(),
140                ),
141            },
142            SetupIssue {
143                code: "ios_background_refresh".into(),
144                message: "Background App Refresh must be enabled in iOS Settings for \
145                          BGTaskScheduler to work"
146                    .into(),
147                platform: Platform::Ios,
148                fix: Some(
149                    "Instruct users to enable Background App Refresh in Settings > General > \
150                     Background App Refresh"
151                        .into(),
152                ),
153            },
154        ];
155
156        let issues: Vec<_> = warnings
157            .iter()
158            .map(|w| w.to_validation_issue(Severity::Warning))
159            .collect();
160
161        SetupValidationReport {
162            ok: true,
163            errors: vec![],
164            warnings,
165            issues,
166        }
167    }
168
169    #[allow(unused_mut)]
170    fn desktop_checks(platform: Platform) -> SetupValidationReport {
171        let mut errors: Vec<SetupIssue> = vec![];
172        let mut warnings: Vec<SetupIssue> = vec![];
173
174        #[cfg(feature = "desktop-service")]
175        {
176            if matches!(platform, Platform::Linux) {
177                let systemctl = std::path::Path::new("/usr/bin/systemctl").exists()
178                    || std::path::Path::new("/bin/systemctl").exists()
179                    || which_exists("systemctl");
180
181                if !systemctl {
182                    errors.push(SetupIssue {
183                        code: "desktop_systemd_missing".into(),
184                        message: "systemctl not found — OS service mode requires systemd".into(),
185                        platform: Platform::Linux,
186                        fix: Some("Install systemd or use inProcess mode".into()),
187                    });
188                } else {
189                    let uid = unsafe { libc::getuid() };
190                    let linger_path = format!("/var/lib/systemd/linger/{uid}");
191                    let linger_ok = std::path::Path::new(&linger_path).exists()
192                        || std::env::var("USER")
193                            .ok()
194                            .map(|u| {
195                                std::path::Path::new(&format!("/var/lib/systemd/linger/{u}"))
196                                    .exists()
197                            })
198                            .unwrap_or(false);
199
200                    if !linger_ok {
201                        warnings.push(SetupIssue {
202                            code: "desktop_systemd_no_linger".into(),
203                            message: "systemd lingering is not enabled — user services \
204                                      will stop when you log out"
205                                .into(),
206                            platform: Platform::Linux,
207                            fix: Some(
208                                "Run 'loginctl enable-linger' to keep user services alive \
209                                 after logout"
210                                    .into(),
211                            ),
212                        });
213                    }
214                }
215            }
216
217            if matches!(platform, Platform::Macos) {
218                warnings.push(SetupIssue {
219                    code: "desktop_macos_sandbox".into(),
220                    message: "OS service mode is incompatible with macOS App Sandbox. \
221                              Ensure your app is not sandboxed or use inProcess mode"
222                        .into(),
223                    platform: Platform::Macos,
224                    fix: Some(
225                        "Disable App Sandbox in your app's entitlements, or use \
226                         desktopServiceMode: 'inProcess'"
227                            .into(),
228                    ),
229                });
230            }
231        }
232
233        #[cfg(not(feature = "desktop-service"))]
234        {
235            let _ = platform;
236        }
237
238        let issues: Vec<_> = errors
239            .iter()
240            .map(|e| e.to_validation_issue(Severity::Error))
241            .chain(
242                warnings
243                    .iter()
244                    .map(|w| w.to_validation_issue(Severity::Warning)),
245            )
246            .collect();
247
248        SetupValidationReport {
249            ok: errors.is_empty(),
250            errors,
251            warnings,
252            issues,
253        }
254    }
255}
256
257/// Check if a command exists in PATH.
258#[cfg(feature = "desktop-service")]
259fn which_exists(cmd: &str) -> bool {
260    std::process::Command::new("which")
261        .arg(cmd)
262        .stdout(std::process::Stdio::null())
263        .stderr(std::process::Stdio::null())
264        .status()
265        .map(|s| s.success())
266        .unwrap_or(false)
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn android_returns_no_errors() {
275        let report = SetupValidator::validate(Platform::Android);
276        assert!(
277            report.errors.is_empty(),
278            "Android should have no hard errors (checks happen at build/Kotlin level)"
279        );
280        assert!(!report.warnings.is_empty(), "Android should have warnings");
281        assert!(report.ok, "ok should be true when errors is empty");
282    }
283
284    #[test]
285    fn android_has_fgs_type_warning() {
286        let report = SetupValidator::validate(Platform::Android);
287        let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
288        assert!(
289            codes.contains(&"android_fgs_type"),
290            "Should warn about FGS type: {codes:?}"
291        );
292    }
293
294    #[test]
295    fn android_has_post_notifications_warning() {
296        let report = SetupValidator::validate(Platform::Android);
297        let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
298        assert!(
299            codes.contains(&"android_post_notifications"),
300            "Should warn about POST_NOTIFICATIONS: {codes:?}"
301        );
302    }
303
304    #[test]
305    fn android_has_boot_receiver_warning() {
306        let report = SetupValidator::validate(Platform::Android);
307        let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
308        assert!(
309            codes.contains(&"android_boot_receiver"),
310            "Should warn about boot receiver: {codes:?}"
311        );
312    }
313
314    #[test]
315    fn android_has_special_use_subtype_warning() {
316        let report = SetupValidator::validate(Platform::Android);
317        let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
318        assert!(
319            codes.contains(&"android_special_use_subtype"),
320            "Should warn about specialUse subtype: {codes:?}"
321        );
322    }
323
324    #[test]
325    fn android_has_api35_boot_blocked_type_warning() {
326        let report = SetupValidator::validate(Platform::Android);
327        let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
328        assert!(
329            codes.contains(&"android_api35_boot_blocked_type"),
330            "Should warn about API 35+ boot-blocked FGS types: {codes:?}"
331        );
332    }
333
334    #[test]
335    fn android_api35_boot_blocked_warning_lists_types() {
336        let report = SetupValidator::validate(Platform::Android);
337        let warning = report
338            .warnings
339            .iter()
340            .find(|w| w.code == "android_api35_boot_blocked_type")
341            .expect("Should have android_api35_boot_blocked_type warning");
342        for ty in &[
343            "dataSync",
344            "camera",
345            "mediaPlayback",
346            "phoneCall",
347            "mediaProjection",
348            "microphone",
349        ] {
350            assert!(
351                warning.message.contains(ty),
352                "Warning message should mention '{}': {}",
353                ty,
354                warning.message
355            );
356        }
357        assert!(warning.fix.is_some(), "Should have a fix suggestion");
358    }
359
360    #[test]
361    fn android_all_warnings_have_fix() {
362        let report = SetupValidator::validate(Platform::Android);
363        for w in &report.warnings {
364            assert!(
365                w.fix.is_some(),
366                "Warning '{}' should have a fix suggestion",
367                w.code
368            );
369        }
370    }
371
372    #[test]
373    fn android_all_warnings_are_android_platform() {
374        let report = SetupValidator::validate(Platform::Android);
375        for w in &report.warnings {
376            assert_eq!(
377                w.platform,
378                Platform::Android,
379                "Warning '{}' should be Android platform",
380                w.code
381            );
382        }
383    }
384
385    #[test]
386    fn ios_returns_no_errors() {
387        let report = SetupValidator::validate(Platform::Ios);
388        assert!(
389            report.errors.is_empty(),
390            "iOS should have no hard errors (checks happen at build/Swift level)"
391        );
392        assert!(!report.warnings.is_empty(), "iOS should have warnings");
393        assert!(report.ok, "ok should be true when errors is empty");
394    }
395
396    #[test]
397    fn ios_has_background_modes_warning() {
398        let report = SetupValidator::validate(Platform::Ios);
399        let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
400        assert!(
401            codes.contains(&"ios_ui_background_modes"),
402            "Should warn about UIBackgroundModes: {codes:?}"
403        );
404    }
405
406    #[test]
407    fn ios_has_task_identifiers_warning() {
408        let report = SetupValidator::validate(Platform::Ios);
409        let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
410        assert!(
411            codes.contains(&"ios_bg_task_identifiers"),
412            "Should warn about BGTaskSchedulerPermittedIdentifiers: {codes:?}"
413        );
414    }
415
416    #[test]
417    fn ios_has_background_refresh_warning() {
418        let report = SetupValidator::validate(Platform::Ios);
419        let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
420        assert!(
421            codes.contains(&"ios_background_refresh"),
422            "Should warn about background refresh: {codes:?}"
423        );
424    }
425
426    #[test]
427    fn ios_all_warnings_have_fix() {
428        let report = SetupValidator::validate(Platform::Ios);
429        for w in &report.warnings {
430            assert!(
431                w.fix.is_some(),
432                "Warning '{}' should have a fix suggestion",
433                w.code
434            );
435        }
436    }
437
438    #[test]
439    fn ios_all_warnings_are_ios_platform() {
440        let report = SetupValidator::validate(Platform::Ios);
441        for w in &report.warnings {
442            assert_eq!(
443                w.platform,
444                Platform::Ios,
445                "Warning '{}' should be iOS platform",
446                w.code
447            );
448        }
449    }
450
451    #[test]
452    fn desktop_linux_no_errors_by_default() {
453        let report = SetupValidator::validate(Platform::Linux);
454        assert!(
455            report.ok || !report.errors.is_empty(),
456            "Report should be consistent: ok == errors.is_empty()"
457        );
458        assert_eq!(report.ok, report.errors.is_empty());
459    }
460
461    #[test]
462    fn desktop_macos_no_errors_by_default() {
463        let report = SetupValidator::validate(Platform::Macos);
464        assert_eq!(report.ok, report.errors.is_empty());
465    }
466
467    #[test]
468    fn desktop_windows_no_errors() {
469        let report = SetupValidator::validate(Platform::Windows);
470        assert!(
471            report.errors.is_empty(),
472            "Windows should have no desktop-service errors (not yet supported)"
473        );
474        assert!(report.ok);
475    }
476
477    #[test]
478    fn desktop_unknown_no_errors() {
479        let report = SetupValidator::validate(Platform::Unknown);
480        assert!(report.errors.is_empty());
481        assert!(report.ok);
482    }
483
484    #[test]
485    fn all_issues_have_non_empty_message() {
486        for platform in [
487            Platform::Android,
488            Platform::Ios,
489            Platform::Linux,
490            Platform::Macos,
491            Platform::Windows,
492        ] {
493            let report = SetupValidator::validate(platform);
494            for issue in report.errors.iter().chain(report.warnings.iter()) {
495                assert!(
496                    !issue.message.is_empty(),
497                    "Issue '{}' on {:?} should have a non-empty message",
498                    issue.code,
499                    platform
500                );
501                assert!(
502                    !issue.code.is_empty(),
503                    "Found an issue with an empty code on {:?}",
504                    platform
505                );
506            }
507        }
508    }
509
510    #[test]
511    fn setup_issue_serde_roundtrip() {
512        let issue = SetupIssue {
513            code: "test_code".into(),
514            message: "Test message".into(),
515            platform: Platform::Android,
516            fix: Some("Do something".into()),
517        };
518        let json = serde_json::to_string(&issue).unwrap();
519        let de: SetupIssue = serde_json::from_str(&json).unwrap();
520        assert_eq!(de.code, "test_code");
521        assert_eq!(de.message, "Test message");
522        assert_eq!(de.platform, Platform::Android);
523        assert_eq!(de.fix, Some("Do something".into()));
524    }
525
526    #[test]
527    fn setup_issue_json_keys_camel_case() {
528        let issue = SetupIssue {
529            code: "c".into(),
530            message: "m".into(),
531            platform: Platform::Linux,
532            fix: Some("f".into()),
533        };
534        let json = serde_json::to_string(&issue).unwrap();
535        assert!(json.contains("\"code\":"), "{json}");
536        assert!(json.contains("\"message\":"), "{json}");
537        assert!(json.contains("\"platform\":"), "{json}");
538        assert!(json.contains("\"fix\":"), "{json}");
539    }
540
541    #[test]
542    fn setup_issue_fix_absent_when_none() {
543        let issue = SetupIssue {
544            code: "c".into(),
545            message: "m".into(),
546            platform: Platform::Linux,
547            fix: None,
548        };
549        let json = serde_json::to_string(&issue).unwrap();
550        assert!(
551            !json.contains("\"fix\""),
552            "fix should be absent when None: {json}"
553        );
554    }
555
556    #[test]
557    fn setup_validation_report_serde_roundtrip() {
558        let report = SetupValidationReport {
559            ok: true,
560            errors: vec![],
561            warnings: vec![SetupIssue {
562                code: "w1".into(),
563                message: "Warning 1".into(),
564                platform: Platform::Android,
565                fix: Some("Fix it".into()),
566            }],
567            issues: vec![],
568        };
569        let json = serde_json::to_string(&report).unwrap();
570        let de: SetupValidationReport = serde_json::from_str(&json).unwrap();
571        assert!(de.ok);
572        assert!(de.errors.is_empty());
573        assert_eq!(de.warnings.len(), 1);
574        assert_eq!(de.warnings[0].code, "w1");
575    }
576
577    #[test]
578    fn setup_validation_report_json_keys_camel_case() {
579        let report = SetupValidationReport {
580            ok: false,
581            errors: vec![SetupIssue {
582                code: "e1".into(),
583                message: "Error".into(),
584                platform: Platform::Ios,
585                fix: None,
586            }],
587            warnings: vec![],
588            issues: vec![],
589        };
590        let json = serde_json::to_string(&report).unwrap();
591        assert!(json.contains("\"ok\":"), "{json}");
592        assert!(json.contains("\"errors\":"), "{json}");
593        assert!(json.contains("\"warnings\":"), "{json}");
594    }
595
596    #[test]
597    fn setup_validation_report_ok_true_when_no_errors() {
598        let report = SetupValidationReport {
599            ok: true,
600            errors: vec![],
601            warnings: vec![SetupIssue {
602                code: "w".into(),
603                message: "warn".into(),
604                platform: Platform::Linux,
605                fix: None,
606            }],
607            issues: vec![],
608        };
609        assert!(report.ok);
610    }
611
612    #[test]
613    fn setup_validation_report_ok_false_with_errors() {
614        let report = SetupValidationReport {
615            ok: false,
616            errors: vec![SetupIssue {
617                code: "e".into(),
618                message: "err".into(),
619                platform: Platform::Linux,
620                fix: None,
621            }],
622            warnings: vec![],
623            issues: vec![],
624        };
625        assert!(!report.ok);
626    }
627
628    #[cfg(feature = "desktop-service")]
629    #[test]
630    fn which_exists_true_for_ls() {
631        assert!(which_exists("ls"), "ls should exist in PATH");
632    }
633
634    #[cfg(feature = "desktop-service")]
635    #[test]
636    fn which_exists_false_for_nonsense() {
637        assert!(
638            !which_exists("definitely_not_a_real_command_xyz_123"),
639            "nonsense command should not exist"
640        );
641    }
642
643    #[test]
644    fn android_error_prevents_ok() {
645        let report = SetupValidationReport {
646            ok: false,
647            errors: vec![SetupIssue {
648                code: "test_error".into(),
649                message: "test".into(),
650                platform: Platform::Android,
651                fix: None,
652            }],
653            warnings: vec![],
654            issues: vec![],
655        };
656        assert!(!report.ok);
657        assert_eq!(report.errors.len(), 1);
658    }
659
660    #[test]
661    fn warnings_do_not_affect_ok() {
662        let report = SetupValidationReport {
663            ok: true,
664            errors: vec![],
665            warnings: vec![SetupIssue {
666                code: "w".into(),
667                message: "just a warning".into(),
668                platform: Platform::Linux,
669                fix: None,
670            }],
671            issues: vec![],
672        };
673        assert!(report.ok);
674        assert!(!report.warnings.is_empty());
675    }
676
677    // ── Structured issues tests ──────────────────────────────────────
678
679    #[test]
680    fn setup_issue_to_validation_issue_error_severity() {
681        let issue = SetupIssue {
682            code: "test".into(),
683            message: "msg".into(),
684            platform: Platform::Linux,
685            fix: Some("fix it".into()),
686        };
687        let vi = issue.to_validation_issue(Severity::Error);
688        assert_eq!(vi.severity, Severity::Error);
689        assert_eq!(vi.code, "test");
690        assert_eq!(vi.message, "msg");
691        assert_eq!(vi.platform, Platform::Linux);
692        assert_eq!(vi.fix, Some("fix it".into()));
693    }
694
695    #[test]
696    fn setup_issue_to_validation_issue_warning_severity() {
697        let issue = SetupIssue {
698            code: "w".into(),
699            message: "warn msg".into(),
700            platform: Platform::Android,
701            fix: None,
702        };
703        let vi = issue.to_validation_issue(Severity::Warning);
704        assert_eq!(vi.severity, Severity::Warning);
705        assert_eq!(vi.code, "w");
706        assert!(vi.fix.is_none());
707    }
708
709    #[test]
710    fn android_issues_populated_with_warning_severity() {
711        let report = SetupValidator::validate(Platform::Android);
712        assert!(
713            !report.issues.is_empty(),
714            "Android should have structured issues"
715        );
716        assert_eq!(
717            report.issues.len(),
718            report.warnings.len(),
719            "issues count should match warnings count (no errors on Android)"
720        );
721        for vi in &report.issues {
722            assert_eq!(
723                vi.severity,
724                Severity::Warning,
725                "All Android issues should be warnings: {:?}",
726                vi.code
727            );
728        }
729    }
730
731    #[test]
732    fn ios_issues_populated_with_warning_severity() {
733        let report = SetupValidator::validate(Platform::Ios);
734        assert!(
735            !report.issues.is_empty(),
736            "iOS should have structured issues"
737        );
738        assert_eq!(
739            report.issues.len(),
740            report.warnings.len(),
741            "issues count should match warnings count (no errors on iOS)"
742        );
743        for vi in &report.issues {
744            assert_eq!(
745                vi.severity,
746                Severity::Warning,
747                "All iOS issues should be warnings: {:?}",
748                vi.code
749            );
750        }
751    }
752
753    #[test]
754    fn windows_issues_empty() {
755        let report = SetupValidator::validate(Platform::Windows);
756        assert!(report.issues.is_empty(), "Windows has no validation issues");
757    }
758
759    #[test]
760    fn unknown_issues_empty() {
761        let report = SetupValidator::validate(Platform::Unknown);
762        assert!(report.issues.is_empty(), "Unknown has no validation issues");
763    }
764
765    #[test]
766    fn desktop_issues_include_errors_and_warnings() {
767        let report = SetupValidator::validate(Platform::Linux);
768        let error_count = report
769            .issues
770            .iter()
771            .filter(|vi| vi.severity == Severity::Error)
772            .count();
773        let warning_count = report
774            .issues
775            .iter()
776            .filter(|vi| vi.severity == Severity::Warning)
777            .count();
778        assert_eq!(
779            error_count,
780            report.errors.len(),
781            "Error issues should match errors count"
782        );
783        assert_eq!(
784            warning_count,
785            report.warnings.len(),
786            "Warning issues should match warnings count"
787        );
788        assert_eq!(
789            report.issues.len(),
790            report.errors.len() + report.warnings.len(),
791            "Total issues = errors + warnings"
792        );
793    }
794
795    #[test]
796    fn all_platforms_issues_match_errors_plus_warnings() {
797        for platform in [
798            Platform::Android,
799            Platform::Ios,
800            Platform::Linux,
801            Platform::Macos,
802            Platform::Windows,
803        ] {
804            let report = SetupValidator::validate(platform);
805            assert_eq!(
806                report.issues.len(),
807                report.errors.len() + report.warnings.len(),
808                "issues count should equal errors + warnings for {:?}",
809                platform
810            );
811        }
812    }
813
814    #[test]
815    fn issues_preserve_codes_from_errors_and_warnings() {
816        let report = SetupValidator::validate(Platform::Android);
817        let error_codes: Vec<&str> = report.errors.iter().map(|e| e.code.as_str()).collect();
818        let warning_codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
819        let issue_codes: Vec<&str> = report.issues.iter().map(|i| i.code.as_str()).collect();
820        for code in error_codes.iter().chain(warning_codes.iter()) {
821            assert!(
822                issue_codes.contains(code),
823                "issues should contain code '{}'",
824                code
825            );
826        }
827    }
828
829    #[test]
830    fn validation_issue_serde_roundtrip() {
831        let vi = ValidationIssue {
832            severity: Severity::Error,
833            code: "test_code".into(),
834            message: "test message".into(),
835            fix: Some("fix it".into()),
836            platform: Platform::Linux,
837        };
838        let json = serde_json::to_string(&vi).unwrap();
839        let de: ValidationIssue = serde_json::from_str(&json).unwrap();
840        assert_eq!(de.severity, Severity::Error);
841        assert_eq!(de.code, "test_code");
842        assert_eq!(de.message, "test message");
843    }
844
845    #[test]
846    fn report_issues_default_empty_on_deserialize() {
847        let json = r#"{"ok":true,"errors":[],"warnings":[]}"#;
848        let de: SetupValidationReport = serde_json::from_str(json).unwrap();
849        assert!(de.issues.is_empty(), "issues should default to empty");
850    }
851}