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 Self::ios_checks_for_plist(read_runtime_ios_plist().as_deref())
118 }
119
120 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 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 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 #[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#[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
359fn 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
380fn 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#[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 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 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 #[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}