Skip to main content

sonos_cli/cli/
resolve.rs

1use sonos_sdk::{Group, SonosSystem, Speaker};
2
3use crate::cli::GlobalFlags;
4use crate::config::Config;
5use crate::errors::CliError;
6
7/// Resolve --speaker / --group flags to a Speaker handle.
8///
9/// Priority: --group wins over --speaker. If neither is given, uses config default
10/// or falls back to the first group's coordinator.
11pub fn resolve_speaker(
12    system: &SonosSystem,
13    config: &Config,
14    global: &GlobalFlags,
15) -> Result<Speaker, CliError> {
16    // --group wins over --speaker
17    if let Some(group_name) = &global.group {
18        let g = system
19            .group(group_name)
20            .ok_or_else(|| CliError::GroupNotFound(group_name.to_string()))?;
21        return g
22            .coordinator()
23            .ok_or_else(|| CliError::GroupNotFound(group_name.to_string()));
24    }
25
26    if let Some(speaker_name) = &global.speaker {
27        return system
28            .speaker(speaker_name)
29            .ok_or_else(|| CliError::SpeakerNotFound(speaker_name.to_string()));
30    }
31
32    // Default: config group → first group coordinator → first speaker
33    if let Some(default_group) = &config.default_group {
34        if let Some(g) = system.group(default_group) {
35            if let Some(coordinator) = g.coordinator() {
36                return Ok(coordinator);
37            }
38        }
39    }
40
41    // Prefer a group coordinator so we get track/position data
42    // (non-coordinator speakers return NOT_IMPLEMENTED for these)
43    if let Some(coordinator) = system
44        .groups()
45        .into_iter()
46        .next()
47        .and_then(|g| g.coordinator())
48    {
49        return Ok(coordinator);
50    }
51
52    // Last resort: first speaker (standalone, no groups)
53    system
54        .speakers()
55        .into_iter()
56        .next()
57        .ok_or_else(|| CliError::SpeakerNotFound("no speakers available".to_string()))
58}
59
60/// Resolve --group / --speaker flags to a Group handle.
61///
62/// Priority: --group wins. If neither flag is given, uses config default
63/// or falls back to the first available group.
64pub fn resolve_group(
65    system: &SonosSystem,
66    config: &Config,
67    global: &GlobalFlags,
68) -> Result<Group, CliError> {
69    if let Some(group_name) = &global.group {
70        return system
71            .group(group_name)
72            .ok_or_else(|| CliError::GroupNotFound(group_name.to_string()));
73    }
74
75    // Default: config group → first group
76    if let Some(default_group) = &config.default_group {
77        if let Some(g) = system.group(default_group) {
78            return Ok(g);
79        }
80    }
81
82    system
83        .groups()
84        .into_iter()
85        .next()
86        .ok_or_else(|| CliError::GroupNotFound("no groups available".to_string()))
87}
88
89/// Resolve --speaker flag for speaker-only commands (bass, treble, loudness).
90/// Rejects --group with a validation error.
91pub fn require_speaker_only(
92    system: &SonosSystem,
93    global: &GlobalFlags,
94    command_name: &str,
95) -> Result<Speaker, CliError> {
96    if global.group.is_some() {
97        return Err(CliError::Validation(format!(
98            "--speaker is required for {command_name}"
99        )));
100    }
101    let name = global
102        .speaker
103        .as_deref()
104        .ok_or_else(|| CliError::Validation(format!("--speaker is required for {command_name}")))?;
105    system
106        .speaker(name)
107        .ok_or_else(|| CliError::SpeakerNotFound(name.to_string()))
108}
109
110#[cfg(all(test, feature = "test-helpers"))]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn resolve_speaker_by_name() {
116        let system = SonosSystem::with_speakers(&["Kitchen"]);
117        let config = Config::default();
118        let global = GlobalFlags {
119            speaker: Some("Kitchen".into()),
120            group: None,
121            quiet: false,
122            verbose: 0,
123            no_input: false,
124        };
125        let spk = resolve_speaker(&system, &config, &global).unwrap();
126        assert_eq!(spk.name, "Kitchen");
127    }
128
129    #[test]
130    fn resolve_speaker_not_found() {
131        let system = SonosSystem::with_speakers(&["Kitchen"]);
132        let config = Config::default();
133        let global = GlobalFlags {
134            speaker: Some("Nonexistent".into()),
135            group: None,
136            quiet: false,
137            verbose: 0,
138            no_input: false,
139        };
140        let result = resolve_speaker(&system, &config, &global);
141        assert!(matches!(result, Err(CliError::SpeakerNotFound(_))));
142    }
143
144    #[test]
145    fn resolve_speaker_falls_back_to_first() {
146        let system = SonosSystem::with_speakers(&["Kitchen"]);
147        let config = Config::default();
148        let global = GlobalFlags {
149            speaker: None,
150            group: None,
151            quiet: false,
152            verbose: 0,
153            no_input: false,
154        };
155        let spk = resolve_speaker(&system, &config, &global).unwrap();
156        assert_eq!(spk.name, "Kitchen");
157    }
158
159    #[test]
160    fn resolve_speaker_prefers_group_coordinator() {
161        let system = SonosSystem::with_groups(&["Kitchen", "Bedroom"]);
162        let config = Config::default();
163        let global = GlobalFlags {
164            speaker: None,
165            group: None,
166            quiet: false,
167            verbose: 0,
168            no_input: false,
169        };
170        let spk = resolve_speaker(&system, &config, &global).unwrap();
171        // Should pick the first group's coordinator, not an arbitrary speaker
172        let first_group = system.groups().into_iter().next().unwrap();
173        let expected_coordinator = first_group.coordinator().unwrap();
174        assert_eq!(spk.name, expected_coordinator.name);
175    }
176
177    #[test]
178    fn resolve_speaker_empty_system_fails() {
179        let system = SonosSystem::with_speakers(&[]);
180        let config = Config::default();
181        let global = GlobalFlags {
182            speaker: None,
183            group: None,
184            quiet: false,
185            verbose: 0,
186            no_input: false,
187        };
188        let result = resolve_speaker(&system, &config, &global);
189        assert!(result.is_err());
190    }
191
192    #[test]
193    fn require_speaker_only_rejects_group() {
194        let system = SonosSystem::with_speakers(&["Kitchen"]);
195        let global = GlobalFlags {
196            speaker: None,
197            group: Some("Living Room".into()),
198            quiet: false,
199            verbose: 0,
200            no_input: false,
201        };
202        let result = require_speaker_only(&system, &global, "bass");
203        assert!(matches!(result, Err(CliError::Validation(_))));
204    }
205
206    #[test]
207    fn require_speaker_only_requires_speaker_flag() {
208        let system = SonosSystem::with_speakers(&["Kitchen"]);
209        let global = GlobalFlags {
210            speaker: None,
211            group: None,
212            quiet: false,
213            verbose: 0,
214            no_input: false,
215        };
216        let result = require_speaker_only(&system, &global, "bass");
217        assert!(matches!(result, Err(CliError::Validation(_))));
218    }
219
220    #[test]
221    fn require_speaker_only_finds_speaker() {
222        let system = SonosSystem::with_speakers(&["Kitchen"]);
223        let global = GlobalFlags {
224            speaker: Some("Kitchen".into()),
225            group: None,
226            quiet: false,
227            verbose: 0,
228            no_input: false,
229        };
230        let spk = require_speaker_only(&system, &global, "bass").unwrap();
231        assert_eq!(spk.name, "Kitchen");
232    }
233
234    #[test]
235    fn resolve_group_by_name() {
236        let system = SonosSystem::with_groups(&["Kitchen", "Bedroom"]);
237        let config = Config::default();
238        let global = GlobalFlags {
239            speaker: None,
240            group: Some("Kitchen".into()),
241            quiet: false,
242            verbose: 0,
243            no_input: false,
244        };
245        let grp = resolve_group(&system, &config, &global).unwrap();
246        let coord = grp.coordinator().unwrap();
247        assert_eq!(coord.name, "Kitchen");
248    }
249
250    #[test]
251    fn resolve_group_not_found() {
252        let system = SonosSystem::with_groups(&["Kitchen"]);
253        let config = Config::default();
254        let global = GlobalFlags {
255            speaker: None,
256            group: Some("Nonexistent".into()),
257            quiet: false,
258            verbose: 0,
259            no_input: false,
260        };
261        let result = resolve_group(&system, &config, &global);
262        assert!(matches!(result, Err(CliError::GroupNotFound(_))));
263    }
264
265    #[test]
266    fn resolve_group_falls_back_to_first() {
267        let system = SonosSystem::with_groups(&["Kitchen"]);
268        let config = Config::default();
269        let global = GlobalFlags {
270            speaker: None,
271            group: None,
272            quiet: false,
273            verbose: 0,
274            no_input: false,
275        };
276        let grp = resolve_group(&system, &config, &global).unwrap();
277        let coord = grp.coordinator().unwrap();
278        assert_eq!(coord.name, "Kitchen");
279    }
280
281    #[test]
282    fn resolve_group_uses_config_default() {
283        let system = SonosSystem::with_groups(&["Kitchen", "Bedroom"]);
284        let config = Config {
285            default_group: Some("Bedroom".into()),
286            ..Config::default()
287        };
288        let global = GlobalFlags {
289            speaker: None,
290            group: None,
291            quiet: false,
292            verbose: 0,
293            no_input: false,
294        };
295        let grp = resolve_group(&system, &config, &global).unwrap();
296        let coord = grp.coordinator().unwrap();
297        assert_eq!(coord.name, "Bedroom");
298    }
299
300    #[test]
301    fn resolve_group_empty_system_fails() {
302        let system = SonosSystem::with_groups(&[]);
303        let config = Config::default();
304        let global = GlobalFlags {
305            speaker: None,
306            group: None,
307            quiet: false,
308            verbose: 0,
309            no_input: false,
310        };
311        let result = resolve_group(&system, &config, &global);
312        assert!(matches!(result, Err(CliError::GroupNotFound(_))));
313    }
314
315    #[test]
316    fn resolve_group_flag_wins_over_speaker() {
317        let system = SonosSystem::with_groups(&["Kitchen", "Bedroom"]);
318        let config = Config::default();
319        let global = GlobalFlags {
320            speaker: Some("Bedroom".into()),
321            group: Some("Kitchen".into()),
322            quiet: false,
323            verbose: 0,
324            no_input: false,
325        };
326        let grp = resolve_group(&system, &config, &global).unwrap();
327        let coord = grp.coordinator().unwrap();
328        assert_eq!(coord.name, "Kitchen");
329    }
330}