Files
local-transcription/src-tauri/src/sidecar/mod.rs

942 lines
31 KiB
Rust
Raw Normal View History

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 {
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<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();
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<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());
}
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<u16> {
self.port
}
// -- private helpers -------------------------------------------------------
fn build_dev_command(&self) -> Result<std::process::Command, String> {
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<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")?,
);
Ok(cmd)
}
fn wait_for_ready(stdout: std::process::ChildStdout) -> Result<u16, String> {
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::<ReadyEvent>(&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<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 fn start_sidecar(state: tauri::State<'_, ManagedSidecar>) -> Result<u16, String> {
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::<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-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\""));
}
}