use crate::models::{Platform, SetupIssue, SetupValidationReport, Severity};
#[cfg(test)]
use crate::models::ValidationIssue;
pub struct SetupValidator;
impl SetupValidator {
pub fn validate(platform: Platform) -> SetupValidationReport {
match platform {
Platform::Android => Self::android_checks(),
Platform::Ios => Self::ios_checks(),
Platform::Linux | Platform::Macos | Platform::Windows | Platform::Unknown => {
Self::desktop_checks(platform)
}
}
}
fn android_checks() -> SetupValidationReport {
let warnings = vec![
SetupIssue {
code: "android_fgs_type".into(),
message: "Ensure the foreground service type is declared in AndroidManifest.xml \
with the matching permission"
.into(),
platform: Platform::Android,
fix: Some(
"Add <foregroundServiceType> to your <service> element and the \
corresponding <uses-permission> to the manifest"
.into(),
),
},
SetupIssue {
code: "android_post_notifications".into(),
message: "Android 13+ requires POST_NOTIFICATIONS runtime permission for \
foreground service notifications"
.into(),
platform: Platform::Android,
fix: Some(
"Request android.permission.POST_NOTIFICATIONS at runtime before \
starting the service on Android 13+"
.into(),
),
},
SetupIssue {
code: "android_boot_receiver".into(),
message: "Boot recovery requires a registered BroadcastReceiver for \
BOOT_COMPLETED"
.into(),
platform: Platform::Android,
fix: Some(
"Add RECEIVE_BOOT_COMPLETED permission and a <receiver> element for \
BOOT_COMPLETED in AndroidManifest.xml"
.into(),
),
},
SetupIssue {
code: "android_special_use_subtype".into(),
message: "When using specialUse FGS type, PROPERTY_SPECIAL_USE_FGS_SUBTYPE \
must be declared in the manifest"
.into(),
platform: Platform::Android,
fix: Some(
"Add <property android:name=\"android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE\" \
android:value=\"your_reason\" /> to the <service> element"
.into(),
),
},
SetupIssue {
code: "android_api35_boot_blocked_type".into(),
message:
"Android 15 (API 35) blocks certain FGS types from starting in \
BOOT_COMPLETED receivers: dataSync, camera, mediaPlayback, phoneCall, \
mediaProjection, microphone. Boot recovery will not work with these types"
.into(),
platform: Platform::Android,
fix: Some(
"Use a non-blocked FGS type (e.g. connectedDevice, health, location, \
mediaProcessing) for boot recovery, or handle re-launch via user interaction"
.into(),
),
},
];
let issues: Vec<_> = warnings
.iter()
.map(|w| w.to_validation_issue(Severity::Warning))
.collect();
SetupValidationReport {
ok: true,
errors: vec![],
warnings,
issues,
}
}
fn ios_checks() -> SetupValidationReport {
Self::ios_checks_for_plist(read_runtime_ios_plist().as_deref())
}
fn ios_checks_for_plist(plist: Option<&str>) -> SetupValidationReport {
let warnings = vec![
SetupIssue {
code: "ios_ui_background_modes".into(),
message: "UIBackgroundModes must include 'fetch' and \
'processing' in Info.plist"
.into(),
platform: Platform::Ios,
fix: Some(
"Add UIBackgroundModes array with 'fetch' and \
'processing' to Info.plist"
.into(),
),
},
SetupIssue {
code: "ios_bg_task_identifiers".into(),
message: "BGTaskSchedulerPermittedIdentifiers must list your task \
identifiers in Info.plist"
.into(),
platform: Platform::Ios,
fix: Some(
"Add BGTaskSchedulerPermittedIdentifiers array with \
'$(BUNDLE_ID).bg-refresh' and '$(BUNDLE_ID).bg-processing' to Info.plist"
.into(),
),
},
SetupIssue {
code: "ios_background_refresh".into(),
message: "Background App Refresh must be enabled in iOS Settings for \
BGTaskScheduler to work"
.into(),
platform: Platform::Ios,
fix: Some(
"Instruct users to enable Background App Refresh in Settings > General > \
Background App Refresh"
.into(),
),
},
];
let mut errors: Vec<SetupIssue> = vec![];
if let Some(plist) = plist {
let bg_modes_array = plist_array_after_key(plist, "UIBackgroundModes");
let has_fetch = bg_modes_array.is_some_and(|a| a.contains("fetch"));
let has_processing = bg_modes_array.is_some_and(|a| a.contains("processing"));
let has_bg_task = plist.contains("BGTaskSchedulerPermittedIdentifiers");
if !(has_fetch && has_processing) {
errors.push(SetupIssue {
code: "ios_ui_background_modes_missing".into(),
message: "Built Info.plist is missing UIBackgroundModes 'fetch' and/or \
'processing' — BGTaskScheduler will not run"
.into(),
platform: Platform::Ios,
fix: Some(
"Add 'fetch' and 'processing' to the UIBackgroundModes array in \
Info.ios.plist and rebuild"
.into(),
),
});
}
if !has_bg_task {
errors.push(SetupIssue {
code: "ios_bg_task_identifiers_missing".into(),
message: "Built Info.plist is missing BGTaskSchedulerPermittedIdentifiers \
— background tasks cannot be scheduled"
.into(),
platform: Platform::Ios,
fix: Some(
"Add BGTaskSchedulerPermittedIdentifiers with your bg-refresh and \
bg-processing identifiers to Info.ios.plist and rebuild"
.into(),
),
});
} else if let Some(bundle_id) = extract_bundle_identifier(plist) {
if !plist.contains(&format!("{bundle_id}.")) {
errors.push(SetupIssue {
code: "ios_bundle_id_mismatch".into(),
message: "BGTaskSchedulerPermittedIdentifiers do not match the bundle \
identifier — task registration will fail"
.into(),
platform: Platform::Ios,
fix: Some(
"Namespace the scheduler identifiers under the app bundle id \
(e.g. '<bundle-id>.bg-refresh')"
.into(),
),
});
}
}
}
let issues: Vec<_> = errors
.iter()
.map(|e| e.to_validation_issue(Severity::Error))
.chain(
warnings
.iter()
.map(|w| w.to_validation_issue(Severity::Warning)),
)
.collect();
SetupValidationReport {
ok: errors.is_empty(),
errors,
warnings,
issues,
}
}
#[allow(unused_mut)]
fn desktop_checks(platform: Platform) -> SetupValidationReport {
let mut errors: Vec<SetupIssue> = vec![];
let mut warnings: Vec<SetupIssue> = vec![];
#[cfg(feature = "desktop-service")]
{
#[cfg(target_os = "linux")]
if matches!(platform, Platform::Linux) {
let systemctl = std::path::Path::new("/usr/bin/systemctl").exists()
|| std::path::Path::new("/bin/systemctl").exists()
|| which_exists("systemctl");
if !systemctl {
errors.push(SetupIssue {
code: "desktop_systemd_missing".into(),
message: "systemctl not found — OS service mode requires systemd".into(),
platform: Platform::Linux,
fix: Some("Install systemd or use inProcess mode".into()),
});
} else {
let uid = unsafe { libc::getuid() };
let linger_path = format!("/var/lib/systemd/linger/{uid}");
let linger_ok = std::path::Path::new(&linger_path).exists()
|| std::env::var("USER")
.ok()
.map(|u| {
std::path::Path::new(&format!("/var/lib/systemd/linger/{u}"))
.exists()
})
.unwrap_or(false);
if !linger_ok {
warnings.push(SetupIssue {
code: "desktop_systemd_no_linger".into(),
message: "systemd lingering is not enabled — user services \
will stop when you log out"
.into(),
platform: Platform::Linux,
fix: Some(
"Run 'loginctl enable-linger' to keep user services alive \
after logout"
.into(),
),
});
}
}
}
if matches!(platform, Platform::Macos) {
warnings.push(SetupIssue {
code: "desktop_macos_sandbox".into(),
message: "OS service mode is incompatible with macOS App Sandbox. \
Ensure your app is not sandboxed or use inProcess mode"
.into(),
platform: Platform::Macos,
fix: Some(
"Disable App Sandbox in your app's entitlements, or use \
desktopServiceMode: 'inProcess'"
.into(),
),
});
}
}
#[cfg(not(feature = "desktop-service"))]
{
let _ = platform;
}
let issues: Vec<_> = errors
.iter()
.map(|e| e.to_validation_issue(Severity::Error))
.chain(
warnings
.iter()
.map(|w| w.to_validation_issue(Severity::Warning)),
)
.collect();
SetupValidationReport {
ok: errors.is_empty(),
errors,
warnings,
issues,
}
}
}
#[cfg(target_os = "ios")]
fn read_runtime_ios_plist() -> Option<String> {
let exe = std::env::current_exe().ok()?;
let plist_path = exe.parent()?.join("Info.plist");
std::fs::read_to_string(plist_path).ok()
}
#[cfg(not(target_os = "ios"))]
fn read_runtime_ios_plist() -> Option<String> {
None
}
fn plist_array_after_key<'a>(plist_xml: &'a str, key: &str) -> Option<&'a str> {
let key_tag = format!("<key>{key}</key>");
let key_pos = plist_xml.find(&key_tag)?;
let after = &plist_xml[key_pos + key_tag.len()..];
let value_region = match after.find("<key>") {
Some(next_key) => &after[..next_key],
None => after,
};
let arr_start = value_region.find("<array>")? + "<array>".len();
let arr_end = value_region[arr_start..].find("</array>")?;
Some(&value_region[arr_start..arr_start + arr_end])
}
fn extract_bundle_identifier(plist_xml: &str) -> Option<String> {
let key_pos = plist_xml.find("CFBundleIdentifier")?;
let after = &plist_xml[key_pos..];
let start = after.find("<string>")? + "<string>".len();
let end = after[start..].find("</string>")?;
let value = after[start..start + end].trim();
if value.is_empty() || value.starts_with("$(") {
None
} else {
Some(value.to_string())
}
}
#[cfg(all(feature = "desktop-service", target_os = "linux"))]
fn which_exists(cmd: &str) -> bool {
std::process::Command::new("which")
.arg(cmd)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn android_returns_no_errors() {
let report = SetupValidator::validate(Platform::Android);
assert!(
report.errors.is_empty(),
"Android should have no hard errors (checks happen at build/Kotlin level)"
);
assert!(!report.warnings.is_empty(), "Android should have warnings");
assert!(report.ok, "ok should be true when errors is empty");
}
#[test]
fn android_has_fgs_type_warning() {
let report = SetupValidator::validate(Platform::Android);
let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
assert!(
codes.contains(&"android_fgs_type"),
"Should warn about FGS type: {codes:?}"
);
}
#[test]
fn android_has_post_notifications_warning() {
let report = SetupValidator::validate(Platform::Android);
let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
assert!(
codes.contains(&"android_post_notifications"),
"Should warn about POST_NOTIFICATIONS: {codes:?}"
);
}
#[test]
fn android_has_boot_receiver_warning() {
let report = SetupValidator::validate(Platform::Android);
let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
assert!(
codes.contains(&"android_boot_receiver"),
"Should warn about boot receiver: {codes:?}"
);
}
#[test]
fn android_has_special_use_subtype_warning() {
let report = SetupValidator::validate(Platform::Android);
let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
assert!(
codes.contains(&"android_special_use_subtype"),
"Should warn about specialUse subtype: {codes:?}"
);
}
#[test]
fn android_has_api35_boot_blocked_type_warning() {
let report = SetupValidator::validate(Platform::Android);
let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
assert!(
codes.contains(&"android_api35_boot_blocked_type"),
"Should warn about API 35+ boot-blocked FGS types: {codes:?}"
);
}
#[test]
fn android_api35_boot_blocked_warning_lists_types() {
let report = SetupValidator::validate(Platform::Android);
let warning = report
.warnings
.iter()
.find(|w| w.code == "android_api35_boot_blocked_type")
.expect("Should have android_api35_boot_blocked_type warning");
for ty in &[
"dataSync",
"camera",
"mediaPlayback",
"phoneCall",
"mediaProjection",
"microphone",
] {
assert!(
warning.message.contains(ty),
"Warning message should mention '{}': {}",
ty,
warning.message
);
}
assert!(warning.fix.is_some(), "Should have a fix suggestion");
}
#[test]
fn android_all_warnings_have_fix() {
let report = SetupValidator::validate(Platform::Android);
for w in &report.warnings {
assert!(
w.fix.is_some(),
"Warning '{}' should have a fix suggestion",
w.code
);
}
}
#[test]
fn android_all_warnings_are_android_platform() {
let report = SetupValidator::validate(Platform::Android);
for w in &report.warnings {
assert_eq!(
w.platform,
Platform::Android,
"Warning '{}' should be Android platform",
w.code
);
}
}
const VALID_IOS_PLIST: &str = r#"<plist><dict>
<key>CFBundleIdentifier</key>
<string>app.example</string>
<key>UIBackgroundModes</key>
<array><string>fetch</string><string>processing</string></array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array><string>app.example.bg-refresh</string><string>app.example.bg-processing</string></array>
</dict></plist>"#;
#[test]
fn ios_returns_no_errors() {
let report = SetupValidator::validate(Platform::Ios);
assert!(
report.errors.is_empty(),
"iOS validate() has no hard errors when no runtime plist is available"
);
assert!(!report.warnings.is_empty(), "iOS should have warnings");
assert!(report.ok, "ok should be true when errors is empty");
}
#[test]
fn ios_checks_no_plist_warnings_only() {
let report = SetupValidator::ios_checks_for_plist(None);
assert!(report.ok);
assert!(report.errors.is_empty());
assert!(!report.warnings.is_empty());
}
#[test]
fn ios_checks_valid_plist_has_no_errors() {
let report = SetupValidator::ios_checks_for_plist(Some(VALID_IOS_PLIST));
assert!(report.ok, "valid plist should produce no hard errors");
assert!(report.errors.is_empty());
assert!(
!report.warnings.is_empty(),
"warnings still advise the user"
);
}
#[test]
fn ios_checks_missing_background_modes_emits_error() {
let bad = r#"<plist><dict>
<key>CFBundleIdentifier</key><string>app.example</string>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array><string>app.example.bg-refresh</string></array>
</dict></plist>"#;
let report = SetupValidator::ios_checks_for_plist(Some(bad));
assert!(!report.ok);
assert!(
report
.errors
.iter()
.any(|e| e.code.contains("background_modes")),
"missing UIBackgroundModes must be a hard error: {:?}",
report.errors.iter().map(|e| &e.code).collect::<Vec<_>>()
);
}
#[test]
fn ios_checks_missing_processing_mode_masked_by_identifier_emits_error() {
let bad = r#"<plist><dict>
<key>CFBundleIdentifier</key><string>app.example</string>
<key>UIBackgroundModes</key>
<array><string>fetch</string></array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array><string>app.example.bg-refresh</string><string>app.example.bg-processing</string></array>
</dict></plist>"#;
let report = SetupValidator::ios_checks_for_plist(Some(bad));
assert!(
!report.ok,
"missing 'processing' UIBackgroundMode must be a hard error even when \
a bg-processing scheduler identifier is present"
);
assert!(
report
.errors
.iter()
.any(|e| e.code.contains("background_modes")),
"missing UIBackgroundModes mode must be a hard error: {:?}",
report.errors.iter().map(|e| &e.code).collect::<Vec<_>>()
);
}
#[test]
fn ios_checks_missing_bg_identifiers_emits_error() {
let bad = r#"<plist><dict>
<key>CFBundleIdentifier</key><string>app.example</string>
<key>UIBackgroundModes</key>
<array><string>fetch</string><string>processing</string></array>
</dict></plist>"#;
let report = SetupValidator::ios_checks_for_plist(Some(bad));
assert!(!report.ok);
assert!(
report
.errors
.iter()
.any(|e| e.code.contains("bg_task_identifiers")),
"missing BGTaskSchedulerPermittedIdentifiers must be a hard error"
);
}
#[test]
fn ios_checks_bundle_id_mismatch_emits_error() {
let mismatch = r#"<plist><dict>
<key>CFBundleIdentifier</key><string>com.other.app</string>
<key>UIBackgroundModes</key>
<array><string>fetch</string><string>processing</string></array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array><string>app.example.bg-refresh</string></array>
</dict></plist>"#;
let report = SetupValidator::ios_checks_for_plist(Some(mismatch));
assert!(!report.ok);
assert!(
report.errors.iter().any(|e| e.code.contains("bundle_id")),
"bundle-id mismatch must be a hard error"
);
}
#[test]
fn ios_checks_errors_have_error_severity_in_issues() {
let bad = r#"<plist><dict>
<key>CFBundleIdentifier</key><string>app.example</string>
<key>UIBackgroundModes</key><array><string>fetch</string></array>
</dict></plist>"#;
let report = SetupValidator::ios_checks_for_plist(Some(bad));
let error_issues = report
.issues
.iter()
.filter(|vi| vi.severity == Severity::Error)
.count();
assert_eq!(
error_issues,
report.errors.len(),
"every hard error must appear in issues with Error severity"
);
assert!(error_issues > 0);
}
#[test]
fn ios_has_background_modes_warning() {
let report = SetupValidator::validate(Platform::Ios);
let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
assert!(
codes.contains(&"ios_ui_background_modes"),
"Should warn about UIBackgroundModes: {codes:?}"
);
}
#[test]
fn ios_has_task_identifiers_warning() {
let report = SetupValidator::validate(Platform::Ios);
let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
assert!(
codes.contains(&"ios_bg_task_identifiers"),
"Should warn about BGTaskSchedulerPermittedIdentifiers: {codes:?}"
);
}
#[test]
fn ios_has_background_refresh_warning() {
let report = SetupValidator::validate(Platform::Ios);
let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
assert!(
codes.contains(&"ios_background_refresh"),
"Should warn about background refresh: {codes:?}"
);
}
#[test]
fn ios_all_warnings_have_fix() {
let report = SetupValidator::validate(Platform::Ios);
for w in &report.warnings {
assert!(
w.fix.is_some(),
"Warning '{}' should have a fix suggestion",
w.code
);
}
}
#[test]
fn ios_all_warnings_are_ios_platform() {
let report = SetupValidator::validate(Platform::Ios);
for w in &report.warnings {
assert_eq!(
w.platform,
Platform::Ios,
"Warning '{}' should be iOS platform",
w.code
);
}
}
#[test]
fn desktop_linux_no_errors_by_default() {
let report = SetupValidator::validate(Platform::Linux);
assert!(
report.ok || !report.errors.is_empty(),
"Report should be consistent: ok == errors.is_empty()"
);
assert_eq!(report.ok, report.errors.is_empty());
}
#[test]
fn desktop_macos_no_errors_by_default() {
let report = SetupValidator::validate(Platform::Macos);
assert_eq!(report.ok, report.errors.is_empty());
}
#[test]
fn desktop_windows_no_errors() {
let report = SetupValidator::validate(Platform::Windows);
assert!(
report.errors.is_empty(),
"Windows should have no desktop-service errors (not yet supported)"
);
assert!(report.ok);
}
#[test]
fn desktop_unknown_no_errors() {
let report = SetupValidator::validate(Platform::Unknown);
assert!(report.errors.is_empty());
assert!(report.ok);
}
#[test]
fn all_issues_have_non_empty_message() {
for platform in [
Platform::Android,
Platform::Ios,
Platform::Linux,
Platform::Macos,
Platform::Windows,
] {
let report = SetupValidator::validate(platform);
for issue in report.errors.iter().chain(report.warnings.iter()) {
assert!(
!issue.message.is_empty(),
"Issue '{}' on {:?} should have a non-empty message",
issue.code,
platform
);
assert!(
!issue.code.is_empty(),
"Found an issue with an empty code on {:?}",
platform
);
}
}
}
#[test]
fn setup_issue_serde_roundtrip() {
let issue = SetupIssue {
code: "test_code".into(),
message: "Test message".into(),
platform: Platform::Android,
fix: Some("Do something".into()),
};
let json = serde_json::to_string(&issue).unwrap();
let de: SetupIssue = serde_json::from_str(&json).unwrap();
assert_eq!(de.code, "test_code");
assert_eq!(de.message, "Test message");
assert_eq!(de.platform, Platform::Android);
assert_eq!(de.fix, Some("Do something".into()));
}
#[test]
fn setup_issue_json_keys_camel_case() {
let issue = SetupIssue {
code: "c".into(),
message: "m".into(),
platform: Platform::Linux,
fix: Some("f".into()),
};
let json = serde_json::to_string(&issue).unwrap();
assert!(json.contains("\"code\":"), "{json}");
assert!(json.contains("\"message\":"), "{json}");
assert!(json.contains("\"platform\":"), "{json}");
assert!(json.contains("\"fix\":"), "{json}");
}
#[test]
fn setup_issue_fix_absent_when_none() {
let issue = SetupIssue {
code: "c".into(),
message: "m".into(),
platform: Platform::Linux,
fix: None,
};
let json = serde_json::to_string(&issue).unwrap();
assert!(
!json.contains("\"fix\""),
"fix should be absent when None: {json}"
);
}
#[test]
fn setup_validation_report_serde_roundtrip() {
let report = SetupValidationReport {
ok: true,
errors: vec![],
warnings: vec![SetupIssue {
code: "w1".into(),
message: "Warning 1".into(),
platform: Platform::Android,
fix: Some("Fix it".into()),
}],
issues: vec![],
};
let json = serde_json::to_string(&report).unwrap();
let de: SetupValidationReport = serde_json::from_str(&json).unwrap();
assert!(de.ok);
assert!(de.errors.is_empty());
assert_eq!(de.warnings.len(), 1);
assert_eq!(de.warnings[0].code, "w1");
}
#[test]
fn setup_validation_report_json_keys_camel_case() {
let report = SetupValidationReport {
ok: false,
errors: vec![SetupIssue {
code: "e1".into(),
message: "Error".into(),
platform: Platform::Ios,
fix: None,
}],
warnings: vec![],
issues: vec![],
};
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("\"ok\":"), "{json}");
assert!(json.contains("\"errors\":"), "{json}");
assert!(json.contains("\"warnings\":"), "{json}");
}
#[test]
fn setup_validation_report_ok_true_when_no_errors() {
let report = SetupValidationReport {
ok: true,
errors: vec![],
warnings: vec![SetupIssue {
code: "w".into(),
message: "warn".into(),
platform: Platform::Linux,
fix: None,
}],
issues: vec![],
};
assert!(report.ok);
}
#[test]
fn setup_validation_report_ok_false_with_errors() {
let report = SetupValidationReport {
ok: false,
errors: vec![SetupIssue {
code: "e".into(),
message: "err".into(),
platform: Platform::Linux,
fix: None,
}],
warnings: vec![],
issues: vec![],
};
assert!(!report.ok);
}
#[cfg(all(feature = "desktop-service", target_os = "linux"))]
#[test]
fn which_exists_true_for_ls() {
assert!(which_exists("ls"), "ls should exist in PATH");
}
#[cfg(all(feature = "desktop-service", target_os = "linux"))]
#[test]
fn which_exists_false_for_nonsense() {
assert!(
!which_exists("definitely_not_a_real_command_xyz_123"),
"nonsense command should not exist"
);
}
#[test]
fn android_error_prevents_ok() {
let report = SetupValidationReport {
ok: false,
errors: vec![SetupIssue {
code: "test_error".into(),
message: "test".into(),
platform: Platform::Android,
fix: None,
}],
warnings: vec![],
issues: vec![],
};
assert!(!report.ok);
assert_eq!(report.errors.len(), 1);
}
#[test]
fn warnings_do_not_affect_ok() {
let report = SetupValidationReport {
ok: true,
errors: vec![],
warnings: vec![SetupIssue {
code: "w".into(),
message: "just a warning".into(),
platform: Platform::Linux,
fix: None,
}],
issues: vec![],
};
assert!(report.ok);
assert!(!report.warnings.is_empty());
}
#[test]
fn setup_issue_to_validation_issue_error_severity() {
let issue = SetupIssue {
code: "test".into(),
message: "msg".into(),
platform: Platform::Linux,
fix: Some("fix it".into()),
};
let vi = issue.to_validation_issue(Severity::Error);
assert_eq!(vi.severity, Severity::Error);
assert_eq!(vi.code, "test");
assert_eq!(vi.message, "msg");
assert_eq!(vi.platform, Platform::Linux);
assert_eq!(vi.fix, Some("fix it".into()));
}
#[test]
fn setup_issue_to_validation_issue_warning_severity() {
let issue = SetupIssue {
code: "w".into(),
message: "warn msg".into(),
platform: Platform::Android,
fix: None,
};
let vi = issue.to_validation_issue(Severity::Warning);
assert_eq!(vi.severity, Severity::Warning);
assert_eq!(vi.code, "w");
assert!(vi.fix.is_none());
}
#[test]
fn android_issues_populated_with_warning_severity() {
let report = SetupValidator::validate(Platform::Android);
assert!(
!report.issues.is_empty(),
"Android should have structured issues"
);
assert_eq!(
report.issues.len(),
report.warnings.len(),
"issues count should match warnings count (no errors on Android)"
);
for vi in &report.issues {
assert_eq!(
vi.severity,
Severity::Warning,
"All Android issues should be warnings: {:?}",
vi.code
);
}
}
#[test]
fn ios_issues_populated_with_warning_severity() {
let report = SetupValidator::validate(Platform::Ios);
assert!(
!report.issues.is_empty(),
"iOS should have structured issues"
);
assert_eq!(
report.issues.len(),
report.warnings.len(),
"issues count should match warnings count (no errors on iOS)"
);
for vi in &report.issues {
assert_eq!(
vi.severity,
Severity::Warning,
"All iOS issues should be warnings: {:?}",
vi.code
);
}
}
#[test]
fn windows_issues_empty() {
let report = SetupValidator::validate(Platform::Windows);
assert!(report.issues.is_empty(), "Windows has no validation issues");
}
#[test]
fn unknown_issues_empty() {
let report = SetupValidator::validate(Platform::Unknown);
assert!(report.issues.is_empty(), "Unknown has no validation issues");
}
#[test]
fn desktop_issues_include_errors_and_warnings() {
let report = SetupValidator::validate(Platform::Linux);
let error_count = report
.issues
.iter()
.filter(|vi| vi.severity == Severity::Error)
.count();
let warning_count = report
.issues
.iter()
.filter(|vi| vi.severity == Severity::Warning)
.count();
assert_eq!(
error_count,
report.errors.len(),
"Error issues should match errors count"
);
assert_eq!(
warning_count,
report.warnings.len(),
"Warning issues should match warnings count"
);
assert_eq!(
report.issues.len(),
report.errors.len() + report.warnings.len(),
"Total issues = errors + warnings"
);
}
#[test]
fn all_platforms_issues_match_errors_plus_warnings() {
for platform in [
Platform::Android,
Platform::Ios,
Platform::Linux,
Platform::Macos,
Platform::Windows,
] {
let report = SetupValidator::validate(platform);
assert_eq!(
report.issues.len(),
report.errors.len() + report.warnings.len(),
"issues count should equal errors + warnings for {:?}",
platform
);
}
}
#[test]
fn issues_preserve_codes_from_errors_and_warnings() {
let report = SetupValidator::validate(Platform::Android);
let error_codes: Vec<&str> = report.errors.iter().map(|e| e.code.as_str()).collect();
let warning_codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
let issue_codes: Vec<&str> = report.issues.iter().map(|i| i.code.as_str()).collect();
for code in error_codes.iter().chain(warning_codes.iter()) {
assert!(
issue_codes.contains(code),
"issues should contain code '{}'",
code
);
}
}
#[test]
fn validation_issue_serde_roundtrip() {
let vi = ValidationIssue {
severity: Severity::Error,
code: "test_code".into(),
message: "test message".into(),
fix: Some("fix it".into()),
platform: Platform::Linux,
};
let json = serde_json::to_string(&vi).unwrap();
let de: ValidationIssue = serde_json::from_str(&json).unwrap();
assert_eq!(de.severity, Severity::Error);
assert_eq!(de.code, "test_code");
assert_eq!(de.message, "test message");
}
#[test]
fn report_issues_default_empty_on_deserialize() {
let json = r#"{"ok":true,"errors":[],"warnings":[]}"#;
let de: SetupValidationReport = serde_json::from_str(json).unwrap();
assert!(de.issues.is_empty(), "issues should default to empty");
}
}