Skip to main content

romm_cli/
client.rs

1//! HTTP client wrapper around the ROMM API.
2//!
3//! `RommClient` owns a configured `reqwest::Client` plus base URL and
4//! authentication settings. Frontends (CLI, TUI, or a future GUI) depend
5//! on this type instead of talking to `reqwest` directly.
6
7use anyhow::{anyhow, Result};
8use base64::{engine::general_purpose, Engine as _};
9use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
10use reqwest::multipart;
11use reqwest::{Client as HttpClient, Method};
12use serde_json::Value;
13use std::path::Path;
14use std::time::Instant;
15use tokio::io::AsyncWriteExt as _;
16
17use crate::config::{normalize_romm_origin, AuthConfig, Config};
18use crate::core::interrupt::cancelled_error;
19use crate::endpoints::Endpoint;
20
21/// Default `User-Agent` for every request. The stock `reqwest` UA is sometimes blocked at the HTTP
22/// layer (403, etc.) by reverse proxies; override with env `ROMM_USER_AGENT` if needed.
23fn http_user_agent() -> String {
24    match std::env::var("ROMM_USER_AGENT") {
25        Ok(s) if !s.trim().is_empty() => s,
26        _ => format!(
27            "Mozilla/5.0 (compatible; romm-cli/{}; +https://github.com/patricksmill/romm-cli)",
28            env!("CARGO_PKG_VERSION")
29        ),
30    }
31}
32
33/// Map a successful HTTP response body to JSON [`Value`].
34///
35/// Empty or whitespace-only bodies become [`Value::Null`] (e.g. HTTP 204).
36/// Non-JSON UTF-8 bodies are wrapped as `{"_non_json_body": "..."}`.
37fn decode_json_response_body(bytes: &[u8]) -> Value {
38    if bytes.is_empty() || bytes.iter().all(|b| b.is_ascii_whitespace()) {
39        return Value::Null;
40    }
41    serde_json::from_slice(bytes).unwrap_or_else(|_| {
42        serde_json::json!({
43            "_non_json_body": String::from_utf8_lossy(bytes).to_string()
44        })
45    })
46}
47
48fn version_from_heartbeat_json(v: &Value) -> Option<String> {
49    v.get("SYSTEM")?.get("VERSION")?.as_str().map(String::from)
50}
51
52/// High-level HTTP client for the ROMM API.
53///
54/// This type hides the details of `reqwest` and authentication headers
55/// behind a small interface that all frontends can share.
56///
57/// # Examples
58///
59/// ```no_run
60/// # use romm_cli::config::Config;
61/// # use romm_cli::client::RommClient;
62/// # async fn example() -> anyhow::Result<()> {
63/// let config = Config {
64///     base_url: "https://romm.example.com".to_string(),
65///     download_dir: "./downloads".to_string(),
66///     use_https: true,
67///     auth: None,
68/// };
69/// let client = RommClient::new(&config, false)?;
70/// # Ok(())
71/// # }
72/// ```
73#[derive(Clone)]
74pub struct RommClient {
75    /// The underlying HTTP client.
76    http: HttpClient,
77    /// The base URL of the RomM server.
78    base_url: String,
79    /// Current authentication configuration.
80    auth: Option<AuthConfig>,
81    /// Whether to log request details to stderr.
82    verbose: bool,
83}
84
85/// Returns the browser-style origin for RomM (no `/api` suffix).
86///
87/// Same as [`crate::config::normalize_romm_origin`].
88pub fn api_root_url(base_url: &str) -> String {
89    normalize_romm_origin(base_url)
90}
91
92fn alternate_http_scheme_root(root: &str) -> Option<String> {
93    root.strip_prefix("http://")
94        .map(|rest| format!("https://{}", rest))
95        .or_else(|| {
96            root.strip_prefix("https://")
97                .map(|rest| format!("http://{}", rest))
98        })
99}
100
101/// Resolves the origin used to fetch `/openapi.json`.
102///
103/// Normally equals [`normalize_romm_origin`] applied to `api_base_url`,
104/// but can be overridden by the `ROMM_OPENAPI_BASE_URL` environment variable.
105pub fn resolve_openapi_root(api_base_url: &str) -> String {
106    if let Ok(s) = std::env::var("ROMM_OPENAPI_BASE_URL") {
107        let t = s.trim();
108        if !t.is_empty() {
109            return normalize_romm_origin(t);
110        }
111    }
112    normalize_romm_origin(api_base_url)
113}
114
115/// Returns a list of candidate URLs to try for the OpenAPI JSON document.
116///
117/// This includes both HTTP/HTTPS schemes and common paths like `/openapi.json`
118/// and `/api/openapi.json`.
119pub fn openapi_spec_urls(api_root: &str) -> Vec<String> {
120    let root = api_root.trim_end_matches('/').to_string();
121    let mut roots = vec![root.clone()];
122    if let Some(alt) = alternate_http_scheme_root(&root) {
123        if alt != root {
124            roots.push(alt);
125        }
126    }
127
128    let mut urls = Vec::new();
129    for r in roots {
130        let b = r.trim_end_matches('/');
131        urls.push(format!("{b}/openapi.json"));
132        urls.push(format!("{b}/api/openapi.json"));
133    }
134    urls
135}
136
137impl RommClient {
138    /// Construct a new client from the high-level [`Config`].
139    ///
140    /// `verbose` enables stderr request logging (method, path, query key names, status, timing).
141    /// This is typically done once in `main` and the resulting `RommClient` is shared
142    /// (by reference or cloning) with the chosen frontend.
143    pub fn new(config: &Config, verbose: bool) -> Result<Self> {
144        let http = HttpClient::builder()
145            .user_agent(http_user_agent())
146            .build()?;
147        Ok(Self {
148            http,
149            base_url: config.base_url.clone(),
150            auth: config.auth.clone(),
151            verbose,
152        })
153    }
154
155    /// Returns true if verbose logging is enabled.
156    pub fn verbose(&self) -> bool {
157        self.verbose
158    }
159
160    /// Build the HTTP headers for the current authentication mode.
161    ///
162    /// This helper centralises all auth logic so that the rest of the
163    /// code never needs to worry about `Basic` vs `Bearer` vs API key.
164    fn build_headers(&self) -> Result<HeaderMap> {
165        let mut headers = HeaderMap::new();
166
167        if let Some(auth) = &self.auth {
168            match auth {
169                AuthConfig::Basic { username, password } => {
170                    let creds = format!("{username}:{password}");
171                    let encoded = general_purpose::STANDARD.encode(creds.as_bytes());
172                    let value = format!("Basic {encoded}");
173                    headers.insert(
174                        AUTHORIZATION,
175                        HeaderValue::from_str(&value)
176                            .map_err(|_| anyhow!("invalid basic auth header value"))?,
177                    );
178                }
179                AuthConfig::Bearer { token } => {
180                    let value = format!("Bearer {token}");
181                    headers.insert(
182                        AUTHORIZATION,
183                        HeaderValue::from_str(&value)
184                            .map_err(|_| anyhow!("invalid bearer auth header value"))?,
185                    );
186                }
187                AuthConfig::ApiKey { header, key } => {
188                    let name = reqwest::header::HeaderName::from_bytes(header.as_bytes()).map_err(
189                        |_| anyhow!("invalid API_KEY_HEADER, must be a valid HTTP header name"),
190                    )?;
191                    headers.insert(
192                        name,
193                        HeaderValue::from_str(key)
194                            .map_err(|_| anyhow!("invalid API_KEY header value"))?,
195                    );
196                }
197            }
198        }
199
200        Ok(headers)
201    }
202
203    /// Executes a typed [`Endpoint`] and returns its deserialized output.
204    ///
205    /// This is the preferred way to interact with the API when a typed
206    /// endpoint definition exists.
207    pub async fn call<E>(&self, ep: &E) -> anyhow::Result<E::Output>
208    where
209        E: Endpoint,
210        E::Output: serde::de::DeserializeOwned,
211    {
212        let method = ep.method();
213        let path = ep.path();
214        let query = ep.query();
215        let body = ep.body();
216
217        let value = self.request_json(method, &path, &query, body).await?;
218        let output = serde_json::from_value(value)
219            .map_err(|e| anyhow!("failed to decode response for {} {}: {}", method, path, e))?;
220
221        Ok(output)
222    }
223
224    /// Low-level helper that issues an HTTP request and returns a raw JSON [`Value`].
225    ///
226    /// Higher-level code should generally prefer [`RommClient::call`].
227    pub async fn request_json(
228        &self,
229        method: &str,
230        path: &str,
231        query: &[(String, String)],
232        body: Option<Value>,
233    ) -> Result<Value> {
234        let url = format!(
235            "{}/{}",
236            self.base_url.trim_end_matches('/'),
237            path.trim_start_matches('/')
238        );
239        let headers = self.build_headers()?;
240
241        let http_method = Method::from_bytes(method.as_bytes())
242            .map_err(|_| anyhow!("invalid HTTP method: {method}"))?;
243
244        // Ensure query params serialize as key=value pairs (reqwest/serde_urlencoded
245        // expect sequences of (key, value); using &[(&str, &str)] guarantees correct encoding).
246        let query_refs: Vec<(&str, &str)> = query
247            .iter()
248            .map(|(k, v)| (k.as_str(), v.as_str()))
249            .collect();
250
251        let mut req = self
252            .http
253            .request(http_method, &url)
254            .headers(headers)
255            .query(&query_refs);
256
257        if let Some(body) = body {
258            req = req.json(&body);
259        }
260
261        let t0 = Instant::now();
262        let resp = req
263            .send()
264            .await
265            .map_err(|e| anyhow!("request error: {e}"))?;
266
267        let status = resp.status();
268        if self.verbose {
269            let keys: Vec<&str> = query.iter().map(|(k, _)| k.as_str()).collect();
270            tracing::info!(
271                "[romm-cli] {} {} query_keys={:?} -> {} ({}ms)",
272                method,
273                path,
274                keys,
275                status.as_u16(),
276                t0.elapsed().as_millis()
277            );
278        }
279        if !status.is_success() {
280            let body = resp.text().await.unwrap_or_default();
281            return Err(anyhow!(
282                "ROMM API error: {} {} - {}",
283                status.as_u16(),
284                status.canonical_reason().unwrap_or(""),
285                body
286            ));
287        }
288
289        let bytes = resp
290            .bytes()
291            .await
292            .map_err(|e| anyhow!("read response body: {e}"))?;
293
294        Ok(decode_json_response_body(&bytes))
295    }
296
297    pub async fn request_json_unauthenticated(
298        &self,
299        method: &str,
300        path: &str,
301        query: &[(String, String)],
302        body: Option<Value>,
303    ) -> Result<Value> {
304        let url = format!(
305            "{}/{}",
306            self.base_url.trim_end_matches('/'),
307            path.trim_start_matches('/')
308        );
309        let headers = HeaderMap::new();
310
311        let http_method = Method::from_bytes(method.as_bytes())
312            .map_err(|_| anyhow!("invalid HTTP method: {method}"))?;
313
314        // Ensure query params serialize as key=value pairs (reqwest/serde_urlencoded
315        // expect sequences of (key, value); using &[(&str, &str)] guarantees correct encoding).
316        let query_refs: Vec<(&str, &str)> = query
317            .iter()
318            .map(|(k, v)| (k.as_str(), v.as_str()))
319            .collect();
320
321        let mut req = self
322            .http
323            .request(http_method, &url)
324            .headers(headers)
325            .query(&query_refs);
326
327        if let Some(body) = body {
328            req = req.json(&body);
329        }
330
331        let t0 = Instant::now();
332        let resp = req
333            .send()
334            .await
335            .map_err(|e| anyhow!("request error: {e}"))?;
336
337        let status = resp.status();
338        if self.verbose {
339            let keys: Vec<&str> = query.iter().map(|(k, _)| k.as_str()).collect();
340            tracing::info!(
341                "[romm-cli] {} {} query_keys={:?} -> {} ({}ms)",
342                method,
343                path,
344                keys,
345                status.as_u16(),
346                t0.elapsed().as_millis()
347            );
348        }
349        if !status.is_success() {
350            let body = resp.text().await.unwrap_or_default();
351            return Err(anyhow!(
352                "ROMM API error: {} {} - {}",
353                status.as_u16(),
354                status.canonical_reason().unwrap_or(""),
355                body
356            ));
357        }
358
359        let bytes = resp
360            .bytes()
361            .await
362            .map_err(|e| anyhow!("read response body: {e}"))?;
363
364        Ok(decode_json_response_body(&bytes))
365    }
366
367    /// RomM application version from `GET /api/heartbeat` (`SYSTEM.VERSION`), if the endpoint succeeds.
368    pub async fn rom_server_version_from_heartbeat(&self) -> Option<String> {
369        let v = self
370            .request_json_unauthenticated("GET", "/api/heartbeat", &[], None)
371            .await
372            .ok()?;
373        version_from_heartbeat_json(&v)
374    }
375
376    /// GET the OpenAPI spec from the server. Tries [`openapi_spec_urls`] in order (HTTP/HTTPS and
377    /// `/openapi.json` vs `/api/openapi.json`). Uses [`resolve_openapi_root`] for the origin.
378    pub async fn fetch_openapi_json(&self) -> Result<String> {
379        let root = resolve_openapi_root(&self.base_url);
380        let urls = openapi_spec_urls(&root);
381        let mut failures = Vec::new();
382        for url in &urls {
383            match self.fetch_openapi_json_once(url).await {
384                Ok(body) => return Ok(body),
385                Err(e) => failures.push(format!("{url}: {e:#}")),
386            }
387        }
388        Err(anyhow!(
389            "could not download OpenAPI ({} attempt(s)): {}",
390            failures.len(),
391            failures.join(" | ")
392        ))
393    }
394
395    async fn fetch_openapi_json_once(&self, url: &str) -> Result<String> {
396        let headers = self.build_headers()?;
397
398        let t0 = Instant::now();
399        let resp = self
400            .http
401            .get(url)
402            .headers(headers)
403            .send()
404            .await
405            .map_err(|e| anyhow!("request failed: {e}"))?;
406
407        let status = resp.status();
408        if self.verbose {
409            tracing::info!(
410                "[romm-cli] GET {} -> {} ({}ms)",
411                url,
412                status.as_u16(),
413                t0.elapsed().as_millis()
414            );
415        }
416        if !status.is_success() {
417            let body = resp.text().await.unwrap_or_default();
418            return Err(anyhow!(
419                "HTTP {} {} - {}",
420                status.as_u16(),
421                status.canonical_reason().unwrap_or(""),
422                body.chars().take(500).collect::<String>()
423            ));
424        }
425
426        resp.text()
427            .await
428            .map_err(|e| anyhow!("read OpenAPI body: {e}"))
429    }
430
431    /// Downloads a ROM (or multiple ROMs as a zip) to the specified path.
432    ///
433    /// This method supports resuming interrupted downloads by checking if the file
434    /// already exists and sending an HTTP `Range` header.
435    ///
436    /// # Progress
437    ///
438    /// The `on_progress` callback is called with `(received_bytes, total_bytes)`.
439    pub async fn download_rom<F>(
440        &self,
441        rom_id: u64,
442        save_path: &Path,
443        mut on_progress: F,
444    ) -> Result<()>
445    where
446        F: FnMut(u64, u64) + Send,
447    {
448        self.download_rom_with_cancel(rom_id, save_path, |_, _| false, &mut on_progress)
449            .await
450    }
451
452    pub async fn download_rom_with_cancel<F, C>(
453        &self,
454        rom_id: u64,
455        save_path: &Path,
456        mut is_cancelled: C,
457        on_progress: &mut F,
458    ) -> Result<()>
459    where
460        F: FnMut(u64, u64) + Send,
461        C: FnMut(u64, u64) -> bool + Send,
462    {
463        let path = "/api/roms/download";
464        let url = format!(
465            "{}/{}",
466            self.base_url.trim_end_matches('/'),
467            path.trim_start_matches('/')
468        );
469        let mut headers = self.build_headers()?;
470
471        let filename = save_path
472            .file_name()
473            .and_then(|n| n.to_str())
474            .unwrap_or("download.zip");
475
476        // Check for an existing partial file to resume from.
477        let existing_len = tokio::fs::metadata(save_path)
478            .await
479            .map(|m| m.len())
480            .unwrap_or(0);
481
482        if existing_len > 0 {
483            let range = format!("bytes={existing_len}-");
484            if let Ok(v) = reqwest::header::HeaderValue::from_str(&range) {
485                headers.insert(reqwest::header::RANGE, v);
486            }
487        }
488
489        let t0 = Instant::now();
490        let mut resp = self
491            .http
492            .get(&url)
493            .headers(headers)
494            .query(&[
495                ("rom_ids", rom_id.to_string()),
496                ("filename", filename.to_string()),
497            ])
498            .send()
499            .await
500            .map_err(|e| anyhow!("download request error: {e}"))?;
501
502        let status = resp.status();
503        if self.verbose {
504            tracing::info!(
505                "[romm-cli] GET /api/roms/download rom_id={} filename={:?} -> {} ({}ms)",
506                rom_id,
507                filename,
508                status.as_u16(),
509                t0.elapsed().as_millis()
510            );
511        }
512        if !status.is_success() {
513            let body = resp.text().await.unwrap_or_default();
514            return Err(anyhow!(
515                "ROMM API error: {} {} - {}",
516                status.as_u16(),
517                status.canonical_reason().unwrap_or(""),
518                body
519            ));
520        }
521
522        // Determine whether the server honoured our Range header.
523        let (mut received, total, mut file) = if status == reqwest::StatusCode::PARTIAL_CONTENT {
524            // 206 — resume: content_length is the *remaining* bytes.
525            let remaining = resp.content_length().unwrap_or(0);
526            let total = existing_len + remaining;
527            let file = tokio::fs::OpenOptions::new()
528                .append(true)
529                .open(save_path)
530                .await
531                .map_err(|e| anyhow!("open file for append {:?}: {e}", save_path))?;
532            (existing_len, total, file)
533        } else {
534            // 200 — server doesn't support ranges; start from scratch.
535            let total = resp.content_length().unwrap_or(0);
536            let file = tokio::fs::File::create(save_path)
537                .await
538                .map_err(|e| anyhow!("create file {:?}: {e}", save_path))?;
539            (0u64, total, file)
540        };
541
542        if is_cancelled(received, total) {
543            return Err(cancelled_error());
544        }
545
546        while let Some(chunk) = resp.chunk().await.map_err(|e| anyhow!("read chunk: {e}"))? {
547            if is_cancelled(received, total) {
548                return Err(cancelled_error());
549            }
550            file.write_all(&chunk)
551                .await
552                .map_err(|e| anyhow!("write chunk {:?}: {e}", save_path))?;
553            received += chunk.len() as u64;
554            on_progress(received, total);
555        }
556
557        Ok(())
558    }
559
560    /// Uploads a ROM file to the server using the RomM chunked upload API.
561    ///
562    /// This method splits the file into chunks (default 2MB) and uploads them
563    /// sequentially, providing progress updates via the `on_progress` callback.
564    pub async fn upload_rom<F>(
565        &self,
566        platform_id: u64,
567        file_path: &Path,
568        mut on_progress: F,
569    ) -> Result<()>
570    where
571        F: FnMut(u64, u64) + Send,
572    {
573        let filename = file_path
574            .file_name()
575            .and_then(|n| n.to_str())
576            .ok_or_else(|| anyhow!("Invalid filename for upload"))?;
577
578        let metadata = tokio::fs::metadata(file_path)
579            .await
580            .map_err(|e| anyhow!("Failed to read file metadata {:?}: {}", file_path, e))?;
581        let total_size = metadata.len();
582
583        // 2MB chunk size
584        let chunk_size: u64 = 2 * 1024 * 1024;
585        // Use integer division ceiling
586        let total_chunks = if total_size == 0 {
587            1
588        } else {
589            total_size.div_ceil(chunk_size)
590        };
591
592        let mut start_headers = self.build_headers()?;
593        start_headers.insert(
594            reqwest::header::HeaderName::from_static("x-upload-platform"),
595            reqwest::header::HeaderValue::from_str(&platform_id.to_string())?,
596        );
597        start_headers.insert(
598            reqwest::header::HeaderName::from_static("x-upload-filename"),
599            reqwest::header::HeaderValue::from_str(filename)?,
600        );
601        start_headers.insert(
602            reqwest::header::HeaderName::from_static("x-upload-total-size"),
603            reqwest::header::HeaderValue::from_str(&total_size.to_string())?,
604        );
605        start_headers.insert(
606            reqwest::header::HeaderName::from_static("x-upload-total-chunks"),
607            reqwest::header::HeaderValue::from_str(&total_chunks.to_string())?,
608        );
609
610        let start_url = format!(
611            "{}/api/roms/upload/start",
612            self.base_url.trim_end_matches('/')
613        );
614
615        let t0 = Instant::now();
616        let resp = self
617            .http
618            .post(&start_url)
619            .headers(start_headers)
620            .send()
621            .await
622            .map_err(|e| anyhow!("upload start request error: {}", e))?;
623
624        let status = resp.status();
625        if self.verbose {
626            tracing::info!(
627                "[romm-cli] POST /api/roms/upload/start -> {} ({}ms)",
628                status.as_u16(),
629                t0.elapsed().as_millis()
630            );
631        }
632
633        if !status.is_success() {
634            let body = resp.text().await.unwrap_or_default();
635            return Err(anyhow!(
636                "ROMM API error: {} {} - {}",
637                status.as_u16(),
638                status.canonical_reason().unwrap_or(""),
639                body
640            ));
641        }
642
643        let start_resp: Value = resp
644            .json()
645            .await
646            .map_err(|e| anyhow!("failed to parse start upload response: {}", e))?;
647        let upload_id = start_resp
648            .get("upload_id")
649            .and_then(|v| v.as_str())
650            .ok_or_else(|| anyhow!("Missing upload_id in start response: {}", start_resp))?
651            .to_string();
652
653        use tokio::io::AsyncReadExt;
654        let mut file = tokio::fs::File::open(file_path).await?;
655        let mut uploaded_bytes = 0;
656        let mut buffer = vec![0u8; chunk_size as usize];
657
658        for chunk_index in 0..total_chunks {
659            let mut chunk_bytes = 0;
660            let mut chunk_data = Vec::new();
661
662            while chunk_bytes < chunk_size as usize {
663                let n = file.read(&mut buffer[..]).await?;
664                if n == 0 {
665                    break;
666                }
667                chunk_data.extend_from_slice(&buffer[..n]);
668                chunk_bytes += n;
669            }
670
671            let mut chunk_headers = self.build_headers()?;
672            chunk_headers.insert(
673                reqwest::header::HeaderName::from_static("x-chunk-index"),
674                reqwest::header::HeaderValue::from_str(&chunk_index.to_string())?,
675            );
676
677            let chunk_url = format!(
678                "{}/api/roms/upload/{}",
679                self.base_url.trim_end_matches('/'),
680                upload_id
681            );
682
683            let _t_chunk = Instant::now();
684            let chunk_resp = self
685                .http
686                .put(&chunk_url)
687                .headers(chunk_headers)
688                .body(chunk_data.clone())
689                .send()
690                .await
691                .map_err(|e| anyhow!("chunk upload request error: {}", e))?;
692
693            if !chunk_resp.status().is_success() {
694                let body = chunk_resp.text().await.unwrap_or_default();
695                // Attempt to cancel
696                let cancel_url = format!(
697                    "{}/api/roms/upload/{}/cancel",
698                    self.base_url.trim_end_matches('/'),
699                    upload_id
700                );
701                let _ = self
702                    .http
703                    .post(&cancel_url)
704                    .headers(self.build_headers()?)
705                    .send()
706                    .await;
707
708                return Err(anyhow!("Failed to upload chunk {}: {}", chunk_index, body));
709            }
710
711            uploaded_bytes += chunk_data.len() as u64;
712            on_progress(uploaded_bytes, total_size);
713        }
714
715        let complete_url = format!(
716            "{}/api/roms/upload/{}/complete",
717            self.base_url.trim_end_matches('/'),
718            upload_id
719        );
720        let complete_resp = self
721            .http
722            .post(&complete_url)
723            .headers(self.build_headers()?)
724            .send()
725            .await
726            .map_err(|e| anyhow!("upload complete request error: {}", e))?;
727
728        if !complete_resp.status().is_success() {
729            let body = complete_resp.text().await.unwrap_or_default();
730            return Err(anyhow!("Failed to complete upload: {}", body));
731        }
732
733        Ok(())
734    }
735
736    /// Triggers a server-side task by name (e.g., `"scan_library"`).
737    ///
738    /// # Arguments
739    ///
740    /// * `task_name` - The internal name of the task to run.
741    /// * `kwargs` - Optional JSON arguments to pass to the task.
742    pub async fn run_task(&self, task_name: &str, kwargs: Option<Value>) -> Result<Value> {
743        let path = format!("/api/tasks/run/{}", task_name);
744        self.request_json("POST", &path, &[], kwargs).await
745    }
746
747    /// Polls the status of a running task by its ID.
748    pub async fn get_task_status(&self, task_id: &str) -> Result<Value> {
749        let path = format!("/api/tasks/{}", task_id);
750        self.request_json("GET", &path, &[], None).await
751    }
752
753    /// Enqueues all runnable tasks on the server.
754    pub async fn run_all_tasks(&self) -> Result<Value> {
755        self.request_json("POST", "/api/tasks/run", &[], None).await
756    }
757
758    /// Lists all recent and active tasks.
759    pub async fn list_tasks(&self) -> Result<Value> {
760        self.request_json("GET", "/api/tasks", &[], None).await
761    }
762
763    /// Returns the current status of the task queue (active, queued, completed).
764    pub async fn get_tasks_queue_status(&self) -> Result<Value> {
765        self.request_json("GET", "/api/tasks/status", &[], None)
766            .await
767    }
768
769    /// Uploads a game save file to the server.
770    ///
771    /// # Arguments
772    ///
773    /// * `rom_id` - ID of the ROM this save belongs to.
774    /// * `emulator` - Optional name of the emulator that generated the save.
775    /// * `file_path` - Local path to the save file.
776    pub async fn upload_save_file(
777        &self,
778        rom_id: u64,
779        emulator: Option<&str>,
780        file_path: &Path,
781    ) -> Result<Value> {
782        let url = format!("{}/api/saves", self.base_url.trim_end_matches('/'));
783        let bytes = tokio::fs::read(file_path)
784            .await
785            .map_err(|e| anyhow!("read {}: {e}", file_path.display()))?;
786        let fname = file_path
787            .file_name()
788            .and_then(|n| n.to_str())
789            .ok_or_else(|| anyhow!("upload path must have a unicode filename"))?;
790        let part = multipart::Part::bytes(bytes).file_name(fname.to_string());
791        let form = multipart::Form::new().part("saveFile", part);
792        let mut query: Vec<(String, String)> = vec![("rom_id".into(), rom_id.to_string())];
793        if let Some(em) = emulator {
794            if !em.is_empty() {
795                query.push(("emulator".into(), em.to_string()));
796            }
797        }
798        let query_refs: Vec<(&str, &str)> = query
799            .iter()
800            .map(|(k, v)| (k.as_str(), v.as_str()))
801            .collect();
802        let headers = self.build_headers()?;
803        let t0 = Instant::now();
804        let resp = self
805            .http
806            .post(&url)
807            .headers(headers)
808            .query(&query_refs)
809            .multipart(form)
810            .send()
811            .await
812            .map_err(|e| anyhow!("save upload request: {e}"))?;
813        let status = resp.status();
814        if self.verbose {
815            tracing::info!(
816                "[romm-cli] POST /api/saves rom_id={rom_id} -> {} ({}ms)",
817                status.as_u16(),
818                t0.elapsed().as_millis()
819            );
820        }
821        if !status.is_success() {
822            let body = resp.text().await.unwrap_or_default();
823            return Err(anyhow!(
824                "ROMM API error: {} {} - {}",
825                status.as_u16(),
826                status.canonical_reason().unwrap_or(""),
827                body
828            ));
829        }
830        let bytes = resp
831            .bytes()
832            .await
833            .map_err(|e| anyhow!("read save upload body: {e}"))?;
834        Ok(decode_json_response_body(&bytes))
835    }
836
837    /// `POST /api/states` with multipart field `stateFile`.
838    pub async fn upload_state_file(
839        &self,
840        rom_id: u64,
841        emulator: Option<&str>,
842        file_path: &Path,
843    ) -> Result<Value> {
844        let url = format!("{}/api/states", self.base_url.trim_end_matches('/'));
845        let bytes = tokio::fs::read(file_path)
846            .await
847            .map_err(|e| anyhow!("read {}: {e}", file_path.display()))?;
848        let fname = file_path
849            .file_name()
850            .and_then(|n| n.to_str())
851            .ok_or_else(|| anyhow!("upload path must have a unicode filename"))?;
852        let part = multipart::Part::bytes(bytes).file_name(fname.to_string());
853        let form = multipart::Form::new().part("stateFile", part);
854        let mut query: Vec<(String, String)> = vec![("rom_id".into(), rom_id.to_string())];
855        if let Some(em) = emulator {
856            if !em.is_empty() {
857                query.push(("emulator".into(), em.to_string()));
858            }
859        }
860        let query_refs: Vec<(&str, &str)> = query
861            .iter()
862            .map(|(k, v)| (k.as_str(), v.as_str()))
863            .collect();
864        let headers = self.build_headers()?;
865        let resp = self
866            .http
867            .post(&url)
868            .headers(headers)
869            .query(&query_refs)
870            .multipart(form)
871            .send()
872            .await
873            .map_err(|e| anyhow!("state upload request: {e}"))?;
874        let status = resp.status();
875        if !status.is_success() {
876            let body = resp.text().await.unwrap_or_default();
877            return Err(anyhow!(
878                "ROMM API error: {} {} - {}",
879                status.as_u16(),
880                status.canonical_reason().unwrap_or(""),
881                body
882            ));
883        }
884        let bytes = resp
885            .bytes()
886            .await
887            .map_err(|e| anyhow!("read state upload body: {e}"))?;
888        Ok(decode_json_response_body(&bytes))
889    }
890
891    /// `POST /api/screenshots` with multipart field `screenshotFile`.
892    pub async fn upload_screenshot_file(&self, rom_id: u64, file_path: &Path) -> Result<Value> {
893        let url = format!("{}/api/screenshots", self.base_url.trim_end_matches('/'));
894        let bytes = tokio::fs::read(file_path)
895            .await
896            .map_err(|e| anyhow!("read {}: {e}", file_path.display()))?;
897        let fname = file_path
898            .file_name()
899            .and_then(|n| n.to_str())
900            .ok_or_else(|| anyhow!("upload path must have a unicode filename"))?;
901        let part = multipart::Part::bytes(bytes).file_name(fname.to_string());
902        let form = multipart::Form::new().part("screenshotFile", part);
903        let headers = self.build_headers()?;
904        let resp = self
905            .http
906            .post(&url)
907            .headers(headers)
908            .query(&[("rom_id", rom_id.to_string().as_str())])
909            .multipart(form)
910            .send()
911            .await
912            .map_err(|e| anyhow!("screenshot upload: {e}"))?;
913        let status = resp.status();
914        if !status.is_success() {
915            let body = resp.text().await.unwrap_or_default();
916            return Err(anyhow!(
917                "ROMM API error: {} {} - {}",
918                status.as_u16(),
919                status.canonical_reason().unwrap_or(""),
920                body
921            ));
922        }
923        let bytes = resp
924            .bytes()
925            .await
926            .map_err(|e| anyhow!("read screenshot body: {e}"))?;
927        Ok(decode_json_response_body(&bytes))
928    }
929
930    /// `POST /api/firmware?platform_id=` with multipart `files` (single file supported).
931    pub async fn upload_firmware_file(&self, platform_id: u64, file_path: &Path) -> Result<Value> {
932        let url = format!("{}/api/firmware", self.base_url.trim_end_matches('/'));
933        let bytes = tokio::fs::read(file_path)
934            .await
935            .map_err(|e| anyhow!("read {}: {e}", file_path.display()))?;
936        let fname = file_path
937            .file_name()
938            .and_then(|n| n.to_str())
939            .ok_or_else(|| anyhow!("upload path must have a unicode filename"))?;
940        let part = multipart::Part::bytes(bytes).file_name(fname.to_string());
941        let form = multipart::Form::new().part("files", part);
942        let headers = self.build_headers()?;
943        let resp = self
944            .http
945            .post(&url)
946            .headers(headers)
947            .query(&[("platform_id", platform_id.to_string())])
948            .multipart(form)
949            .send()
950            .await
951            .map_err(|e| anyhow!("firmware upload: {e}"))?;
952        let status = resp.status();
953        if !status.is_success() {
954            let body = resp.text().await.unwrap_or_default();
955            return Err(anyhow!(
956                "ROMM API error: {} {} - {}",
957                status.as_u16(),
958                status.canonical_reason().unwrap_or(""),
959                body
960            ));
961        }
962        let bytes = resp
963            .bytes()
964            .await
965            .map_err(|e| anyhow!("read firmware body: {e}"))?;
966        Ok(decode_json_response_body(&bytes))
967    }
968
969    /// Authenticated GET returning raw bytes (e.g. save/state/firmware file or gamelist export).
970    pub async fn get_bytes(&self, path: &str, query: &[(String, String)]) -> Result<Vec<u8>> {
971        let url = format!(
972            "{}/{}",
973            self.base_url.trim_end_matches('/'),
974            path.trim_start_matches('/')
975        );
976        let headers = self.build_headers()?;
977        let query_refs: Vec<(&str, &str)> = query
978            .iter()
979            .map(|(k, v)| (k.as_str(), v.as_str()))
980            .collect();
981        let resp = self
982            .http
983            .get(&url)
984            .headers(headers)
985            .query(&query_refs)
986            .send()
987            .await
988            .map_err(|e| anyhow!("GET {path}: {e}"))?;
989        let status = resp.status();
990        if !status.is_success() {
991            let body = resp.text().await.unwrap_or_default();
992            return Err(anyhow!(
993                "ROMM API error: {} {} - {}",
994                status.as_u16(),
995                status.canonical_reason().unwrap_or(""),
996                body
997            ));
998        }
999        Ok(resp.bytes().await?.to_vec())
1000    }
1001
1002    /// POST returning raw bytes (e.g. gamelist XML).
1003    pub async fn post_bytes(
1004        &self,
1005        path: &str,
1006        query: &[(String, String)],
1007        json_body: Option<Value>,
1008    ) -> Result<Vec<u8>> {
1009        let url = format!(
1010            "{}/{}",
1011            self.base_url.trim_end_matches('/'),
1012            path.trim_start_matches('/')
1013        );
1014        let headers = self.build_headers()?;
1015        let query_refs: Vec<(&str, &str)> = query
1016            .iter()
1017            .map(|(k, v)| (k.as_str(), v.as_str()))
1018            .collect();
1019        let mut req = self.http.post(&url).headers(headers).query(&query_refs);
1020        if let Some(b) = json_body {
1021            req = req.json(&b);
1022        }
1023        let resp = req.send().await.map_err(|e| anyhow!("POST {path}: {e}"))?;
1024        let status = resp.status();
1025        if !status.is_success() {
1026            let body = resp.text().await.unwrap_or_default();
1027            return Err(anyhow!(
1028                "ROMM API error: {} {} - {}",
1029                status.as_u16(),
1030                status.canonical_reason().unwrap_or(""),
1031                body
1032            ));
1033        }
1034        Ok(resp.bytes().await?.to_vec())
1035    }
1036
1037    /// `POST /api/roms/{id}/manuals` — raw file body with `x-upload-filename` header.
1038    pub async fn upload_rom_manual(&self, rom_id: u64, file_path: &Path) -> Result<Value> {
1039        let fname = file_path
1040            .file_name()
1041            .and_then(|n| n.to_str())
1042            .ok_or_else(|| anyhow!("manual path must have a unicode filename"))?
1043            .to_string();
1044        let url = format!(
1045            "{}/api/roms/{}/manuals",
1046            self.base_url.trim_end_matches('/'),
1047            rom_id
1048        );
1049        let bytes = tokio::fs::read(file_path)
1050            .await
1051            .map_err(|e| anyhow!("read {}: {e}", file_path.display()))?;
1052        let mut headers = self.build_headers()?;
1053        headers.insert(
1054            reqwest::header::HeaderName::from_static("x-upload-filename"),
1055            HeaderValue::from_str(&fname).map_err(|_| anyhow!("invalid x-upload-filename"))?,
1056        );
1057        let resp = self
1058            .http
1059            .post(&url)
1060            .headers(headers)
1061            .body(bytes)
1062            .send()
1063            .await
1064            .map_err(|e| anyhow!("manual upload: {e}"))?;
1065        let status = resp.status();
1066        if !status.is_success() {
1067            let body = resp.text().await.unwrap_or_default();
1068            return Err(anyhow!(
1069                "ROMM API error: {} {} - {}",
1070                status.as_u16(),
1071                status.canonical_reason().unwrap_or(""),
1072                body
1073            ));
1074        }
1075        let out = resp.bytes().await?;
1076        Ok(decode_json_response_body(&out))
1077    }
1078}
1079
1080#[cfg(test)]
1081mod tests {
1082    use super::*;
1083
1084    #[test]
1085    fn decode_json_empty_and_whitespace_to_null() {
1086        assert_eq!(decode_json_response_body(b""), Value::Null);
1087        assert_eq!(decode_json_response_body(b"  \n\t "), Value::Null);
1088    }
1089
1090    #[test]
1091    fn decode_json_object_roundtrip() {
1092        let v = decode_json_response_body(br#"{"a":1}"#);
1093        assert_eq!(v["a"], 1);
1094    }
1095
1096    #[test]
1097    fn decode_non_json_wrapped() {
1098        let v = decode_json_response_body(b"plain text");
1099        assert_eq!(v["_non_json_body"], "plain text");
1100    }
1101
1102    #[test]
1103    fn api_root_url_strips_trailing_api() {
1104        assert_eq!(
1105            super::api_root_url("http://localhost:8080/api"),
1106            "http://localhost:8080"
1107        );
1108        assert_eq!(
1109            super::api_root_url("http://localhost:8080/api/"),
1110            "http://localhost:8080"
1111        );
1112        assert_eq!(
1113            super::api_root_url("http://localhost:8080"),
1114            "http://localhost:8080"
1115        );
1116    }
1117
1118    #[test]
1119    fn openapi_spec_urls_try_primary_scheme_then_alt() {
1120        let urls = super::openapi_spec_urls("http://example.test");
1121        assert_eq!(urls[0], "http://example.test/openapi.json");
1122        assert_eq!(urls[1], "http://example.test/api/openapi.json");
1123        assert!(
1124            urls.iter()
1125                .any(|u| u == "https://example.test/openapi.json"),
1126            "{urls:?}"
1127        );
1128    }
1129}