use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use rskit_errors::{AppError, AppResult, ErrorCode};
use crate::{DataItem, DatasetItem, DatasetLimits, Label};
#[async_trait::async_trait]
pub trait ItemSink<T: DatasetItem>: Send + Sync {
fn name(&self) -> &str;
async fn prepare(&self) -> AppResult<()> {
Ok(())
}
async fn write(&self, item: T) -> AppResult<()>;
async fn finish(&self) -> AppResult<()> {
Ok(())
}
}
pub struct LocalBlobSink {
real_dir: PathBuf,
ai_dir: PathBuf,
limits: DatasetLimits,
file_counter: AtomicUsize,
}
impl LocalBlobSink {
#[must_use]
pub fn new(output_dir: &Path, limits: DatasetLimits) -> Self {
Self {
real_dir: output_dir.join("real"),
ai_dir: output_dir.join("ai"),
limits,
file_counter: AtomicUsize::new(0),
}
}
}
#[async_trait::async_trait]
impl ItemSink<DataItem> for LocalBlobSink {
fn name(&self) -> &str {
"local-blob"
}
async fn prepare(&self) -> AppResult<()> {
let real_dir = self.real_dir.clone();
let ai_dir = self.ai_dir.clone();
let existing = tokio::task::spawn_blocking(move || -> AppResult<usize> {
create_dir(&real_dir)?;
create_dir(&ai_dir)?;
Ok(count_files(&real_dir)? + count_files(&ai_dir)?)
})
.await
.map_err(AppError::internal)??;
self.file_counter.store(existing, Ordering::SeqCst);
Ok(())
}
async fn write(&self, item: DataItem) -> AppResult<()> {
let subdir = if item.label() == Label::Real {
&self.real_dir
} else {
&self.ai_dir
};
let file_idx = self.file_counter.fetch_add(1, Ordering::SeqCst);
let path = subdir.join(format!("{:06}{}", file_idx, item.extension));
let limits = self.limits;
tokio::task::spawn_blocking(move || item.write_to_path(&path, &limits))
.await
.map_err(AppError::internal)??;
Ok(())
}
}
fn create_dir(dir: &Path) -> AppResult<()> {
std::fs::create_dir_all(dir).map_err(|error| {
AppError::new(
ErrorCode::Internal,
format!(
"failed to create dataset directory {}: {error}",
dir.display()
),
)
})
}
pub(crate) fn count_files(dir: &Path) -> AppResult<usize> {
if !dir.exists() {
return Ok(0);
}
std::fs::read_dir(dir)
.map_err(|error| {
AppError::new(
ErrorCode::Internal,
format!(
"failed to read dataset output directory {}: {error}",
dir.display()
),
)
})
.map(|entries| {
entries
.filter_map(Result::ok)
.filter(|entry| entry.path().is_file())
.count()
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::MediaType;
#[test]
fn count_files_ignores_directories_and_reports_read_errors() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("file.txt"), b"x").unwrap();
std::fs::create_dir(dir.path().join("nested")).unwrap();
assert_eq!(count_files(dir.path()).unwrap(), 1);
assert_eq!(count_files(&dir.path().join("missing")).unwrap(), 0);
let file = dir.path().join("not-dir");
std::fs::write(&file, b"x").unwrap();
let err = count_files(&file).unwrap_err();
assert_eq!(err.code(), ErrorCode::Internal);
assert!(
err.to_string()
.contains("failed to read dataset output directory")
);
}
#[tokio::test]
async fn local_blob_sink_prepares_dirs_and_writes_labeled_files() {
let out = tempfile::tempdir().unwrap();
let sink = LocalBlobSink::new(out.path(), DatasetLimits::default());
sink.prepare().await.unwrap();
let real = DataItem::new(b"r".to_vec(), Label::Real, MediaType::Text, "s")
.unwrap()
.with_extension(".txt");
let ai = DataItem::new(b"a".to_vec(), Label::AiGenerated, MediaType::Text, "s")
.unwrap()
.with_extension(".txt");
sink.write(real).await.unwrap();
sink.write(ai).await.unwrap();
assert_eq!(
std::fs::read(out.path().join("real/000000.txt")).unwrap(),
b"r"
);
assert_eq!(
std::fs::read(out.path().join("ai/000001.txt")).unwrap(),
b"a"
);
}
#[tokio::test]
async fn local_blob_sink_prepare_reports_directory_creation_errors() {
let out = tempfile::tempdir().unwrap();
let blocker = out.path().join("blocker");
std::fs::write(&blocker, b"x").unwrap();
let sink = LocalBlobSink::new(&blocker, DatasetLimits::default());
assert_eq!(
sink.prepare().await.unwrap_err().code(),
ErrorCode::Internal
);
}
#[tokio::test]
async fn local_blob_sink_counter_resumes_past_existing_files() {
let out = tempfile::tempdir().unwrap();
std::fs::create_dir_all(out.path().join("real")).unwrap();
std::fs::write(out.path().join("real/000000.txt"), b"old").unwrap();
let sink = LocalBlobSink::new(out.path(), DatasetLimits::default());
sink.prepare().await.unwrap();
let item = DataItem::new(b"new".to_vec(), Label::Real, MediaType::Text, "s")
.unwrap()
.with_extension(".txt");
sink.write(item).await.unwrap();
assert!(out.path().join("real/000001.txt").exists());
}
}