Skip to main content

sonos_cli/
diagnostics.rs

1//! Platform-specific diagnostics for discovery failures.
2
3use std::io::IsTerminal;
4
5use crate::cli::GlobalFlags;
6
7/// Returns a platform-specific hint for when SSDP discovery finds no speakers.
8pub fn discovery_hint() -> &'static str {
9    if cfg!(target_os = "macos") {
10        "hint: this could mean:\n\
11         \x20 - no Sonos speakers are on this network\n\
12         \x20 - your terminal lacks Local Network access\n\
13         \x20   (System Settings > Privacy & Security > Local Network)\n\
14         \x20 - a firewall is blocking UDP multicast on port 1900"
15    } else if cfg!(target_os = "windows") {
16        "hint: this could mean:\n\
17         \x20 - no Sonos speakers are on this network\n\
18         \x20 - Network Discovery is disabled or your firewall is\n\
19         \x20   blocking UDP traffic on port 1900 (SSDP)"
20    } else {
21        "hint: this could mean:\n\
22         \x20 - no Sonos speakers are on this network\n\
23         \x20 - a firewall is blocking UDP multicast on port 1900\n\
24         \x20   (ufw: sudo ufw allow proto udp from any to 239.255.255.250 port 1900)"
25    }
26}
27
28/// On macOS/Windows, prompts the user to open the relevant settings pane.
29///
30/// Respects `--no-input` and TTY detection. Does nothing on Linux or when
31/// stdin is not a terminal.
32pub fn offer_open_settings(global: &GlobalFlags) {
33    if !can_prompt(global) {
34        return;
35    }
36
37    let (cmd, args, fallback) = if cfg!(target_os = "macos") {
38        (
39            "open",
40            "x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork",
41            "System Settings > Privacy & Security > Local Network",
42        )
43    } else if cfg!(target_os = "windows") {
44        (
45            "cmd",
46            "/C start ms-settings:privacy-localnetwork",
47            "Settings > Privacy & Security > Local Network",
48        )
49    } else {
50        return;
51    };
52
53    eprintln!();
54    eprint!("Open settings? [Y/n] ");
55
56    let mut input = String::new();
57    if std::io::stdin().read_line(&mut input).is_err() {
58        return;
59    }
60
61    let answer = input.trim();
62    if answer.is_empty() || answer.eq_ignore_ascii_case("y") {
63        let result = if cfg!(target_os = "windows") {
64            std::process::Command::new(cmd)
65                .args(args.split_whitespace())
66                .spawn()
67        } else {
68            std::process::Command::new(cmd).arg(args).spawn()
69        };
70
71        if result.is_err() {
72            eprintln!("Could not open settings. Navigate to {fallback} manually.");
73        }
74    }
75}
76
77fn can_prompt(global: &GlobalFlags) -> bool {
78    std::io::stdin().is_terminal() && !global.no_input
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn discovery_hint_lists_possible_causes() {
87        let hint = discovery_hint();
88        assert!(hint.starts_with("hint: this could mean:"));
89        assert!(hint.contains("no Sonos speakers are on this network"));
90    }
91
92    #[test]
93    #[cfg(target_os = "macos")]
94    fn discovery_hint_macos_mentions_local_network() {
95        let hint = discovery_hint();
96        assert!(hint.contains("Local Network"));
97        assert!(hint.contains("System Settings"));
98    }
99
100    #[test]
101    #[cfg(target_os = "windows")]
102    fn discovery_hint_windows_mentions_firewall() {
103        let hint = discovery_hint();
104        assert!(hint.contains("Network Discovery"));
105    }
106
107    #[test]
108    #[cfg(target_os = "linux")]
109    fn discovery_hint_linux_mentions_ufw() {
110        let hint = discovery_hint();
111        assert!(hint.contains("ufw"));
112        assert!(hint.contains("239.255.255.250"));
113    }
114
115    #[test]
116    fn no_input_flag_prevents_prompt() {
117        let global = GlobalFlags {
118            speaker: None,
119            group: None,
120            quiet: false,
121            verbose: 0,
122            no_input: true,
123        };
124        assert!(!can_prompt(&global));
125    }
126
127    #[test]
128    fn discovery_failed_triggers_platform_hint() {
129        // SdkError::DiscoveryFailed is the variant that should route to
130        // platform-specific diagnostics in main.rs
131        let err =
132            sonos_sdk::SdkError::DiscoveryFailed("no Sonos devices found on the network".into());
133        assert!(matches!(err, sonos_sdk::SdkError::DiscoveryFailed(_)));
134
135        // And the hint it would display is our platform-specific one
136        let hint = discovery_hint();
137        assert!(hint.starts_with("hint:"));
138    }
139
140    #[test]
141    fn speaker_not_found_routes_to_platform_hint() {
142        // When resolve functions return "no speakers available", the
143        // recovery_hint() should return the platform-specific diagnostic
144        use crate::errors::CliError;
145        let err = CliError::SpeakerNotFound("no speakers available".into());
146        let hint = err.recovery_hint().expect("should have a recovery hint");
147        assert_eq!(hint, discovery_hint());
148    }
149
150    #[test]
151    fn group_not_found_routes_to_platform_hint() {
152        use crate::errors::CliError;
153        let err = CliError::GroupNotFound("no groups available".into());
154        let hint = err.recovery_hint().expect("should have a recovery hint");
155        assert_eq!(hint, discovery_hint());
156    }
157
158    #[test]
159    fn sdk_discovery_failed_uses_platform_hint() {
160        use crate::errors::CliError;
161        let err = CliError::Sdk(sonos_sdk::SdkError::DiscoveryFailed("test".into()));
162        let hint = err.recovery_hint().unwrap();
163        assert_eq!(hint, discovery_hint());
164    }
165
166    #[test]
167    fn sdk_non_network_error_uses_generic_hint() {
168        // Non-network SDK errors (e.g. lock poisoned) should get a
169        // generic hint, not the platform-specific one
170        use crate::errors::CliError;
171        let err = CliError::Sdk(sonos_sdk::SdkError::LockPoisoned);
172        let hint = err.recovery_hint().unwrap();
173        assert_ne!(
174            hint,
175            discovery_hint(),
176            "non-network SDK errors should use generic hint"
177        );
178    }
179}