use std::fs;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
use tempfile::NamedTempFile;
use crate::error::ServerError;
pub type FilesStoreResult<T> = Result<T, ServerError>;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FilePurpose {
Assistants,
Batch,
FineTune,
}
impl FilePurpose {
pub fn from_purpose_str(s: &str) -> Option<Self> {
match s {
"assistants" => Some(Self::Assistants),
"batch" => Some(Self::Batch),
"fine-tune" | "fine_tune" => Some(Self::FineTune),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OxiFile {
pub id: String,
pub object: String,
pub filename: String,
pub purpose: FilePurpose,
pub bytes: usize,
pub created_at: u64,
pub status: String,
}
pub const MAX_FILE_BYTES: usize = 512 * 1024 * 1024;
pub struct FilesStore {
root: PathBuf,
}
impl FilesStore {
pub fn new(root: PathBuf) -> FilesStoreResult<Self> {
fs::create_dir_all(&root).map_err(|e| ServerError::IoError {
context: format!("create files store root {}", root.display()),
source: e,
})?;
Ok(Self { root })
}
pub fn create(
&self,
filename: &str,
purpose: FilePurpose,
data: &[u8],
) -> FilesStoreResult<OxiFile> {
self.create_with_limit(filename, purpose, data, MAX_FILE_BYTES)
}
pub fn create_with_limit(
&self,
filename: &str,
purpose: FilePurpose,
data: &[u8],
limit: usize,
) -> FilesStoreResult<OxiFile> {
if data.len() > limit {
return Err(ServerError::FileTooLarge(format!(
"file '{}' is {} bytes; limit is {} bytes",
filename,
data.len(),
limit
)));
}
let file_id = format!("file-{}", uuid::Uuid::new_v4().as_simple());
let file_dir = self.file_dir(&file_id);
fs::create_dir_all(&file_dir).map_err(|e| ServerError::IoError {
context: format!("create file directory {}", file_dir.display()),
source: e,
})?;
self.write_bytes_atomic(&file_dir, "data.bin", data)?;
let created_at = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let meta = OxiFile {
id: file_id.clone(),
object: "file".to_string(),
filename: filename.to_string(),
purpose,
bytes: data.len(),
created_at,
status: "uploaded".to_string(),
};
self.write_json_atomic(&file_dir, "meta.json", &meta)?;
Ok(meta)
}
pub fn get(&self, file_id: &str) -> FilesStoreResult<OxiFile> {
let path = self.file_dir(file_id).join("meta.json");
let content = fs::read_to_string(&path)
.map_err(|_| ServerError::FileNotFound(file_id.to_string()))?;
serde_json::from_str(&content).map_err(ServerError::Serialization)
}
pub fn list(&self) -> FilesStoreResult<Vec<OxiFile>> {
let mut files = Vec::new();
for entry in fs::read_dir(&self.root).map_err(|e| ServerError::IoError {
context: "list files directory".to_string(),
source: e,
})? {
let entry = entry.map_err(|e| ServerError::IoError {
context: "read files directory entry".to_string(),
source: e,
})?;
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
continue;
}
let meta_path = entry.path().join("meta.json");
if !meta_path.exists() {
continue;
}
if let Ok(content) = fs::read_to_string(&meta_path) {
if let Ok(meta) = serde_json::from_str::<OxiFile>(&content) {
files.push(meta);
}
}
}
files.sort_by_key(|f| f.created_at);
Ok(files)
}
pub fn get_content(&self, file_id: &str) -> FilesStoreResult<Vec<u8>> {
let dir = self.file_dir(file_id);
if !dir.join("meta.json").exists() {
return Err(ServerError::FileNotFound(file_id.to_string()));
}
let data_path = dir.join("data.bin");
fs::read(&data_path).map_err(|e| ServerError::IoError {
context: format!("read file content for {file_id}"),
source: e,
})
}
pub fn delete(&self, file_id: &str) -> FilesStoreResult<()> {
let dir = self.file_dir(file_id);
if !dir.join("meta.json").exists() {
return Err(ServerError::FileNotFound(file_id.to_string()));
}
fs::remove_dir_all(&dir).map_err(|e| ServerError::IoError {
context: format!("delete file directory for {file_id}"),
source: e,
})?;
Ok(())
}
fn file_dir(&self, file_id: &str) -> PathBuf {
self.root.join(file_id)
}
fn write_bytes_atomic(&self, dir: &Path, filename: &str, data: &[u8]) -> FilesStoreResult<()> {
let mut tmp = NamedTempFile::new_in(dir).map_err(|e| ServerError::IoError {
context: format!("create temp file in {}", dir.display()),
source: e,
})?;
tmp.write_all(data).map_err(|e| ServerError::IoError {
context: "write bytes to temp file".to_string(),
source: e,
})?;
tmp.flush().map_err(|e| ServerError::IoError {
context: "flush bytes temp file".to_string(),
source: e,
})?;
let target = dir.join(filename);
tmp.persist(&target).map_err(|e| ServerError::IoError {
context: format!("persist atomic write to {}", target.display()),
source: e.error,
})?;
Ok(())
}
fn write_json_atomic<T: serde::Serialize>(
&self,
dir: &Path,
filename: &str,
value: &T,
) -> FilesStoreResult<()> {
let json = serde_json::to_string_pretty(value).map_err(ServerError::Serialization)?;
let mut tmp = NamedTempFile::new_in(dir).map_err(|e| ServerError::IoError {
context: format!("create json temp file in {}", dir.display()),
source: e,
})?;
tmp.write_all(json.as_bytes())
.map_err(|e| ServerError::IoError {
context: "write json to temp file".to_string(),
source: e,
})?;
tmp.flush().map_err(|e| ServerError::IoError {
context: "flush json temp file".to_string(),
source: e,
})?;
let target = dir.join(filename);
tmp.persist(&target).map_err(|e| ServerError::IoError {
context: format!("persist atomic json write to {}", target.display()),
source: e.error,
})?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env::temp_dir;
use uuid::Uuid;
fn make_store(tag: &str) -> FilesStore {
let id = Uuid::new_v4().as_simple().to_string();
let dir = temp_dir().join(format!("oxillama_files_store_test_{tag}_{id}"));
FilesStore::new(dir).expect("FilesStore::new should succeed")
}
#[test]
fn files_create_returns_id() {
let store = make_store("create_id");
let data = b"hello world";
let meta = store
.create("hello.txt", FilePurpose::Assistants, data)
.expect("create should succeed");
assert!(
meta.id.starts_with("file-"),
"id should start with file-: {}",
meta.id
);
assert_eq!(meta.filename, "hello.txt");
assert_eq!(meta.bytes, data.len());
assert_eq!(meta.status, "uploaded");
assert_eq!(meta.purpose, FilePurpose::Assistants);
}
#[test]
fn files_list_returns_uploaded() {
let store = make_store("list_uploaded");
let data = b"some content";
let meta = store
.create("report.jsonl", FilePurpose::Batch, data)
.expect("create");
let list = store.list().expect("list");
assert!(
list.iter().any(|f| f.id == meta.id),
"list should contain the created file"
);
}
#[test]
fn files_content_returns_bytes() {
let store = make_store("content_bytes");
let data = b"the quick brown fox";
let meta = store
.create("fox.txt", FilePurpose::Assistants, data)
.expect("create");
let content = store.get_content(&meta.id).expect("get_content");
assert_eq!(content.as_slice(), data);
}
#[test]
fn files_delete_removes_persisted_state() {
let store = make_store("delete_state");
let data = b"temporary";
let meta = store
.create("tmp.txt", FilePurpose::FineTune, data)
.expect("create");
store.delete(&meta.id).expect("delete should succeed");
let err = store
.get(&meta.id)
.expect_err("get should fail after delete");
assert!(
matches!(err, ServerError::FileNotFound(_)),
"expected FileNotFound, got: {err}"
);
}
#[test]
fn files_too_large_checked() {
let store = make_store("too_large");
let data = vec![0u8; 32];
let err = store
.create_with_limit("big.bin", FilePurpose::Assistants, &data, 16)
.expect_err("should fail with too-large data");
assert!(
matches!(err, ServerError::FileTooLarge(_)),
"expected FileTooLarge, got: {err}"
);
}
#[test]
fn files_delete_nonexistent_returns_not_found() {
let store = make_store("delete_notfound");
let err = store
.delete("file-doesnotexist")
.expect_err("delete of nonexistent should fail");
assert!(matches!(err, ServerError::FileNotFound(_)));
}
#[test]
fn files_list_empty_store() {
let store = make_store("list_empty");
let list = store.list().expect("list on empty store");
assert!(list.is_empty());
}
#[test]
fn files_persist_across_store_drop_and_recreate() {
let id = Uuid::new_v4().as_simple().to_string();
let dir = temp_dir().join(format!("oxillama_files_persist_{id}"));
let file_id = {
let store = FilesStore::new(dir.clone()).expect("create store");
let meta = store
.create("data.bin", FilePurpose::Assistants, b"persisted bytes")
.expect("create");
meta.id
};
let store2 = FilesStore::new(dir).expect("reopen store");
let meta = store2.get(&file_id).expect("get after reopen");
assert_eq!(meta.id, file_id);
assert_eq!(meta.filename, "data.bin");
let content = store2.get_content(&file_id).expect("content after reopen");
assert_eq!(content.as_slice(), b"persisted bytes");
}
}