use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock, RwLock};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AssetError {
#[error("Asset not found: {0}")]
NotFound(String),
#[error("Failed to read asset '{path}': {source}")]
ReadError {
path: String,
#[source]
source: std::io::Error,
},
#[error("Asset '{0}' is not valid UTF-8")]
InvalidUtf8(String),
#[error("Failed to extract asset '{path}': {source}")]
ExtractionError {
path: String,
#[source]
source: std::io::Error,
},
#[error("Invalid glob pattern '{0}': {1}")]
InvalidPattern(String, String),
}
pub(super) type Result<T> = std::result::Result<T, AssetError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Platform {
Local,
Lambda,
CloudRun,
CloudflareWorkers,
Container,
}
impl Platform {
pub fn detect() -> Self {
if std::env::var("AWS_LAMBDA_FUNCTION_NAME").is_ok() {
return Self::Lambda;
}
if std::env::var("K_SERVICE").is_ok() || std::env::var("CLOUD_RUN_JOB").is_ok() {
return Self::CloudRun;
}
if std::env::var("CF_WORKER").is_ok() {
return Self::CloudflareWorkers;
}
if std::env::var("CONTAINER").is_ok()
|| Path::new("/.dockerenv").exists()
|| Path::new("/run/.containerenv").exists()
{
return Self::Container;
}
Self::Local
}
pub fn assets_base_path(&self) -> PathBuf {
match self {
Self::Lambda => {
std::env::var("LAMBDA_TASK_ROOT")
.map_or_else(|_| PathBuf::from("/var/task"), PathBuf::from)
.join("assets")
},
Self::CloudRun | Self::Container => {
PathBuf::from("/app/assets")
},
Self::CloudflareWorkers => {
PathBuf::from("/tmp/assets")
},
Self::Local => {
std::env::var("PMCP_ASSETS_DIR").map_or_else(
|_| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
PathBuf::from,
)
},
}
}
pub fn temp_path(&self) -> PathBuf {
match self {
Self::Lambda => PathBuf::from("/tmp/pmcp-assets"),
Self::CloudflareWorkers => PathBuf::from("/tmp/pmcp-assets"),
_ => std::env::temp_dir().join("pmcp-assets"),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AssetConfig {
#[serde(default)]
pub include: Vec<String>,
#[serde(default)]
pub exclude: Vec<String>,
#[serde(default)]
pub base_dir: Option<String>,
}
impl AssetConfig {
pub fn new() -> Self {
Self::default()
}
pub fn include(mut self, pattern: impl Into<String>) -> Self {
self.include.push(pattern.into());
self
}
pub fn exclude(mut self, pattern: impl Into<String>) -> Self {
self.exclude.push(pattern.into());
self
}
pub fn base_dir(mut self, dir: impl Into<String>) -> Self {
self.base_dir = Some(dir.into());
self
}
}
static LOADER: OnceLock<AssetLoader> = OnceLock::new();
fn get_loader() -> &'static AssetLoader {
LOADER.get_or_init(AssetLoader::new)
}
pub struct AssetLoader {
platform: Platform,
base_path: PathBuf,
temp_path: PathBuf,
cache: Arc<RwLock<HashMap<String, Arc<Vec<u8>>>>>,
extracted: Arc<RwLock<HashMap<String, PathBuf>>>,
}
impl std::fmt::Debug for AssetLoader {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AssetLoader")
.field("platform", &self.platform)
.field("base_path", &self.base_path)
.field("temp_path", &self.temp_path)
.finish()
}
}
impl AssetLoader {
pub fn new() -> Self {
let platform = Platform::detect();
Self {
base_path: platform.assets_base_path(),
temp_path: platform.temp_path(),
platform,
cache: Arc::new(RwLock::new(HashMap::new())),
extracted: Arc::new(RwLock::new(HashMap::new())),
}
}
pub fn with_base_path(base_path: impl Into<PathBuf>) -> Self {
let platform = Platform::detect();
Self {
base_path: base_path.into(),
temp_path: platform.temp_path(),
platform,
cache: Arc::new(RwLock::new(HashMap::new())),
extracted: Arc::new(RwLock::new(HashMap::new())),
}
}
pub fn platform(&self) -> Platform {
self.platform
}
pub fn base_path(&self) -> &Path {
&self.base_path
}
fn resolve_path(&self, asset_path: &str) -> PathBuf {
let normalized = asset_path.replace('\\', "/");
if Path::new(&normalized).is_absolute() {
return PathBuf::from(normalized);
}
self.base_path.join(normalized)
}
pub fn load(&self, asset_path: &str) -> Result<Arc<Vec<u8>>> {
{
let cache = self.cache.read().unwrap();
if let Some(data) = cache.get(asset_path) {
return Ok(Arc::clone(data));
}
}
let full_path = self.resolve_path(asset_path);
let data = std::fs::read(&full_path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
AssetError::NotFound(asset_path.to_string())
} else {
AssetError::ReadError {
path: asset_path.to_string(),
source: e,
}
}
})?;
let data = Arc::new(data);
{
let mut cache = self.cache.write().unwrap();
cache.insert(asset_path.to_string(), Arc::clone(&data));
}
Ok(data)
}
pub fn load_string(&self, asset_path: &str) -> Result<String> {
let data = self.load(asset_path)?;
String::from_utf8((*data).clone())
.map_err(|_| AssetError::InvalidUtf8(asset_path.to_string()))
}
pub fn path(&self, asset_path: &str) -> Result<PathBuf> {
{
let extracted = self.extracted.read().unwrap();
if let Some(path) = extracted.get(asset_path) {
if path.exists() {
return Ok(path.clone());
}
}
}
let source_path = self.resolve_path(asset_path);
if self.platform == Platform::Local && source_path.exists() {
return Ok(source_path);
}
if !source_path.exists() {
return Err(AssetError::NotFound(asset_path.to_string()));
}
if matches!(
self.platform,
Platform::Lambda | Platform::CloudflareWorkers
) {
let temp_asset_path = self.temp_path.join(asset_path);
if let Some(parent) = temp_asset_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| AssetError::ExtractionError {
path: asset_path.to_string(),
source: e,
})?;
}
if !temp_asset_path.exists() {
std::fs::copy(&source_path, &temp_asset_path).map_err(|e| {
AssetError::ExtractionError {
path: asset_path.to_string(),
source: e,
}
})?;
}
{
let mut extracted = self.extracted.write().unwrap();
extracted.insert(asset_path.to_string(), temp_asset_path.clone());
}
return Ok(temp_asset_path);
}
Ok(source_path)
}
pub fn exists(&self, asset_path: &str) -> bool {
let full_path = self.resolve_path(asset_path);
full_path.exists()
}
pub fn list(&self, pattern: &str) -> Result<Vec<String>> {
let full_pattern = self.base_path.join(pattern);
let pattern_str = full_pattern.to_string_lossy();
let paths = glob::glob(&pattern_str)
.map_err(|e| AssetError::InvalidPattern(pattern.to_string(), e.to_string()))?;
let base_path_len = self.base_path.to_string_lossy().len();
let results: Vec<String> = paths
.filter_map(|entry| entry.ok())
.filter(|path| path.is_file())
.map(|path| {
let path_str = path.to_string_lossy();
if path_str.len() > base_path_len {
path_str[base_path_len..]
.trim_start_matches('/')
.to_string()
} else {
path_str.to_string()
}
})
.collect();
Ok(results)
}
pub fn clear_cache(&self) {
let mut cache = self.cache.write().unwrap();
cache.clear();
}
}
impl Default for AssetLoader {
fn default() -> Self {
Self::new()
}
}
pub fn load(asset_path: &str) -> Result<Arc<Vec<u8>>> {
get_loader().load(asset_path)
}
pub fn load_string(asset_path: &str) -> Result<String> {
get_loader().load_string(asset_path)
}
pub fn path(asset_path: &str) -> Result<PathBuf> {
get_loader().path(asset_path)
}
pub fn exists(asset_path: &str) -> bool {
get_loader().exists(asset_path)
}
pub fn list(pattern: &str) -> Result<Vec<String>> {
get_loader().list(pattern)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn setup_test_assets() -> TempDir {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("test.txt"), "Hello, World!").unwrap();
fs::create_dir_all(temp_dir.path().join("resources")).unwrap();
fs::write(
temp_dir.path().join("resources/guide.md"),
"# Guide\n\nThis is a guide.",
)
.unwrap();
fs::write(
temp_dir.path().join("resources/api.md"),
"# API\n\nAPI documentation.",
)
.unwrap();
temp_dir
}
#[test]
fn test_platform_detection_local() {
let platform = Platform::detect();
assert!(matches!(platform, Platform::Local | Platform::Container));
}
#[test]
fn test_asset_loader_load() {
let temp_dir = setup_test_assets();
let loader = AssetLoader::with_base_path(temp_dir.path());
let data = loader.load("test.txt").unwrap();
assert_eq!(&*data, b"Hello, World!");
}
#[test]
fn test_asset_loader_load_string() {
let temp_dir = setup_test_assets();
let loader = AssetLoader::with_base_path(temp_dir.path());
let content = loader.load_string("resources/guide.md").unwrap();
assert!(content.contains("# Guide"));
}
#[test]
fn test_asset_loader_exists() {
let temp_dir = setup_test_assets();
let loader = AssetLoader::with_base_path(temp_dir.path());
assert!(loader.exists("test.txt"));
assert!(loader.exists("resources/guide.md"));
assert!(!loader.exists("nonexistent.txt"));
}
#[test]
fn test_asset_loader_list() {
let temp_dir = setup_test_assets();
let loader = AssetLoader::with_base_path(temp_dir.path());
let markdown_files = loader.list("resources/*.md").unwrap();
assert_eq!(markdown_files.len(), 2);
assert!(markdown_files.iter().any(|f| f.contains("guide.md")));
assert!(markdown_files.iter().any(|f| f.contains("api.md")));
}
#[test]
fn test_asset_loader_path() {
let temp_dir = setup_test_assets();
let loader = AssetLoader::with_base_path(temp_dir.path());
let path = loader.path("test.txt").unwrap();
assert!(path.exists());
}
#[test]
fn test_asset_loader_not_found() {
let temp_dir = setup_test_assets();
let loader = AssetLoader::with_base_path(temp_dir.path());
let result = loader.load("nonexistent.txt");
assert!(matches!(result, Err(AssetError::NotFound(_))));
}
#[test]
fn test_asset_loader_caching() {
let temp_dir = setup_test_assets();
let loader = AssetLoader::with_base_path(temp_dir.path());
let data1 = loader.load("test.txt").unwrap();
let data2 = loader.load("test.txt").unwrap();
assert!(Arc::ptr_eq(&data1, &data2));
}
#[test]
fn test_asset_config_builder() {
let config = AssetConfig::new()
.include("*.db")
.include("resources/**/*.md")
.exclude("**/*.tmp")
.base_dir("assets");
assert_eq!(config.include.len(), 2);
assert_eq!(config.exclude.len(), 1);
assert_eq!(config.base_dir, Some("assets".to_string()));
}
}