cider_api/client.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Async HTTP client for the Cider REST API.
6
7use std::time::Duration;
8
9use reqwest::Client;
10use thiserror::Error;
11use tracing::{debug, instrument, warn};
12
13use crate::types::{
14 AmApiRequest, ApiResponse, AutoplayResponse, IsPlayingResponse, NowPlaying,
15 NowPlayingResponse, PlayItemHrefRequest, PlayItemRequest, PlayUrlRequest, QueueItem,
16 QueueMoveRequest, QueueRemoveRequest, RatingRequest, RepeatModeResponse, SeekRequest,
17 ShuffleModeResponse, VolumeRequest, VolumeResponse,
18};
19
20/// Default Cider RPC port.
21pub const DEFAULT_PORT: u16 = 10767;
22
23/// Connection timeout — short because the server is localhost.
24const CONNECTION_TIMEOUT: Duration = Duration::from_secs(1);
25
26/// Per-request timeout.
27const REQUEST_TIMEOUT: Duration = Duration::from_secs(2);
28
29/// Errors returned by [`CiderClient`] methods.
30///
31/// # Examples
32///
33/// ```no_run
34/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
35/// use cider_api::{CiderClient, CiderError};
36///
37/// let client = CiderClient::new();
38/// match client.is_active().await {
39/// Ok(()) => println!("Cider is running"),
40/// Err(CiderError::Unauthorized) => println!("Bad API token"),
41/// Err(CiderError::Http(e)) if e.is_connect() => println!("Cider not running"),
42/// Err(e) => println!("Error: {e}"),
43/// }
44/// # Ok(())
45/// # }
46/// ```
47#[derive(Debug, Error)]
48pub enum CiderError {
49 /// An HTTP-level error from [`reqwest`].
50 #[error("HTTP request failed: {0}")]
51 Http(#[from] reqwest::Error),
52
53 /// Cider is not running or the port is unreachable.
54 #[error("Cider is not running or not reachable")]
55 NotReachable,
56
57 /// The API token was rejected (HTTP 401/403).
58 #[error("Invalid API token")]
59 Unauthorized,
60
61 /// No track is currently loaded.
62 #[error("No track currently playing")]
63 NothingPlaying,
64
65 /// Catch-all for unexpected API responses.
66 #[error("API error: {0}")]
67 Api(String),
68}
69
70/// Async client for the [Cider](https://cider.sh) music player REST API.
71///
72/// Communicates with Cider's local HTTP server (default `http://127.0.0.1:10767`)
73/// to control playback, manage the queue, and query track information.
74///
75/// # Construction
76///
77/// ```
78/// use cider_api::CiderClient;
79///
80/// // Default (localhost:10767, no auth)
81/// let client = CiderClient::new();
82///
83/// // Custom port
84/// let client = CiderClient::with_port(9999);
85///
86/// // With authentication
87/// let client = CiderClient::new().with_token("my-token");
88/// ```
89///
90/// The client is cheaply [`Clone`]able — it shares an inner connection pool.
91///
92/// # Errors
93///
94/// All async methods return `Result<_, CiderError>`. Common error cases:
95///
96/// - [`CiderError::Http`] — network or connection failure.
97/// - [`CiderError::Unauthorized`] — invalid API token (HTTP 401/403).
98/// - [`CiderError::Api`] — unexpected response from Cider.
99#[derive(Debug, Clone)]
100pub struct CiderClient {
101 http: Client,
102 base_url: String,
103 api_token: Option<String>,
104}
105
106impl CiderClient {
107 /// Create a new client targeting `http://127.0.0.1:10767`.
108 #[must_use]
109 pub fn new() -> Self {
110 Self::with_port(DEFAULT_PORT)
111 }
112
113 /// Create a new client targeting `http://127.0.0.1:{port}`.
114 ///
115 /// # Panics
116 ///
117 /// Panics if the underlying HTTP client cannot be constructed (only
118 /// possible if TLS initialisation fails at the OS level).
119 #[must_use]
120 pub fn with_port(port: u16) -> Self {
121 let http = Client::builder()
122 .connect_timeout(CONNECTION_TIMEOUT)
123 .timeout(REQUEST_TIMEOUT)
124 .pool_max_idle_per_host(2)
125 .pool_idle_timeout(Duration::from_secs(10))
126 .tcp_keepalive(None)
127 .build()
128 .expect("Failed to build HTTP client");
129
130 Self {
131 http,
132 base_url: format!("http://127.0.0.1:{port}"),
133 api_token: None,
134 }
135 }
136
137 /// Attach an API token for authentication.
138 ///
139 /// The token is sent in the `apitoken` header on every request.
140 /// Generate one in Cider under **Settings > Connectivity > Manage External
141 /// Application Access**.
142 #[must_use]
143 pub fn with_token(mut self, token: impl Into<String>) -> Self {
144 self.api_token = Some(token.into());
145 self
146 }
147
148 // ── Internal helpers ─────────────────────────────────────────────────
149
150 /// Build a request under `/api/v1/playback`.
151 fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
152 let url = format!("{}/api/v1/playback{}", self.base_url, path);
153 let mut req = self.http.request(method, &url);
154 if let Some(token) = &self.api_token {
155 req = req.header("apitoken", token);
156 }
157 req
158 }
159
160 /// Build a request under an arbitrary API path.
161 fn request_raw(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
162 let url = format!("{}{}", self.base_url, path);
163 let mut req = self.http.request(method, &url);
164 if let Some(token) = &self.api_token {
165 req = req.header("apitoken", token);
166 }
167 req
168 }
169
170 // ── Status ───────────────────────────────────────────────────────────
171
172 /// Check that Cider is running and the RPC server is reachable.
173 ///
174 /// Sends `GET /active` — Cider responds with `204 No Content` if alive.
175 ///
176 /// # Errors
177 ///
178 /// - [`CiderError::Unauthorized`] if the token is wrong.
179 /// - [`CiderError::Api`] if the connection is refused or times out.
180 #[instrument(skip(self), fields(base_url = %self.base_url))]
181 pub async fn is_active(&self) -> Result<(), CiderError> {
182 debug!("Checking Cider connection");
183
184 let resp = self
185 .request(reqwest::Method::GET, "/active")
186 .send()
187 .await
188 .map_err(|e| {
189 warn!("Connection error: {e:?}");
190 if e.is_connect() {
191 CiderError::Api(format!("Connection refused ({e})"))
192 } else if e.is_timeout() {
193 CiderError::Api("Connection timed out".to_string())
194 } else {
195 CiderError::Api(format!("Network error ({e})"))
196 }
197 })?;
198
199 debug!("Response status: {}", resp.status());
200
201 match resp.status().as_u16() {
202 200 | 204 => Ok(()),
203 401 | 403 => Err(CiderError::Unauthorized),
204 _ => Err(CiderError::Api(format!(
205 "Unexpected response (HTTP {})",
206 resp.status().as_u16()
207 ))),
208 }
209 }
210
211 /// Check whether music is currently playing.
212 ///
213 /// Sends `GET /is-playing`.
214 ///
215 /// # Errors
216 ///
217 /// Returns [`CiderError`] if the request fails or the response cannot be parsed.
218 pub async fn is_playing(&self) -> Result<bool, CiderError> {
219 let resp: ApiResponse<IsPlayingResponse> = self
220 .request(reqwest::Method::GET, "/is-playing")
221 .send()
222 .await?
223 .json()
224 .await?;
225
226 Ok(resp.data.is_playing)
227 }
228
229 /// Get the currently playing track.
230 ///
231 /// Returns `None` if nothing is loaded. The returned [`NowPlaying`] includes
232 /// both Apple Music catalog metadata and live playback state
233 /// (`current_playback_time`, `remaining_time`, etc.).
234 ///
235 /// Sends `GET /now-playing`.
236 ///
237 /// # Errors
238 ///
239 /// Returns [`CiderError`] on network failure. Returns `Ok(None)` (not an
240 /// error) if nothing is playing or the response cannot be parsed.
241 pub async fn now_playing(&self) -> Result<Option<NowPlaying>, CiderError> {
242 let resp = self
243 .request(reqwest::Method::GET, "/now-playing")
244 .send()
245 .await?;
246
247 if resp.status() == 404 || resp.status() == 204 {
248 return Ok(None);
249 }
250
251 match resp.json::<ApiResponse<NowPlayingResponse>>().await {
252 Ok(data) => Ok(Some(data.data.info)),
253 Err(_) => Ok(None),
254 }
255 }
256
257 // ── Playback control ─────────────────────────────────────────────────
258
259 /// Resume playback.
260 ///
261 /// If nothing is loaded, the behaviour set under
262 /// **Settings > Play Button on Stopped Action** takes effect.
263 ///
264 /// # Errors
265 ///
266 /// Returns [`CiderError`] if the request fails or the server rejects it.
267 pub async fn play(&self) -> Result<(), CiderError> {
268 self.request(reqwest::Method::POST, "/play")
269 .send()
270 .await?
271 .error_for_status()?;
272 Ok(())
273 }
274
275 /// Pause the current track. No-op if already paused or nothing is playing.
276 ///
277 /// # Errors
278 ///
279 /// Returns [`CiderError`] if the request fails or the server rejects it.
280 pub async fn pause(&self) -> Result<(), CiderError> {
281 self.request(reqwest::Method::POST, "/pause")
282 .send()
283 .await?
284 .error_for_status()?;
285 Ok(())
286 }
287
288 /// Toggle between playing and paused.
289 ///
290 /// # Errors
291 ///
292 /// Returns [`CiderError`] if the request fails or the server rejects it.
293 pub async fn play_pause(&self) -> Result<(), CiderError> {
294 self.request(reqwest::Method::POST, "/playpause")
295 .send()
296 .await?
297 .error_for_status()?;
298 Ok(())
299 }
300
301 /// Stop playback and unload the current track. Queue items are kept.
302 ///
303 /// # Errors
304 ///
305 /// Returns [`CiderError`] if the request fails or the server rejects it.
306 pub async fn stop(&self) -> Result<(), CiderError> {
307 self.request(reqwest::Method::POST, "/stop")
308 .send()
309 .await?
310 .error_for_status()?;
311 Ok(())
312 }
313
314 /// Skip to the next track in the queue.
315 ///
316 /// Respects autoplay status if the queue is empty.
317 ///
318 /// # Errors
319 ///
320 /// Returns [`CiderError`] if the request fails or the server rejects it.
321 pub async fn next(&self) -> Result<(), CiderError> {
322 self.request(reqwest::Method::POST, "/next")
323 .send()
324 .await?
325 .error_for_status()?;
326 Ok(())
327 }
328
329 /// Go back to the previously played track (from playback history).
330 ///
331 /// # Errors
332 ///
333 /// Returns [`CiderError`] if the request fails or the server rejects it.
334 pub async fn previous(&self) -> Result<(), CiderError> {
335 self.request(reqwest::Method::POST, "/previous")
336 .send()
337 .await?
338 .error_for_status()?;
339 Ok(())
340 }
341
342 /// Seek to a position in the current track.
343 ///
344 /// # Arguments
345 ///
346 /// * `position_secs` — target offset in **seconds** (e.g. `30.0`).
347 ///
348 /// # Errors
349 ///
350 /// Returns [`CiderError`] if the request fails or the server rejects it.
351 pub async fn seek(&self, position_secs: f64) -> Result<(), CiderError> {
352 self.request(reqwest::Method::POST, "/seek")
353 .json(&SeekRequest {
354 position: position_secs,
355 })
356 .send()
357 .await?
358 .error_for_status()?;
359 Ok(())
360 }
361
362 /// Convenience wrapper for [`seek`](Self::seek) that accepts milliseconds.
363 ///
364 /// # Errors
365 ///
366 /// Returns [`CiderError`] if the request fails or the server rejects it.
367 pub async fn seek_ms(&self, position_ms: u64) -> Result<(), CiderError> {
368 #[allow(clippy::cast_precision_loss)] // ms precision loss only above ~143 million years
369 let secs = position_ms as f64 / 1000.0;
370 self.seek(secs).await
371 }
372
373 // ── Play items ───────────────────────────────────────────────────────
374
375 /// Start playback of an Apple Music URL.
376 ///
377 /// The URL can be obtained from **Share > Apple Music** in Cider or the
378 /// Apple Music web player.
379 ///
380 /// # Arguments
381 ///
382 /// * `url` — e.g. `"https://music.apple.com/ca/album/…/1719860281"`
383 ///
384 /// # Errors
385 ///
386 /// Returns [`CiderError`] if the request fails or the server rejects it.
387 pub async fn play_url(&self, url: &str) -> Result<(), CiderError> {
388 self.request(reqwest::Method::POST, "/play-url")
389 .json(&PlayUrlRequest {
390 url: url.to_string(),
391 })
392 .send()
393 .await?
394 .error_for_status()?;
395 Ok(())
396 }
397
398 /// Start playback of an item by Apple Music type and catalog ID.
399 ///
400 /// # Arguments
401 ///
402 /// * `item_type` — Apple Music type: `"songs"`, `"albums"`, `"playlists"`, etc.
403 /// * `id` — catalog ID as a **string** (e.g. `"1719861213"`).
404 ///
405 /// # Errors
406 ///
407 /// Returns [`CiderError`] if the request fails or the server rejects it.
408 pub async fn play_item(&self, item_type: &str, id: &str) -> Result<(), CiderError> {
409 self.request(reqwest::Method::POST, "/play-item")
410 .json(&PlayItemRequest {
411 item_type: item_type.to_string(),
412 id: id.to_string(),
413 })
414 .send()
415 .await?
416 .error_for_status()?;
417 Ok(())
418 }
419
420 /// Start playback of an item by its Apple Music API href.
421 ///
422 /// # Arguments
423 ///
424 /// * `href` — API path, e.g. `"/v1/catalog/ca/songs/1719861213"`.
425 ///
426 /// # Errors
427 ///
428 /// Returns [`CiderError`] if the request fails or the server rejects it.
429 pub async fn play_item_href(&self, href: &str) -> Result<(), CiderError> {
430 self.request(reqwest::Method::POST, "/play-item-href")
431 .json(&PlayItemHrefRequest {
432 href: href.to_string(),
433 })
434 .send()
435 .await?
436 .error_for_status()?;
437 Ok(())
438 }
439
440 /// Add an item to the **start** of the queue (plays next).
441 ///
442 /// # Arguments
443 ///
444 /// * `item_type` — `"songs"`, `"albums"`, etc.
445 /// * `id` — catalog ID as a string.
446 ///
447 /// # Errors
448 ///
449 /// Returns [`CiderError`] if the request fails or the server rejects it.
450 pub async fn play_next(&self, item_type: &str, id: &str) -> Result<(), CiderError> {
451 self.request(reqwest::Method::POST, "/play-next")
452 .json(&PlayItemRequest {
453 item_type: item_type.to_string(),
454 id: id.to_string(),
455 })
456 .send()
457 .await?
458 .error_for_status()?;
459 Ok(())
460 }
461
462 /// Add an item to the **end** of the queue (plays last).
463 ///
464 /// # Arguments
465 ///
466 /// * `item_type` — `"songs"`, `"albums"`, etc.
467 /// * `id` — catalog ID as a string.
468 ///
469 /// # Errors
470 ///
471 /// Returns [`CiderError`] if the request fails or the server rejects it.
472 pub async fn play_later(&self, item_type: &str, id: &str) -> Result<(), CiderError> {
473 self.request(reqwest::Method::POST, "/play-later")
474 .json(&PlayItemRequest {
475 item_type: item_type.to_string(),
476 id: id.to_string(),
477 })
478 .send()
479 .await?
480 .error_for_status()?;
481 Ok(())
482 }
483
484 // ── Queue ────────────────────────────────────────────────────────────
485
486 /// Get the current playback queue.
487 ///
488 /// Returns a [`Vec<QueueItem>`] that includes history items, the currently
489 /// playing track, and upcoming items. Use [`QueueItem::is_current`] to
490 /// find the active track.
491 ///
492 /// Returns an empty `Vec` if the queue is empty or the response format is
493 /// unexpected.
494 ///
495 /// # Errors
496 ///
497 /// Returns [`CiderError`] on network failure. Returns `Ok(vec![])` (not an
498 /// error) if the queue is empty or the format is unrecognised.
499 pub async fn get_queue(&self) -> Result<Vec<QueueItem>, CiderError> {
500 let resp = self
501 .request(reqwest::Method::GET, "/queue")
502 .send()
503 .await?;
504
505 let status = resp.status();
506 if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::NO_CONTENT {
507 return Ok(vec![]);
508 }
509
510 let text = resp.text().await?;
511 match serde_json::from_str::<Vec<QueueItem>>(&text) {
512 Ok(items) => Ok(items),
513 Err(_) => Ok(vec![]),
514 }
515 }
516
517 /// Move a queue item from one position to another.
518 ///
519 /// Both indices are **1-based**. The queue includes history items, so the
520 /// first visible "Up Next" item may not be at index 1.
521 ///
522 /// # Errors
523 ///
524 /// Returns [`CiderError`] if the request fails or the server rejects it.
525 pub async fn queue_move_to_position(
526 &self,
527 start_index: u32,
528 destination_index: u32,
529 ) -> Result<(), CiderError> {
530 self.request(reqwest::Method::POST, "/queue/move-to-position")
531 .json(&QueueMoveRequest {
532 start_index,
533 destination_index,
534 return_queue: None,
535 })
536 .send()
537 .await?
538 .error_for_status()?;
539 Ok(())
540 }
541
542 /// Remove a queue item by its **1-based** index.
543 ///
544 /// # Errors
545 ///
546 /// Returns [`CiderError`] if the request fails or the server rejects it.
547 pub async fn queue_remove_by_index(&self, index: u32) -> Result<(), CiderError> {
548 self.request(reqwest::Method::POST, "/queue/remove-by-index")
549 .json(&QueueRemoveRequest { index })
550 .send()
551 .await?
552 .error_for_status()?;
553 Ok(())
554 }
555
556 /// Clear all items from the queue.
557 ///
558 /// # Errors
559 ///
560 /// Returns [`CiderError`] if the request fails or the server rejects it.
561 pub async fn clear_queue(&self) -> Result<(), CiderError> {
562 self.request(reqwest::Method::POST, "/queue/clear-queue")
563 .send()
564 .await?
565 .error_for_status()?;
566 Ok(())
567 }
568
569 // ── Volume ───────────────────────────────────────────────────────────
570
571 /// Get the current volume (`0.0` = muted, `1.0` = full).
572 ///
573 /// # Errors
574 ///
575 /// Returns [`CiderError`] if the request fails or the response cannot be parsed.
576 pub async fn get_volume(&self) -> Result<f32, CiderError> {
577 let resp: ApiResponse<VolumeResponse> = self
578 .request(reqwest::Method::GET, "/volume")
579 .send()
580 .await?
581 .json()
582 .await?;
583
584 Ok(resp.data.volume)
585 }
586
587 /// Set the volume. Values are clamped to `0.0..=1.0`.
588 ///
589 /// # Errors
590 ///
591 /// Returns [`CiderError`] if the request fails or the server rejects it.
592 pub async fn set_volume(&self, volume: f32) -> Result<(), CiderError> {
593 self.request(reqwest::Method::POST, "/volume")
594 .json(&VolumeRequest {
595 volume: volume.clamp(0.0, 1.0),
596 })
597 .send()
598 .await?
599 .error_for_status()?;
600 Ok(())
601 }
602
603 // ── Library / ratings ────────────────────────────────────────────────
604
605 /// Add the currently playing track to the user's library.
606 ///
607 /// No-op if the track is already in the library.
608 ///
609 /// # Errors
610 ///
611 /// Returns [`CiderError`] if the request fails or the server rejects it.
612 pub async fn add_to_library(&self) -> Result<(), CiderError> {
613 self.request(reqwest::Method::POST, "/add-to-library")
614 .send()
615 .await?
616 .error_for_status()?;
617 Ok(())
618 }
619
620 /// Rate the currently playing track.
621 ///
622 /// * `-1` — dislike
623 /// * `0` — remove rating
624 /// * `1` — like
625 ///
626 /// The value is clamped to `-1..=1`.
627 ///
628 /// # Errors
629 ///
630 /// Returns [`CiderError`] if the request fails or the server rejects it.
631 pub async fn set_rating(&self, rating: i8) -> Result<(), CiderError> {
632 self.request(reqwest::Method::POST, "/set-rating")
633 .json(&RatingRequest {
634 rating: rating.clamp(-1, 1),
635 })
636 .send()
637 .await?
638 .error_for_status()?;
639 Ok(())
640 }
641
642 // ── Repeat / shuffle / autoplay ──────────────────────────────────────
643
644 /// Get the current repeat mode.
645 ///
646 /// * `0` — off
647 /// * `1` — repeat this song
648 /// * `2` — repeat all
649 ///
650 /// # Errors
651 ///
652 /// Returns [`CiderError`] if the request fails or the response cannot be parsed.
653 pub async fn get_repeat_mode(&self) -> Result<u8, CiderError> {
654 let resp: ApiResponse<RepeatModeResponse> = self
655 .request(reqwest::Method::GET, "/repeat-mode")
656 .send()
657 .await?
658 .json()
659 .await?;
660
661 Ok(resp.data.value)
662 }
663
664 /// Cycle repeat mode: **repeat one > repeat all > off**.
665 ///
666 /// # Errors
667 ///
668 /// Returns [`CiderError`] if the request fails or the server rejects it.
669 pub async fn toggle_repeat(&self) -> Result<(), CiderError> {
670 self.request(reqwest::Method::POST, "/toggle-repeat")
671 .send()
672 .await?
673 .error_for_status()?;
674 Ok(())
675 }
676
677 /// Get the current shuffle mode (`0` = off, `1` = on).
678 ///
679 /// # Errors
680 ///
681 /// Returns [`CiderError`] if the request fails or the response cannot be parsed.
682 pub async fn get_shuffle_mode(&self) -> Result<u8, CiderError> {
683 let resp: ApiResponse<ShuffleModeResponse> = self
684 .request(reqwest::Method::GET, "/shuffle-mode")
685 .send()
686 .await?
687 .json()
688 .await?;
689
690 Ok(resp.data.value)
691 }
692
693 /// Toggle shuffle on/off.
694 ///
695 /// # Errors
696 ///
697 /// Returns [`CiderError`] if the request fails or the server rejects it.
698 pub async fn toggle_shuffle(&self) -> Result<(), CiderError> {
699 self.request(reqwest::Method::POST, "/toggle-shuffle")
700 .send()
701 .await?
702 .error_for_status()?;
703 Ok(())
704 }
705
706 /// Get the current autoplay status (`true` = on).
707 ///
708 /// # Errors
709 ///
710 /// Returns [`CiderError`] if the request fails or the response cannot be parsed.
711 pub async fn get_autoplay(&self) -> Result<bool, CiderError> {
712 let resp: ApiResponse<AutoplayResponse> = self
713 .request(reqwest::Method::GET, "/autoplay")
714 .send()
715 .await?
716 .json()
717 .await?;
718
719 Ok(resp.data.value)
720 }
721
722 /// Toggle autoplay on/off.
723 ///
724 /// # Errors
725 ///
726 /// Returns [`CiderError`] if the request fails or the server rejects it.
727 pub async fn toggle_autoplay(&self) -> Result<(), CiderError> {
728 self.request(reqwest::Method::POST, "/toggle-autoplay")
729 .send()
730 .await?
731 .error_for_status()?;
732 Ok(())
733 }
734
735 // ── Apple Music API passthrough ──────────────────────────────────────
736
737 /// Execute a raw Apple Music API request via Cider's passthrough.
738 ///
739 /// Sends `POST /api/v1/amapi/run-v3` with the given `path`, and returns
740 /// the raw JSON response from Apple Music.
741 ///
742 /// # Arguments
743 ///
744 /// * `path` — Apple Music API path, e.g. `"/v1/me/library/songs"` or
745 /// `"/v1/catalog/us/search?term=flume&types=songs"`.
746 ///
747 /// # Errors
748 ///
749 /// Returns [`CiderError`] if the request fails or the response cannot be parsed.
750 pub async fn amapi_run_v3(&self, path: &str) -> Result<serde_json::Value, CiderError> {
751 let resp = self
752 .request_raw(reqwest::Method::POST, "/api/v1/amapi/run-v3")
753 .json(&AmApiRequest {
754 path: path.to_string(),
755 })
756 .send()
757 .await?
758 .error_for_status()?;
759
760 resp.json().await.map_err(CiderError::from)
761 }
762}
763
764impl Default for CiderClient {
765 fn default() -> Self {
766 Self::new()
767 }
768}
769
770#[cfg(test)]
771mod tests {
772 use super::*;
773
774 #[test]
775 fn default_client() {
776 let client = CiderClient::new();
777 assert_eq!(client.base_url, "http://127.0.0.1:10767");
778 assert!(client.api_token.is_none());
779 }
780
781 #[test]
782 fn client_with_token() {
783 let client = CiderClient::new().with_token("test-token");
784 assert_eq!(client.api_token, Some("test-token".to_string()));
785 }
786
787 #[test]
788 fn client_custom_port() {
789 let client = CiderClient::with_port(9999);
790 assert_eq!(client.base_url, "http://127.0.0.1:9999");
791 }
792
793 #[test]
794 fn client_is_clone() {
795 let a = CiderClient::new();
796 let b = a.clone();
797 assert_eq!(a.base_url, b.base_url);
798 }
799}