use std::path::PathBuf;
use tracing::{debug, info};
use crate::{Result, SearchError};
const LIGHTPANDA_RELEASES_API: &str =
"https://api.github.com/repos/lightpanda-io/browser/releases/latest";
fn platform_id() -> Result<&'static str> {
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
return Ok("x86_64-linux");
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
return Ok("aarch64-linux");
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
return Ok("x86_64-macos");
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
return Ok("aarch64-macos");
#[cfg(not(any(
all(target_os = "linux", target_arch = "x86_64"),
all(target_os = "linux", target_arch = "aarch64"),
all(target_os = "macos", target_arch = "x86_64"),
all(target_os = "macos", target_arch = "aarch64"),
)))]
Err(SearchError::Browser(
"Lightpanda does not provide binaries for this platform. \
Supported platforms: Linux x86_64/aarch64, macOS x86_64/aarch64."
.to_string(),
))
}
fn cache_dir() -> Result<PathBuf> {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map(PathBuf::from)
.map_err(|_| SearchError::Browser("Cannot determine home directory".to_string()))?;
Ok(home.join(".a3s").join("lightpanda"))
}
pub fn detect_lightpanda() -> Option<PathBuf> {
if let Ok(path) = std::env::var("LIGHTPANDA") {
let p = PathBuf::from(&path);
if p.exists() {
debug!("Lightpanda found via LIGHTPANDA env var: {}", path);
return Some(p);
}
}
if let Ok(path) = which::which("lightpanda") {
debug!("Lightpanda found in PATH: {}", path.display());
return Some(path);
}
None
}
fn find_cached_lightpanda() -> Result<PathBuf> {
let base = cache_dir()?;
if !base.exists() {
return Err(SearchError::Browser(
"No cached Lightpanda found".to_string(),
));
}
let mut versions: Vec<_> = std::fs::read_dir(&base)
.map_err(|e| SearchError::Browser(format!("Failed to read cache dir: {}", e)))?
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.collect();
versions.sort_by_key(|b| std::cmp::Reverse(b.file_name()));
for version_dir in versions {
let exe_path = version_dir.path().join("lightpanda");
if exe_path.exists() {
return Ok(exe_path);
}
}
Err(SearchError::Browser(
"No cached Lightpanda found".to_string(),
))
}
pub async fn ensure_lightpanda() -> Result<PathBuf> {
if let Some(path) = detect_lightpanda() {
info!("Using system Lightpanda: {}", path.display());
return Ok(path);
}
if let Ok(path) = find_cached_lightpanda() {
info!("Using cached Lightpanda: {}", path.display());
return Ok(path);
}
info!("Lightpanda not found, downloading latest release...");
download_lightpanda().await
}
async fn download_lightpanda() -> Result<PathBuf> {
let platform = platform_id()?;
let asset_name = format!("lightpanda-{}", platform);
eprintln!("Fetching Lightpanda release info...");
let client = reqwest::Client::builder()
.user_agent("a3s-search")
.build()
.map_err(|e| SearchError::Browser(format!("Failed to create HTTP client: {}", e)))?;
let resp = client
.get(LIGHTPANDA_RELEASES_API)
.send()
.await
.map_err(|e| {
SearchError::Browser(format!("Failed to fetch Lightpanda release info: {}", e))
})?;
let body: serde_json::Value = resp.json().await.map_err(|e| {
SearchError::Browser(format!("Failed to parse Lightpanda release JSON: {}", e))
})?;
let tag = body
.get("tag_name")
.and_then(|v| v.as_str())
.ok_or_else(|| SearchError::Browser("No tag_name in Lightpanda release".to_string()))?;
let assets = body
.get("assets")
.and_then(|a| a.as_array())
.ok_or_else(|| SearchError::Browser("No assets in Lightpanda release".to_string()))?;
let download_url = assets
.iter()
.find(|a| a.get("name").and_then(|n| n.as_str()) == Some(asset_name.as_str()))
.and_then(|a| a.get("browser_download_url"))
.and_then(|u| u.as_str())
.ok_or_else(|| {
SearchError::Browser(format!(
"No Lightpanda binary for platform '{}' in release '{}'",
platform, tag
))
})?
.to_string();
let version_dir = cache_dir()?.join(tag);
std::fs::create_dir_all(&version_dir).map_err(|e| {
SearchError::Browser(format!(
"Failed to create Lightpanda cache directory {}: {}",
version_dir.display(),
e
))
})?;
eprintln!("Downloading Lightpanda {} ({})...", tag, platform);
let bytes = client
.get(&download_url)
.send()
.await
.map_err(|e| SearchError::Browser(format!("Failed to download Lightpanda: {}", e)))?
.bytes()
.await
.map_err(|e| SearchError::Browser(format!("Failed to read Lightpanda download: {}", e)))?;
eprintln!(
"Downloaded {:.1} MB, installing...",
bytes.len() as f64 / 1_048_576.0
);
let exe_path = version_dir.join("lightpanda");
std::fs::write(&exe_path, &bytes)
.map_err(|e| SearchError::Browser(format!("Failed to write Lightpanda binary: {}", e)))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&exe_path, std::fs::Permissions::from_mode(0o755)).map_err(
|e| SearchError::Browser(format!("Failed to set Lightpanda permissions: {}", e)),
)?;
}
eprintln!("Lightpanda {} installed at {}", tag, exe_path.display());
info!("Lightpanda installed at: {}", exe_path.display());
Ok(exe_path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_platform_id() {
let result = platform_id();
#[cfg(any(
all(target_os = "linux", target_arch = "x86_64"),
all(target_os = "linux", target_arch = "aarch64"),
all(target_os = "macos", target_arch = "x86_64"),
all(target_os = "macos", target_arch = "aarch64"),
))]
{
assert!(result.is_ok());
let id = result.unwrap();
assert!(
[
"x86_64-linux",
"aarch64-linux",
"x86_64-macos",
"aarch64-macos"
]
.contains(&id),
"Unexpected platform id: {}",
id
);
}
#[cfg(not(any(
all(target_os = "linux", target_arch = "x86_64"),
all(target_os = "linux", target_arch = "aarch64"),
all(target_os = "macos", target_arch = "x86_64"),
all(target_os = "macos", target_arch = "aarch64"),
)))]
{
assert!(result.is_err());
}
}
#[test]
fn test_asset_name_format() {
let platform = "x86_64-linux";
let asset_name = format!("lightpanda-{}", platform);
assert_eq!(asset_name, "lightpanda-x86_64-linux");
}
#[test]
fn test_cache_dir_structure() {
let original_home = std::env::var("HOME").ok();
std::env::set_var("HOME", "/tmp/test_lp_cache_home");
let dir = cache_dir().unwrap();
assert_eq!(
dir,
PathBuf::from("/tmp/test_lp_cache_home/.a3s/lightpanda")
);
if let Some(home) = original_home {
std::env::set_var("HOME", home);
}
}
#[test]
fn test_find_cached_lightpanda_no_cache() {
std::env::set_var("HOME", "/tmp/a3s_lp_nonexistent_home_xyz");
let result = find_cached_lightpanda();
assert!(result.is_err());
std::env::remove_var("HOME");
}
#[test]
fn test_detect_lightpanda_nonexistent_env_path() {
std::env::set_var("LIGHTPANDA", "/nonexistent/lightpanda/binary");
let result = detect_lightpanda();
if let Some(ref path) = result {
assert_ne!(
path,
&PathBuf::from("/nonexistent/lightpanda/binary"),
"Should not return non-existent LIGHTPANDA env path"
);
}
std::env::remove_var("LIGHTPANDA");
}
#[test]
fn test_find_cached_lightpanda_empty_dir() {
let tmp = std::env::temp_dir().join("a3s_lp_test_empty_cache");
let cache = tmp.join(".a3s").join("lightpanda");
std::fs::create_dir_all(&cache).ok();
let original_home = std::env::var("HOME").ok();
std::env::set_var("HOME", tmp.to_str().unwrap());
let result = find_cached_lightpanda();
assert!(result.is_err());
if let Some(home) = original_home {
std::env::set_var("HOME", home);
}
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn test_find_cached_lightpanda_version_dir_no_binary() {
let tmp = std::env::temp_dir().join("a3s_lp_test_no_binary");
let version_dir = tmp
.join(".a3s")
.join("lightpanda")
.join("nightly-2024-01-01");
std::fs::create_dir_all(&version_dir).ok();
let original_home = std::env::var("HOME").ok();
std::env::set_var("HOME", tmp.to_str().unwrap());
let result = find_cached_lightpanda();
assert!(result.is_err());
if let Some(home) = original_home {
std::env::set_var("HOME", home);
}
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn test_find_cached_lightpanda_with_binary() {
let tmp = std::env::temp_dir().join("a3s_lp_test_with_binary");
let version_dir = tmp
.join(".a3s")
.join("lightpanda")
.join("nightly-2024-01-01");
std::fs::create_dir_all(&version_dir).ok();
let fake_binary = version_dir.join("lightpanda");
std::fs::write(&fake_binary, b"fake").ok();
let original_home = std::env::var("HOME").ok();
std::env::set_var("HOME", tmp.to_str().unwrap());
let result = find_cached_lightpanda();
assert!(result.is_ok());
assert_eq!(result.unwrap(), fake_binary);
if let Some(home) = original_home {
std::env::set_var("HOME", home);
}
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn test_releases_api_url_is_valid() {
assert!(LIGHTPANDA_RELEASES_API.starts_with("https://"));
assert!(LIGHTPANDA_RELEASES_API.contains("lightpanda-io/browser"));
}
}