diff --git a/src-tauri/crates/core/src/plugin/host_impl.rs b/src-tauri/crates/core/src/plugin/host_impl.rs index 248bfbff..8abb56e2 100644 --- a/src-tauri/crates/core/src/plugin/host_impl.rs +++ b/src-tauri/crates/core/src/plugin/host_impl.rs @@ -332,18 +332,17 @@ fn sum_state_dir_bytes( // ----- waveflow:host/http ------------------------------------------------- -/// Render an error *with its cause chain* for a guest to surface. +/// Formats an error and its cause chain for display to a guest. /// -/// `reqwest::Error`'s `Display` stops at the outermost layer — a -/// failed request reads only "error sending request for url (…)", -/// which says the transport failed but never says why. The reason -/// (DNS lookup, connection refused, timeout, TLS handshake) lives in -/// `source()`, and the frontend shows this string verbatim, so -/// without unwinding it a bug report arrives with no way to tell -/// those cases apart. That is exactly what happened in issue #452. +/// Repeated adjacent cause messages are omitted, and excessively deep cause +/// chains are truncated with an ellipsis. /// -/// The chain is depth-capped: `source()` is author-controlled and -/// nothing forbids a cyclic or absurdly deep implementation. +/// # Examples +/// +/// ``` +/// let error = std::io::Error::other("connection refused"); +/// assert_eq!(describe_error(&error), "connection refused"); +/// ``` fn describe_error(err: &dyn std::error::Error) -> String { const MAX_DEPTH: usize = 8; let mut out = err.to_string(); @@ -377,6 +376,18 @@ fn describe_error(err: &dyn std::error::Error) -> String { } impl wit_host::http::Host for HostCtx { + /// Sends an HTTP request subject to offline-mode and URL permission checks. + /// + /// Offline mode produces an empty `503` response. Denied URLs, invalid methods, + /// transport failures, read failures, and oversized response bodies are returned + /// as error strings. + /// + /// # Examples + /// + /// ```rust,ignore + /// let response = host.send(request)?; + /// assert_eq!(response.status, 200); + /// ``` fn send( &mut self, req: wit_host::http::Request, @@ -849,6 +860,17 @@ mod tests { } impl std::error::Error for Chained { + /// Provides the wrapped error as the cause, when one is present. + /// + /// # Examples + /// + /// ``` + /// use std::error::Error; + /// + /// let cause = std::io::Error::other("cause"); + /// let wrapped: Box = Box::new(cause); + /// assert_eq!(wrapped.to_string(), "cause"); + /// ``` fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { self.source .as_deref() diff --git a/src-tauri/plugins/web-radio/src/lib.rs b/src-tauri/plugins/web-radio/src/lib.rs index ffa9d812..67bb832d 100644 --- a/src-tauri/plugins/web-radio/src/lib.rs +++ b/src-tauri/plugins/web-radio/src/lib.rs @@ -94,9 +94,19 @@ impl Guest for WebRadio { ]) } - /// Translate an entry token (or a free-form search string from - /// the host's search box) into a list of tracks. Limited to - /// [`PAGE_LIMIT`] per call — the UI paginates from there. + /// Resolves an entry token or free-form search term into playable radio tracks. + /// + /// # Errors + /// + /// Returns an error if the query is invalid, the radio-browser request fails, or + /// the response cannot be parsed. + /// + /// # Examples + /// + /// ```no_run + /// let tracks = WebRadio::resolve("top".to_owned())?; + /// # Ok::<(), String>(()) + /// ``` fn resolve(query: String) -> Result, String> { log::emit(Level::Debug, &format!("web-radio resolve: {query}")); let path = build_path(&query)?; @@ -122,6 +132,15 @@ impl Guest for WebRadio { bindings::export!(WebRadio with_types_in bindings); +/// Creates an entry with the given display label and query token. +/// +/// # Examples +/// +/// ``` +/// let item = entry("Top stations", "top"); +/// assert_eq!(item.label, "Top stations"); +/// assert_eq!(item.query, "top"); +/// ``` fn entry(label: &str, query: &str) -> Entry { Entry { label: label.to_string(), @@ -130,18 +149,19 @@ fn entry(label: &str, query: &str) -> Entry { } } -/// Decode the entry / search token into a **host-relative** -/// radio-browser API path — [`fetch_json`] prefixes whichever host in -/// [`HOSTS`] answers, so the path must not carry one. -/// Tokens we understand: +/// Converts a category or search token into a host-relative radio-browser API path. +/// +/// Empty queries and malformed country tokens return an error. Unprefixed non-empty +/// tokens are treated as station-name searches. +/// +/// # Examples /// -/// - `top` → top-voted stations -/// - `trending` → most-recently-updated stations -/// - `tag:` → stations tagged ``, ordered by votes -/// - `search:` → explicit name search prefix -/// - anything else non-empty → treated as a free-text search term -/// (the host's search box hands its raw input here and expects -/// matches against station names) +/// ``` +/// assert_eq!( +/// build_path("top").unwrap(), +/// "/json/stations/topvote/50?hidebroken=true" +/// ); +/// ``` fn build_path(query: &str) -> Result { let trimmed = query.trim(); if trimmed.is_empty() { @@ -197,25 +217,47 @@ fn build_path(query: &str) -> Result { )) } -/// Issue a GET for `path` against each host in [`HOSTS`] until one -/// answers, and surface the body bytes. +/// Fetches JSON response bytes from the first available radio-browser host. + +/// + +/// Client errors other than HTTP 429 are returned immediately. Transport + +/// failures, HTTP 429 responses, and other non-success responses are retried + +/// against subsequent hosts. + +/// + +/// # Examples + +/// + +/// ```no_run + +/// let body = fetch_json("/json/stations/topvote/50?hidebroken=true")?; + +/// # Ok::<(), String>(()) + +/// ``` + +/// + +/// # Errors + /// -/// The host enforces the manifest's HTTP allowlist + the 10 MB -/// response cap + the offline short-circuit, so failure modes here -/// are limited to network errors and non-2xx HTTP statuses. + +/// Returns an error when the request receives a non-retriable client status + +/// or when every host fails. + /// -/// **What is and isn't retried.** A transport failure or a 5xx means -/// "this node couldn't serve it" — worth asking the next one. A 4xx -/// is the API rejecting the request itself (bad tag, malformed -/// query); every node would answer the same way, so it returns -/// immediately rather than burning a second round-trip to be told -/// the same thing twice. + +/// # Arguments + /// -/// Offline mode surfaces as the host's `503` sentinel. It is left in -/// the retry path deliberately: it costs one extra no-op call (the -/// host short-circuits before touching the network), and special- -/// casing the value here would couple the guest to a host -/// implementation detail it has no contract for. + +/// * `path` - Host-relative API path to request. fn fetch_json(path: &str) -> Result, String> { let mut last_err = String::from("no radio-browser host reachable"); for host in HOSTS {