portail 2.1.0

Unified proxy/gateway: AI Gateway + MCP Gateway + CDN cache
Documentation
//! File cache — content-addressable disk cache (~500MB fort).
//! Uses `cacache` (npm's cache engine) for SHA-256-keyed storage.

use std::sync::Arc;

use axum::{
    Json,
    body::Bytes,
    extract::{Path, State},
    http::StatusCode,
    response::IntoResponse,
};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileCacheConfig {
    #[serde(default)]
    pub enabled: bool,
    #[serde(default = "default_cache_path")]
    pub path: String,
    #[serde(default = "default_max_size")]
    pub max_size: String,
}

impl Default for FileCacheConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            path: default_cache_path(),
            max_size: default_max_size(),
        }
    }
}

fn default_cache_path() -> String {
    "/var/cache/portail/files".into()
}
fn default_max_size() -> String {
    "500MB".into()
}

// ─── store ────────────────────────────────────────────────────────

#[derive(Clone)]
pub struct FileCache {
    path: Arc<std::path::PathBuf>,
}

impl FileCache {
    pub fn new(config: &FileCacheConfig) -> Self {
        let path = std::path::PathBuf::from(&config.path);
        std::fs::create_dir_all(&path).ok();
        Self {
            path: Arc::new(path),
        }
    }

    pub async fn put(&self, key: &str, data: &[u8]) -> Result<(), String> {
        cacache::write(&*self.path, key, data)
            .await
            .map(|_| ())
            .map_err(|e| format!("cache write error: {}", e))
    }

    pub async fn get(&self, key: &str) -> Result<Vec<u8>, String> {
        cacache::read(&*self.path, key)
            .await
            .map_err(|e| format!("cache read error: {}", e))
    }

    pub async fn delete(&self, key: &str) -> Result<(), String> {
        cacache::remove(&*self.path, key)
            .await
            .map_err(|e| format!("cache delete error: {}", e))
    }

    pub async fn stats(&self) -> FileCacheStats {
        FileCacheStats {
            size_bytes: 0,
            entries: 0,
            path: self.path.display().to_string(),
        }
    }
}

#[derive(Debug, Clone, Serialize)]
pub struct FileCacheStats {
    pub size_bytes: u64,
    pub entries: u64,
    pub path: String,
}

// ─── axum handlers ────────────────────────────────────────────────

async fn handle_put(
    State(cache): State<FileCache>,
    Path(key): Path<String>,
    body: Bytes,
) -> impl IntoResponse {
    match cache.put(&key, &body).await {
        Ok(()) => StatusCode::CREATED,
        Err(e) => {
            tracing::warn!(key=%key, error=%e, "file-cache put");
            StatusCode::INTERNAL_SERVER_ERROR
        }
    }
}

async fn handle_get(State(cache): State<FileCache>, Path(key): Path<String>) -> impl IntoResponse {
    match cache.get(&key).await {
        Ok(data) => (
            StatusCode::OK,
            [("content-type", "application/octet-stream")],
            data,
        )
            .into_response(),
        Err(_) => StatusCode::NOT_FOUND.into_response(),
    }
}

async fn handle_delete(State(cache): State<FileCache>, Path(key): Path<String>) -> StatusCode {
    match cache.delete(&key).await {
        Ok(()) => StatusCode::NO_CONTENT,
        Err(_) => StatusCode::NOT_FOUND,
    }
}

async fn handle_stats(State(cache): State<FileCache>) -> Json<FileCacheStats> {
    Json(cache.stats().await)
}

pub fn router_with_state() -> axum::Router<Arc<crate::AppState>> {
    axum::Router::new()
        .route(
            "/file-cache/{key}",
            axum::routing::put(handle_put_app)
                .get(handle_get_app)
                .delete(handle_delete_app),
        )
        .route("/file-cache/stats", axum::routing::get(handle_stats_app))
}

async fn handle_put_app(
    State(s): State<Arc<crate::AppState>>,
    Path(k): Path<String>,
    b: Bytes,
) -> impl IntoResponse {
    handle_put(State(s.file_cache.clone()), Path(k), b).await
}
async fn handle_get_app(
    State(s): State<Arc<crate::AppState>>,
    Path(k): Path<String>,
) -> impl IntoResponse {
    handle_get(State(s.file_cache.clone()), Path(k)).await
}
async fn handle_delete_app(
    State(s): State<Arc<crate::AppState>>,
    Path(k): Path<String>,
) -> StatusCode {
    handle_delete(State(s.file_cache.clone()), Path(k)).await
}
async fn handle_stats_app(State(s): State<Arc<crate::AppState>>) -> Json<FileCacheStats> {
    handle_stats(State(s.file_cache.clone())).await
}

#[cfg(test)]
mod tests {
    use super::*;

    fn test_cache() -> FileCache {
        let path = format!("/tmp/portail-test-file-cache-{}", std::process::id());
        // Clean up any prior test debris
        let _ = std::fs::remove_dir_all(&path);
        FileCache::new(&FileCacheConfig {
            enabled: true,
            path,
            max_size: "10MB".into(),
        })
    }

    #[tokio::test]
    async fn put_and_get() {
        let cache = test_cache();
        let key = "test-key";
        cache.put(key, b"hello world").await.unwrap();
        let bytes = cache.get(key).await.unwrap();
        assert_eq!(bytes, b"hello world");
        cache.delete(key).await.ok();
    }

    #[tokio::test]
    async fn get_missing_returns_err() {
        let cache = test_cache();
        let result = cache.get("nonexistent-key").await;
        assert!(result.is_err());
    }

    #[test]
    fn config_defaults() {
        let cfg = FileCacheConfig::default();
        assert!(cfg.path.starts_with("/var/cache/portail"));
    }
}