use std::io::BufRead; use std::path::PathBuf; use std::sync::Mutex; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter}; const REPO_API: &str = "https://repo.anhonesthost.net/api/v1/repos/streamer-tools/local-transcription"; const BINARY_NAME: &str = if cfg!(windows) { "local-transcription-backend.exe" } else { "local-transcription-backend" }; // --------------------------------------------------------------------------- // Directory state (initialised once during Tauri setup) // --------------------------------------------------------------------------- static DIRS: std::sync::OnceLock = std::sync::OnceLock::new(); struct SidecarDirs { #[allow(dead_code)] resource_dir: PathBuf, data_dir: PathBuf, } /// Called from Tauri `setup` to persist the resource / data directories. pub fn init_dirs(resource_dir: PathBuf, data_dir: PathBuf) { let _ = DIRS.set(SidecarDirs { resource_dir, data_dir, }); } fn data_dir() -> &'static PathBuf { &DIRS.get().expect("sidecar::init_dirs not called").data_dir } // --------------------------------------------------------------------------- // Version helpers // --------------------------------------------------------------------------- fn version_file() -> PathBuf { data_dir().join("sidecar-version.txt") } fn read_installed_version() -> Option { std::fs::read_to_string(version_file()) .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) } fn sidecar_dir_for_version(version: &str) -> PathBuf { data_dir().join(format!("sidecar-{version}")) } fn binary_path_for_version(version: &str) -> PathBuf { sidecar_dir_for_version(version).join(BINARY_NAME) } // --------------------------------------------------------------------------- // Gitea API types // --------------------------------------------------------------------------- #[derive(Debug, Deserialize)] struct GiteaRelease { tag_name: String, assets: Vec, } #[derive(Debug, Deserialize)] struct GiteaAsset { name: String, browser_download_url: String, size: u64, } // --------------------------------------------------------------------------- // Platform / arch detection // --------------------------------------------------------------------------- fn platform_token() -> &'static str { if cfg!(target_os = "windows") { "windows" } else if cfg!(target_os = "macos") { "macos" } else { "linux" } } fn arch_token() -> &'static str { if cfg!(target_arch = "aarch64") { "aarch64" } else { "x86_64" } } /// Build the expected asset prefix, e.g. `sidecar-linux-x86_64-cuda`. fn asset_prefix(variant: &str) -> String { format!("sidecar-{}-{}-{}", platform_token(), arch_token(), variant) } // --------------------------------------------------------------------------- // Tauri commands // --------------------------------------------------------------------------- /// Returns `true` when a sidecar binary is installed and the file exists. #[tauri::command] pub fn check_sidecar() -> bool { if let Some(version) = read_installed_version() { binary_path_for_version(&version).exists() } else { false } } /// Download progress payload emitted via `sidecar-download-progress`. #[derive(Clone, Serialize)] struct DownloadProgress { downloaded: u64, total: u64, phase: String, // "downloading" | "extracting" | "done" | "error" message: String, } /// Download & install the latest sidecar release. /// /// `variant` is typically `"cuda"` or `"cpu"`. #[tauri::command] pub async fn download_sidecar(app: AppHandle, variant: String) -> Result { use futures_util::StreamExt; let emit = |progress: DownloadProgress| { let _ = app.emit("sidecar-download-progress", progress); }; // 1. Fetch releases from Gitea (filter to sidecar-v* tags) --------------- emit(DownloadProgress { downloaded: 0, total: 0, phase: "downloading".into(), message: "Fetching release info...".into(), }); let releases_url = format!("{REPO_API}/releases?limit=20"); let client = reqwest::Client::new(); let releases: Vec = client .get(&releases_url) .send() .await .map_err(|e| format!("Failed to fetch releases: {e}"))? .json() .await .map_err(|e| format!("Failed to parse releases: {e}"))?; // Find the latest release whose tag starts with `sidecar-v` let release = releases .into_iter() .find(|r| r.tag_name.starts_with("sidecar-v")) .ok_or_else(|| "No sidecar release found".to_string())?; let version = release.tag_name.clone(); // e.g. "sidecar-v1.0.2" // 2. Find matching asset ---------------------------------------------------- let prefix = asset_prefix(&variant); let asset = release .assets .iter() .find(|a| a.name.starts_with(&prefix) && a.name.ends_with(".zip")) .ok_or_else(|| { format!( "No asset matching '{}' in release {}. Available: {}", prefix, version, release .assets .iter() .map(|a| a.name.as_str()) .collect::>() .join(", ") ) })?; let total_size = asset.size; let download_url = asset.browser_download_url.clone(); // 3. Stream download --------------------------------------------------------- emit(DownloadProgress { downloaded: 0, total: total_size, phase: "downloading".into(), message: format!("Downloading {}...", asset.name), }); let response = client .get(&download_url) .send() .await .map_err(|e| format!("Download request failed: {e}"))?; if !response.status().is_success() { return Err(format!("Download failed with status {}", response.status())); } let tmp_zip = data_dir().join("_sidecar_download.zip"); let mut file = tokio::fs::File::create(&tmp_zip) .await .map_err(|e| format!("Cannot create temp file: {e}"))?; let mut stream = response.bytes_stream(); let mut downloaded: u64 = 0; use tokio::io::AsyncWriteExt; while let Some(chunk) = stream.next().await { let chunk = chunk.map_err(|e| format!("Download stream error: {e}"))?; file.write_all(&chunk) .await .map_err(|e| format!("Write error: {e}"))?; downloaded += chunk.len() as u64; emit(DownloadProgress { downloaded, total: total_size, phase: "downloading".into(), message: format!( "Downloading... {:.1} / {:.1} MB", downloaded as f64 / 1_048_576.0, total_size as f64 / 1_048_576.0 ), }); } file.flush() .await .map_err(|e| format!("Flush error: {e}"))?; drop(file); // 4. Extract zip ------------------------------------------------------------- emit(DownloadProgress { downloaded, total: total_size, phase: "extracting".into(), message: "Extracting sidecar...".into(), }); let dest_dir = sidecar_dir_for_version(&version); if dest_dir.exists() { std::fs::remove_dir_all(&dest_dir) .map_err(|e| format!("Cannot clean old dir: {e}"))?; } std::fs::create_dir_all(&dest_dir) .map_err(|e| format!("Cannot create sidecar dir: {e}"))?; // Extraction is blocking I/O -- offload to a spawn_blocking thread. let zip_path = tmp_zip.clone(); let dest = dest_dir.clone(); tokio::task::spawn_blocking(move || extract_zip(&zip_path, &dest)) .await .map_err(|e| format!("Join error: {e}"))? .map_err(|e| format!("Extraction error: {e}"))?; // Remove the temp zip let _ = std::fs::remove_file(&tmp_zip); // 5. Set executable permissions on Unix ------------------------------------- #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let bin = dest_dir.join(BINARY_NAME); if bin.exists() { let mut perms = std::fs::metadata(&bin) .map_err(|e| format!("metadata error: {e}"))? .permissions(); perms.set_mode(0o755); std::fs::set_permissions(&bin, perms) .map_err(|e| format!("chmod error: {e}"))?; } } // 6. Write version file & clean up old versions ---------------------------- std::fs::write(version_file(), &version) .map_err(|e| format!("Failed to write version file: {e}"))?; cleanup_old_versions(&version); emit(DownloadProgress { downloaded, total: total_size, phase: "done".into(), message: "Sidecar installed successfully".into(), }); Ok(version) } /// Check if there is a newer sidecar release than the installed one. /// Returns `Some(tag_name)` when an update is available, or `None`. #[tauri::command] pub async fn check_sidecar_update() -> Result, String> { let installed = match read_installed_version() { Some(v) => v, None => return Ok(None), }; let releases_url = format!("{REPO_API}/releases?limit=20"); let releases: Vec = reqwest::Client::new() .get(&releases_url) .send() .await .map_err(|e| format!("Failed to fetch releases: {e}"))? .json() .await .map_err(|e| format!("Failed to parse releases: {e}"))?; let latest = releases .iter() .find(|r| r.tag_name.starts_with("sidecar-v")); match latest { Some(rel) if rel.tag_name != installed => Ok(Some(rel.tag_name.clone())), _ => Ok(None), } } // --------------------------------------------------------------------------- // Zip extraction helper // --------------------------------------------------------------------------- fn extract_zip(zip_path: &std::path::Path, dest: &std::path::Path) -> Result<(), String> { let file = std::fs::File::open(zip_path).map_err(|e| format!("Cannot open zip: {e}"))?; let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("Invalid zip: {e}"))?; for i in 0..archive.len() { let mut entry = archive .by_index(i) .map_err(|e| format!("Zip entry error: {e}"))?; let entry_path = match entry.enclosed_name() { Some(p) => p.to_owned(), None => continue, }; let out_path = dest.join(&entry_path); if entry.is_dir() { std::fs::create_dir_all(&out_path) .map_err(|e| format!("mkdir error: {e}"))?; } else { if let Some(parent) = out_path.parent() { std::fs::create_dir_all(parent) .map_err(|e| format!("mkdir error: {e}"))?; } let mut outfile = std::fs::File::create(&out_path) .map_err(|e| format!("create file error: {e}"))?; std::io::copy(&mut entry, &mut outfile) .map_err(|e| format!("copy error: {e}"))?; } } Ok(()) } // --------------------------------------------------------------------------- // Cleanup old versions // --------------------------------------------------------------------------- fn cleanup_old_versions(current_version: &str) { let data = data_dir(); let current_dir_name = format!("sidecar-{current_version}"); if let Ok(entries) = std::fs::read_dir(data) { for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().to_string(); if name.starts_with("sidecar-v") // e.g. sidecar-v1.0.1 && name != current_dir_name && entry.path().is_dir() { let _ = std::fs::remove_dir_all(entry.path()); } } } } // --------------------------------------------------------------------------- // SidecarManager — launch / stop / query the backend process // --------------------------------------------------------------------------- #[derive(Debug, Serialize, Deserialize)] struct ReadyEvent { event: String, port: u16, } pub struct SidecarManager { child: Option, port: Option, } impl SidecarManager { pub fn new() -> Self { Self { child: None, port: None, } } /// Returns `true` when the child process is still alive. pub fn is_running(&mut self) -> bool { match &mut self.child { Some(child) => match child.try_wait() { Ok(Some(_)) => { // Process has exited self.child = None; self.port = None; false } Ok(None) => true, Err(_) => false, }, None => false, } } /// Start the sidecar if it is not already running. Returns the port. pub fn ensure_running(&mut self) -> Result { if self.is_running() { return self .port .ok_or_else(|| "Sidecar running but port unknown".into()); } let is_dev = cfg!(debug_assertions) || std::env::var("LOCAL_TRANSCRIPTION_DEV") .map(|v| v == "1") .unwrap_or(false); let mut cmd = if is_dev { self.build_dev_command()? } else { self.build_prod_command()? }; // Hide the console window on Windows in release mode. #[cfg(windows)] { use std::os::windows::process::CommandExt; const CREATE_NO_WINDOW: u32 = 0x08000000; cmd.creation_flags(CREATE_NO_WINDOW); } cmd.stdout(std::process::Stdio::piped()); cmd.stderr(std::process::Stdio::piped()); let mut child = cmd.spawn().map_err(|e| format!("Failed to spawn sidecar: {e}"))?; // Wait for the `{"event":"ready","port":...}` line on stdout. let stdout = child .stdout .take() .ok_or("Failed to capture sidecar stdout")?; let port = Self::wait_for_ready(stdout)?; self.child = Some(child); self.port = Some(port); Ok(port) } /// Stop the sidecar process if running. pub fn stop(&mut self) { if let Some(mut child) = self.child.take() { let _ = child.kill(); let _ = child.wait(); } self.port = None; } /// Return the port the sidecar is listening on, if known. pub fn port(&self) -> Option { self.port } // -- private helpers ------------------------------------------------------- fn build_dev_command(&self) -> Result { let mut cmd = std::process::Command::new("python"); cmd.args(["-m", "backend.main_headless"]); // Try to find the project root (parent of src-tauri) if let Some(dirs) = DIRS.get() { let project_root = dirs .resource_dir .parent() // src-tauri .and_then(|p| p.parent()); // project root if let Some(root) = project_root { cmd.current_dir(root); } } Ok(cmd) } fn build_prod_command(&self) -> Result { let version = read_installed_version() .ok_or("No sidecar version installed")?; let bin = binary_path_for_version(&version); if !bin.exists() { return Err(format!("Sidecar binary not found at {}", bin.display())); } let mut cmd = std::process::Command::new(&bin); cmd.current_dir( bin.parent() .ok_or("Cannot determine sidecar parent dir")?, ); Ok(cmd) } fn wait_for_ready(stdout: std::process::ChildStdout) -> Result { let reader = std::io::BufReader::new(stdout); let timeout = std::time::Duration::from_secs(120); let start = std::time::Instant::now(); for line in reader.lines() { if start.elapsed() > timeout { return Err("Timed out waiting for sidecar ready event".into()); } let line = line.map_err(|e| format!("IO error reading stdout: {e}"))?; if let Ok(evt) = serde_json::from_str::(&line) { if evt.event == "ready" { return Ok(evt.port); } } // Ignore other lines (e.g. log output) } Err("Sidecar process exited before sending ready event".into()) } } // --------------------------------------------------------------------------- // Tauri-managed SidecarManager state & commands // --------------------------------------------------------------------------- /// Wrapper so we can store `SidecarManager` in Tauri's managed state. pub struct ManagedSidecar(pub Mutex); #[tauri::command] pub fn get_sidecar_port(state: tauri::State<'_, ManagedSidecar>) -> Result, String> { let mut mgr = state .0 .lock() .map_err(|e| format!("Lock error: {e}"))?; // Refresh running status before returning port if !mgr.is_running() { return Ok(None); } Ok(mgr.port()) } #[tauri::command] pub fn start_sidecar(state: tauri::State<'_, ManagedSidecar>) -> Result { let mut mgr = state .0 .lock() .map_err(|e| format!("Lock error: {e}"))?; mgr.ensure_running() } #[tauri::command] pub fn stop_sidecar(state: tauri::State<'_, ManagedSidecar>) -> Result<(), String> { let mut mgr = state .0 .lock() .map_err(|e| format!("Lock error: {e}"))?; mgr.stop(); Ok(()) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use std::io::Write; // ----------------------------------------------------------------------- // 1. Platform / arch detection // ----------------------------------------------------------------------- #[test] fn platform_token_returns_valid_value() { let token = platform_token(); assert!( ["windows", "macos", "linux"].contains(&token), "unexpected platform token: {token}" ); } #[test] fn arch_token_returns_valid_value() { let token = arch_token(); assert!( ["x86_64", "aarch64"].contains(&token), "unexpected arch token: {token}" ); } // ----------------------------------------------------------------------- // 2. Asset name construction // ----------------------------------------------------------------------- #[test] fn asset_prefix_cpu() { let prefix = asset_prefix("cpu"); let expected = format!("sidecar-{}-{}-cpu", platform_token(), arch_token()); assert_eq!(prefix, expected); } #[test] fn asset_prefix_cuda() { let prefix = asset_prefix("cuda"); let expected = format!("sidecar-{}-{}-cuda", platform_token(), arch_token()); assert_eq!(prefix, expected); } #[test] fn asset_prefix_format_matches_zip_convention() { // The download function looks for assets matching // `{prefix}*.zip`, so verify the prefix starts with "sidecar-" // and contains exactly three hyphens (sidecar-OS-ARCH-VARIANT). let prefix = asset_prefix("cpu"); assert!(prefix.starts_with("sidecar-")); assert_eq!(prefix.matches('-').count(), 3, "expected 3 hyphens in '{prefix}'"); } // ----------------------------------------------------------------------- // 3. Version parsing — tag_name format "sidecar-vX.Y.Z" // ----------------------------------------------------------------------- #[test] fn sidecar_tag_starts_with_expected_prefix() { // The code filters releases by `tag_name.starts_with("sidecar-v")`. // Verify the convention: a version string like "sidecar-v1.0.2" passes // the filter, while "v1.0.2" does not. let tag = "sidecar-v1.0.2"; assert!(tag.starts_with("sidecar-v")); let bad_tag = "v1.0.2"; assert!(!bad_tag.starts_with("sidecar-v")); } #[test] fn strip_sidecar_v_prefix() { // The codebase stores the full tag as the version (e.g. "sidecar-v1.0.2"). // Verify we can strip the prefix to get just "1.0.2" when needed. let tag = "sidecar-v1.0.2"; let semver = tag.strip_prefix("sidecar-v").unwrap(); assert_eq!(semver, "1.0.2"); } // ----------------------------------------------------------------------- // 4. ReadyEvent deserialization // ----------------------------------------------------------------------- #[test] fn ready_event_deserializes_basic() { let json = r#"{"event": "ready", "port": 8081}"#; let evt: ReadyEvent = serde_json::from_str(json).unwrap(); assert_eq!(evt.event, "ready"); assert_eq!(evt.port, 8081); } #[test] fn ready_event_deserializes_with_extra_fields() { // The backend may emit additional fields like `obs_port`. // serde should ignore unknown fields by default (deny_unknown_fields // is NOT set on ReadyEvent). let json = r#"{"event": "ready", "port": 8081, "obs_port": 8080}"#; let evt: ReadyEvent = serde_json::from_str(json).unwrap(); assert_eq!(evt.event, "ready"); assert_eq!(evt.port, 8081); } #[test] fn ready_event_rejects_missing_port() { let json = r#"{"event": "ready"}"#; assert!(serde_json::from_str::(json).is_err()); } #[test] fn ready_event_rejects_invalid_port_type() { let json = r#"{"event": "ready", "port": "not_a_number"}"#; assert!(serde_json::from_str::(json).is_err()); } // ----------------------------------------------------------------------- // Helper: initialise DIRS with a temp directory so path-related functions // work. Because OnceLock can only be set once per process, all tests that // need DIRS must coordinate. We use std::sync::Once + a global temp path. // ----------------------------------------------------------------------- static TEST_DATA_DIR: std::sync::OnceLock = std::sync::OnceLock::new(); /// Ensure `DIRS` is initialised (idempotent within a test run). /// Returns the data_dir path. fn ensure_dirs_initialised() -> PathBuf { TEST_DATA_DIR .get_or_init(|| { let tmp = tempfile::tempdir().expect("create tempdir"); let data = tmp.path().to_path_buf(); // We intentionally leak `tmp` so the directory lives for the // entire test-run. std::mem::forget(tmp); let resource = data.join("resource"); // dummy std::fs::create_dir_all(&resource).ok(); init_dirs(resource, data.clone()); data }) .clone() } // ----------------------------------------------------------------------- // 5. Path construction (requires init_dirs) // ----------------------------------------------------------------------- #[test] fn version_file_path_is_in_data_dir() { let data = ensure_dirs_initialised(); let vf = version_file(); assert_eq!(vf, data.join("sidecar-version.txt")); } #[test] fn sidecar_dir_for_version_contains_version() { let data = ensure_dirs_initialised(); let dir = sidecar_dir_for_version("sidecar-v1.2.3"); assert_eq!(dir, data.join("sidecar-sidecar-v1.2.3")); } #[test] fn binary_path_for_version_has_correct_filename() { let _data = ensure_dirs_initialised(); let bin = binary_path_for_version("sidecar-v1.2.3"); assert_eq!(bin.file_name().unwrap(), BINARY_NAME); } #[test] fn read_installed_version_none_when_missing() { let _data = ensure_dirs_initialised(); // The version file should not exist yet (clean temp dir). // If another test wrote it, this still validates the function // doesn't panic. let _ = read_installed_version(); // should not panic } #[test] fn write_then_read_installed_version() { let _data = ensure_dirs_initialised(); let vf = version_file(); std::fs::write(&vf, "sidecar-v2.0.0\n").unwrap(); let v = read_installed_version().expect("should read version"); assert_eq!(v, "sidecar-v2.0.0"); } // ----------------------------------------------------------------------- // 6. Cleanup old versions // ----------------------------------------------------------------------- /// Cleanup tests are combined into one function because /// `cleanup_old_versions` operates on the shared `data_dir()` and /// tests run in parallel, so separate tests would race. #[test] fn cleanup_old_versions_behaviour() { let data = ensure_dirs_initialised(); // -- Part A: removes stale version dirs, keeps current ---------------- let dirs_to_create = ["sidecar-v1.0.0", "sidecar-v1.0.1", "sidecar-v1.0.2"]; for d in &dirs_to_create { std::fs::create_dir_all(data.join(d)).unwrap(); } // cleanup_old_versions builds `current_dir_name = "sidecar-{version}"`. // Passing "v1.0.2" produces "sidecar-v1.0.2" which matches our dir name. cleanup_old_versions("v1.0.2"); assert!( !data.join("sidecar-v1.0.0").exists(), "sidecar-v1.0.0 should be removed" ); assert!( !data.join("sidecar-v1.0.1").exists(), "sidecar-v1.0.1 should be removed" ); assert!( data.join("sidecar-v1.0.2").exists(), "sidecar-v1.0.2 should be kept" ); // -- Part B: ignores non-sidecar directories -------------------------- let other = data.join("some-other-dir"); std::fs::create_dir_all(&other).unwrap(); cleanup_old_versions("v1.0.2"); // run again — should leave other alone assert!(other.exists(), "non-sidecar dir should not be removed"); // Clean up so we don't affect other tests that share data_dir. let _ = std::fs::remove_dir_all(data.join("sidecar-v1.0.2")); let _ = std::fs::remove_dir_all(&other); } // ----------------------------------------------------------------------- // 7. Zip extraction // ----------------------------------------------------------------------- #[test] fn extract_zip_creates_files() { let tmp = tempfile::tempdir().unwrap(); let zip_path = tmp.path().join("test.zip"); let dest_dir = tmp.path().join("output"); std::fs::create_dir_all(&dest_dir).unwrap(); // Build a simple zip in memory. { let file = std::fs::File::create(&zip_path).unwrap(); let mut writer = zip::ZipWriter::new(file); let options = zip::write::SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Deflated); writer.start_file("hello.txt", options).unwrap(); writer.write_all(b"Hello, world!").unwrap(); writer.start_file("subdir/nested.txt", options).unwrap(); writer.write_all(b"Nested content").unwrap(); writer.finish().unwrap(); } extract_zip(&zip_path, &dest_dir).expect("extraction should succeed"); let hello = dest_dir.join("hello.txt"); assert!(hello.exists(), "hello.txt should exist"); assert_eq!(std::fs::read_to_string(&hello).unwrap(), "Hello, world!"); let nested = dest_dir.join("subdir/nested.txt"); assert!(nested.exists(), "subdir/nested.txt should exist"); assert_eq!(std::fs::read_to_string(&nested).unwrap(), "Nested content"); } #[test] fn extract_zip_error_on_invalid_file() { let tmp = tempfile::tempdir().unwrap(); let bad_zip = tmp.path().join("bad.zip"); std::fs::write(&bad_zip, b"not a zip file").unwrap(); let dest = tmp.path().join("dest"); std::fs::create_dir_all(&dest).unwrap(); let result = extract_zip(&bad_zip, &dest); assert!(result.is_err(), "should fail on invalid zip"); } // ----------------------------------------------------------------------- // SidecarManager unit tests (no process spawning) // ----------------------------------------------------------------------- #[test] fn sidecar_manager_new_is_not_running() { let mut mgr = SidecarManager::new(); assert!(!mgr.is_running()); assert!(mgr.port().is_none()); } #[test] fn sidecar_manager_stop_when_not_running() { let mut mgr = SidecarManager::new(); mgr.stop(); // should not panic assert!(!mgr.is_running()); } // ----------------------------------------------------------------------- // GiteaRelease / GiteaAsset deserialization // ----------------------------------------------------------------------- #[test] fn gitea_release_deserializes() { let json = r#"{ "tag_name": "sidecar-v1.0.0", "assets": [ { "name": "sidecar-linux-x86_64-cuda.zip", "browser_download_url": "https://example.com/file.zip", "size": 12345 } ] }"#; let release: GiteaRelease = serde_json::from_str(json).unwrap(); assert_eq!(release.tag_name, "sidecar-v1.0.0"); assert_eq!(release.assets.len(), 1); assert_eq!(release.assets[0].name, "sidecar-linux-x86_64-cuda.zip"); assert_eq!(release.assets[0].size, 12345); } #[test] fn gitea_release_with_extra_fields() { // Gitea responses include many more fields; serde should ignore them. let json = r#"{ "id": 42, "tag_name": "sidecar-v2.0.0", "name": "Release 2.0.0", "body": "changelog here", "draft": false, "prerelease": false, "assets": [] }"#; let release: GiteaRelease = serde_json::from_str(json).unwrap(); assert_eq!(release.tag_name, "sidecar-v2.0.0"); assert!(release.assets.is_empty()); } // ----------------------------------------------------------------------- // DownloadProgress serialization round-trip // ----------------------------------------------------------------------- #[test] fn download_progress_serializes() { let progress = DownloadProgress { downloaded: 1024, total: 4096, phase: "downloading".into(), message: "50%".into(), }; let json = serde_json::to_string(&progress).unwrap(); assert!(json.contains("\"downloaded\":1024")); assert!(json.contains("\"phase\":\"downloading\"")); } }