1use crate::models::{Platform, SetupIssue, SetupValidationReport, Severity};
11
12#[cfg(test)]
13use crate::models::ValidationIssue;
14
15pub struct SetupValidator;
21
22impl SetupValidator {
23 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#[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 #[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}