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 { // version is the full tag name, e.g. "sidecar-v1.0.3" -- use it directly data_dir().join(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(); // current_version is already the full tag, e.g. "sidecar-v1.0.3" 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-") && name != current_version && 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()); } // Clear stale PID lock from a previous crash so the sidecar can start. // The Python InstanceLock writes to ~/.local-transcription/app.lock if let Ok(home) = std::env::var("USERPROFILE") .or_else(|_| std::env::var("HOME")) { let lock_file = PathBuf::from(home) .join(".local-transcription") .join("app.lock"); if lock_file.exists() { eprintln!("[sidecar] Removing stale lock file: {}", lock_file.display()); let _ = std::fs::remove_file(&lock_file); } } 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")?; // Capture stderr in a background thread so we can log it let stderr = child .stderr .take() .ok_or("Failed to capture sidecar stderr")?; let log_dir = DIRS.get().map(|d| d.data_dir.clone()); std::thread::spawn(move || { use std::io::BufRead; let reader = std::io::BufReader::new(stderr); let mut log_file = log_dir.and_then(|d| { std::fs::OpenOptions::new() .create(true) .append(true) .open(d.join("sidecar.log")) .ok() }); for line in reader.lines() { if let Ok(line) = line { eprintln!("[sidecar-stderr] {}", line); if let Some(ref mut f) = log_file { use std::io::Write; let _ = writeln!(f, "{}", line); } } } }); match Self::wait_for_ready(stdout) { Ok(port) => { self.child = Some(child); self.port = Some(port); Ok(port) } Err(e) => { // Kill the child if ready failed let _ = child.kill(); let _ = child.wait(); // Read the sidecar.log for context let log_hint = DIRS .get() .and_then(|d| std::fs::read_to_string(d.data_dir.join("sidecar.log")).ok()) .and_then(|s| { let lines: Vec<&str> = s.lines().collect(); let tail: Vec<&str> = lines.iter().rev().take(10).rev().cloned().collect(); if tail.is_empty() { None } else { Some(tail.join("\n")) } }) .unwrap_or_default(); if log_hint.is_empty() { Err(e) } else { Err(format!("{e}\n\nSidecar stderr (last 10 lines):\n{log_hint}")) } } } } /// 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 { // Use `uv run` to ensure we use the project's venv, not system Python let mut cmd = std::process::Command::new("uv"); cmd.args(["run", "python", "-u", "-m", "backend.main_headless"]); // Find the project root: try CARGO_MANIFEST_DIR first (set at compile time), // then fall back to resource_dir parent chain let manifest_dir = option_env!("CARGO_MANIFEST_DIR").map(std::path::PathBuf::from); let project_root = manifest_dir .as_ref() .and_then(|d| d.parent()) // src-tauri -> project root .or_else(|| { DIRS.get() .and_then(|d| d.resource_dir.parent()) .and_then(|p| p.parent()) }); if let Some(root) = project_root { eprintln!("[sidecar] Dev mode: working dir = {}", root.display()); cmd.current_dir(root); } else { eprintln!("[sidecar] Dev mode: WARNING - could not determine project root"); } cmd.env("PYTHONUNBUFFERED", "1"); 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")?, ); // Force unbuffered stdout so the ready event is sent immediately. // PyInstaller frozen executables buffer stdout when piped. cmd.env("PYTHONUNBUFFERED", "1"); Ok(cmd) } fn wait_for_ready(stdout: std::process::ChildStdout) -> Result { use std::sync::mpsc; let timeout = std::time::Duration::from_secs(120); // Read stdout in a background thread so we can enforce a real timeout. // BufReader::lines() blocks indefinitely if no data arrives. let (tx, rx) = mpsc::channel(); std::thread::spawn(move || { let reader = std::io::BufReader::new(stdout); for line in reader.lines() { match line { Ok(line) => { eprintln!("[sidecar-stdout] {}", line); if let Ok(evt) = serde_json::from_str::(&line) { if evt.event == "ready" { let _ = tx.send(Ok(evt.port)); return; } } } Err(e) => { let _ = tx.send(Err(format!("IO error reading stdout: {e}"))); return; } } } let _ = tx.send(Err( "Sidecar process exited before sending ready event".into(), )); }); rx.recv_timeout(timeout).unwrap_or_else(|_| { Err(format!( "Timed out after {}s waiting for sidecar ready event", timeout.as_secs() )) }) } } // --------------------------------------------------------------------------- // Tauri-managed SidecarManager state & commands // --------------------------------------------------------------------------- /// Wrapper so we can store `SidecarManager` in Tauri's managed state. /// Uses Arc so it can be cloned into background threads for async commands. pub struct ManagedSidecar(pub std::sync::Arc>); #[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 async fn start_sidecar(state: tauri::State<'_, ManagedSidecar>) -> Result { let mgr = state.0.clone(); // Run blocking sidecar launch in a background thread so it doesn't // freeze the Tauri UI while waiting for the ready event (up to 120s). tokio::task::spawn_blocking(move || { let mut mgr = mgr.lock().map_err(|e| format!("Lock error: {e}"))?; mgr.ensure_running() }) .await .map_err(|e| format!("Task join error: {e}"))? } #[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(()) } /// Stop the running sidecar, delete its files and version marker. /// The next app launch will show the sidecar download prompt. #[tauri::command] pub fn reset_sidecar(state: tauri::State<'_, ManagedSidecar>) -> Result<(), String> { // Stop the running sidecar first { let mut mgr = state .0 .lock() .map_err(|e| format!("Lock error: {e}"))?; mgr.stop(); } let data = data_dir(); // Delete the version file so check_sidecar returns false let vf = version_file(); if vf.exists() { std::fs::remove_file(&vf) .map_err(|e| format!("Failed to delete version file: {e}"))?; } // Delete all sidecar directories 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-") && entry.path().is_dir() { eprintln!("[sidecar] Removing {}", entry.path().display()); let _ = std::fs::remove_dir_all(entry.path()); } } } 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-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(); } // current_version is the full tag, e.g. "sidecar-v1.0.2" cleanup_old_versions("sidecar-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\"")); } }