mod file_service;
mod forwarding;
#[cfg(feature = "gcs-artifacts")]
mod gcs_service;
mod in_memory;
pub use file_service::FileArtifactService;
pub use forwarding::ForwardingArtifactService;
#[cfg(feature = "gcs-artifacts")]
pub use gcs_service::GcsArtifactService;
pub use in_memory::InMemoryArtifactService;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArtifactMetadata {
pub name: String,
pub mime_type: String,
pub version: u32,
pub size: usize,
pub created_at: u64,
pub updated_at: u64,
}
#[derive(Debug, Clone)]
pub struct Artifact {
pub metadata: ArtifactMetadata,
pub data: Vec<u8>,
}
impl Artifact {
pub fn new(name: impl Into<String>, mime_type: impl Into<String>, data: Vec<u8>) -> Self {
let now = now_secs();
let size = data.len();
Self {
metadata: ArtifactMetadata {
name: name.into(),
mime_type: mime_type.into(),
version: 1,
size,
created_at: now,
updated_at: now,
},
data,
}
}
pub fn json(name: impl Into<String>, value: &serde_json::Value) -> Self {
let data = serde_json::to_vec(value).unwrap_or_default();
Self::new(name, "application/json", data)
}
pub fn text(name: impl Into<String>, text: impl Into<String>) -> Self {
Self::new(name, "text/plain", text.into().into_bytes())
}
}
#[derive(Debug, thiserror::Error)]
pub enum ArtifactError {
#[error("Artifact not found: {0}")]
NotFound(String),
#[error("Version not found: {name} v{version}")]
VersionNotFound {
name: String,
version: u32,
},
#[error("Storage error: {0}")]
Storage(String),
}
#[async_trait]
pub trait ArtifactService: Send + Sync {
async fn save(
&self,
session_id: &str,
artifact: Artifact,
) -> Result<ArtifactMetadata, ArtifactError>;
async fn load(&self, session_id: &str, name: &str) -> Result<Option<Artifact>, ArtifactError>;
async fn load_version(
&self,
session_id: &str,
name: &str,
version: u32,
) -> Result<Option<Artifact>, ArtifactError>;
async fn list(&self, session_id: &str) -> Result<Vec<ArtifactMetadata>, ArtifactError>;
async fn delete(&self, session_id: &str, name: &str) -> Result<(), ArtifactError>;
}
pub(crate) fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn artifact_new() {
let a = Artifact::new("file.bin", "application/octet-stream", vec![1, 2, 3]);
assert_eq!(a.metadata.name, "file.bin");
assert_eq!(a.metadata.mime_type, "application/octet-stream");
assert_eq!(a.metadata.version, 1);
assert_eq!(a.metadata.size, 3);
assert_eq!(a.data, vec![1, 2, 3]);
}
#[test]
fn artifact_json() {
let val = serde_json::json!({"key": "value"});
let a = Artifact::json("config", &val);
assert_eq!(a.metadata.mime_type, "application/json");
let parsed: serde_json::Value = serde_json::from_slice(&a.data).unwrap();
assert_eq!(parsed["key"], "value");
}
#[test]
fn artifact_text() {
let a = Artifact::text("readme", "Hello, world!");
assert_eq!(a.metadata.mime_type, "text/plain");
assert_eq!(std::str::from_utf8(&a.data).unwrap(), "Hello, world!");
}
#[test]
fn artifact_service_is_object_safe() {
fn _assert(_: &dyn ArtifactService) {}
}
#[tokio::test]
async fn save_and_load() {
let svc = InMemoryArtifactService::new();
let artifact = Artifact::text("notes", "First version");
svc.save("s1", artifact).await.unwrap();
let loaded = svc.load("s1", "notes").await.unwrap();
assert!(loaded.is_some());
let loaded = loaded.unwrap();
assert_eq!(std::str::from_utf8(&loaded.data).unwrap(), "First version");
assert_eq!(loaded.metadata.version, 1);
}
#[tokio::test]
async fn versioning() {
let svc = InMemoryArtifactService::new();
svc.save("s1", Artifact::text("notes", "v1")).await.unwrap();
svc.save("s1", Artifact::text("notes", "v2")).await.unwrap();
svc.save("s1", Artifact::text("notes", "v3")).await.unwrap();
let latest = svc.load("s1", "notes").await.unwrap().unwrap();
assert_eq!(latest.metadata.version, 3);
assert_eq!(std::str::from_utf8(&latest.data).unwrap(), "v3");
let v1 = svc.load_version("s1", "notes", 1).await.unwrap().unwrap();
assert_eq!(std::str::from_utf8(&v1.data).unwrap(), "v1");
let v2 = svc.load_version("s1", "notes", 2).await.unwrap().unwrap();
assert_eq!(std::str::from_utf8(&v2.data).unwrap(), "v2");
}
#[tokio::test]
async fn load_nonexistent_returns_none() {
let svc = InMemoryArtifactService::new();
let result = svc.load("s1", "missing").await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn list_artifacts() {
let svc = InMemoryArtifactService::new();
svc.save("s1", Artifact::text("a", "data")).await.unwrap();
svc.save("s1", Artifact::text("b", "data")).await.unwrap();
svc.save("s2", Artifact::text("c", "data")).await.unwrap();
let list = svc.list("s1").await.unwrap();
assert_eq!(list.len(), 2);
}
#[tokio::test]
async fn delete_artifact() {
let svc = InMemoryArtifactService::new();
svc.save("s1", Artifact::text("notes", "data"))
.await
.unwrap();
svc.delete("s1", "notes").await.unwrap();
let result = svc.load("s1", "notes").await.unwrap();
assert!(result.is_none());
}
}