Files
local-transcription/src-tauri/src/sidecar/mod.rs
Developer 3f16aa838d
Some checks failed
Tests / Python Backend Tests (push) Successful in 6s
Tests / Frontend Tests (push) Successful in 8s
Tests / Rust Sidecar Tests (push) Has been cancelled
Add ability to change transcription engine from Settings
New features:
- Settings > Transcription Engine > "Change Transcription Engine"
  button stops the sidecar, deletes downloaded files, and reloads
  the app to show the engine selection screen
- Improved SidecarSetup descriptions with detailed explanations
  of each variant and "Recommended" tag on Cloud (Deepgram)
- Cloud option listed first as the recommended choice
- New reset_sidecar Tauri command that cleans up sidecar files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:02:31 -07:00

1083 lines
36 KiB
Rust

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<SidecarDirs> = 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<String> {
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<GiteaAsset>,
}
#[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<String, String> {
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<GiteaRelease> = 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::<Vec<_>>()
.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<Option<String>, 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<GiteaRelease> = 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<std::process::Child>,
port: Option<u16>,
}
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<u16, String> {
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<u16> {
self.port
}
// -- private helpers -------------------------------------------------------
fn build_dev_command(&self) -> Result<std::process::Command, String> {
// 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<std::process::Command, String> {
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<u16, String> {
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::<ReadyEvent>(&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<Mutex<SidecarManager>>);
#[tauri::command]
pub fn get_sidecar_port(state: tauri::State<'_, ManagedSidecar>) -> Result<Option<u16>, 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<u16, String> {
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::<ReadyEvent>(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::<ReadyEvent>(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<PathBuf> = 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\""));
}
}