1use std::path::{Path, PathBuf};
10use std::sync::atomic::{AtomicUsize, Ordering};
11
12use rskit_errors::{AppError, AppResult, ErrorCode};
13
14use crate::{DataItem, DatasetItem, DatasetLimits, Label};
15
16#[async_trait::async_trait]
18pub trait ItemSink<T: DatasetItem>: Send + Sync {
19 fn name(&self) -> &str;
21
22 async fn prepare(&self) -> AppResult<()> {
24 Ok(())
25 }
26
27 async fn write(&self, item: T) -> AppResult<()>;
29
30 async fn finish(&self) -> AppResult<()> {
32 Ok(())
33 }
34}
35
36pub struct LocalBlobSink {
42 real_dir: PathBuf,
43 ai_dir: PathBuf,
44 limits: DatasetLimits,
45 file_counter: AtomicUsize,
46}
47
48impl LocalBlobSink {
49 #[must_use]
51 pub fn new(output_dir: &Path, limits: DatasetLimits) -> Self {
52 Self {
53 real_dir: output_dir.join("real"),
54 ai_dir: output_dir.join("ai"),
55 limits,
56 file_counter: AtomicUsize::new(0),
57 }
58 }
59}
60
61#[async_trait::async_trait]
62impl ItemSink<DataItem> for LocalBlobSink {
63 fn name(&self) -> &str {
64 "local-blob"
65 }
66
67 async fn prepare(&self) -> AppResult<()> {
68 let real_dir = self.real_dir.clone();
69 let ai_dir = self.ai_dir.clone();
70 let existing = tokio::task::spawn_blocking(move || -> AppResult<usize> {
71 create_dir(&real_dir)?;
72 create_dir(&ai_dir)?;
73 Ok(count_files(&real_dir)? + count_files(&ai_dir)?)
74 })
75 .await
76 .map_err(AppError::internal)??;
77 self.file_counter.store(existing, Ordering::SeqCst);
78 Ok(())
79 }
80
81 async fn write(&self, item: DataItem) -> AppResult<()> {
82 let subdir = if item.label() == Label::Real {
83 &self.real_dir
84 } else {
85 &self.ai_dir
86 };
87 let file_idx = self.file_counter.fetch_add(1, Ordering::SeqCst);
88 let path = subdir.join(format!("{:06}{}", file_idx, item.extension));
89 let limits = self.limits;
90 tokio::task::spawn_blocking(move || item.write_to_path(&path, &limits))
91 .await
92 .map_err(AppError::internal)??;
93 Ok(())
94 }
95}
96
97fn create_dir(dir: &Path) -> AppResult<()> {
98 std::fs::create_dir_all(dir).map_err(|error| {
99 AppError::new(
100 ErrorCode::Internal,
101 format!(
102 "failed to create dataset directory {}: {error}",
103 dir.display()
104 ),
105 )
106 })
107}
108
109pub(crate) fn count_files(dir: &Path) -> AppResult<usize> {
111 if !dir.exists() {
112 return Ok(0);
113 }
114 std::fs::read_dir(dir)
115 .map_err(|error| {
116 AppError::new(
117 ErrorCode::Internal,
118 format!(
119 "failed to read dataset output directory {}: {error}",
120 dir.display()
121 ),
122 )
123 })
124 .map(|entries| {
125 entries
126 .filter_map(Result::ok)
127 .filter(|entry| entry.path().is_file())
128 .count()
129 })
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135 use crate::MediaType;
136
137 #[test]
138 fn count_files_ignores_directories_and_reports_read_errors() {
139 let dir = tempfile::tempdir().unwrap();
140 std::fs::write(dir.path().join("file.txt"), b"x").unwrap();
141 std::fs::create_dir(dir.path().join("nested")).unwrap();
142 assert_eq!(count_files(dir.path()).unwrap(), 1);
143 assert_eq!(count_files(&dir.path().join("missing")).unwrap(), 0);
144
145 let file = dir.path().join("not-dir");
146 std::fs::write(&file, b"x").unwrap();
147 let err = count_files(&file).unwrap_err();
148 assert_eq!(err.code(), ErrorCode::Internal);
149 assert!(
150 err.to_string()
151 .contains("failed to read dataset output directory")
152 );
153 }
154
155 #[tokio::test]
156 async fn local_blob_sink_prepares_dirs_and_writes_labeled_files() {
157 let out = tempfile::tempdir().unwrap();
158 let sink = LocalBlobSink::new(out.path(), DatasetLimits::default());
159 sink.prepare().await.unwrap();
160
161 let real = DataItem::new(b"r".to_vec(), Label::Real, MediaType::Text, "s")
162 .unwrap()
163 .with_extension(".txt");
164 let ai = DataItem::new(b"a".to_vec(), Label::AiGenerated, MediaType::Text, "s")
165 .unwrap()
166 .with_extension(".txt");
167 sink.write(real).await.unwrap();
168 sink.write(ai).await.unwrap();
169
170 assert_eq!(
171 std::fs::read(out.path().join("real/000000.txt")).unwrap(),
172 b"r"
173 );
174 assert_eq!(
175 std::fs::read(out.path().join("ai/000001.txt")).unwrap(),
176 b"a"
177 );
178 }
179
180 #[tokio::test]
181 async fn local_blob_sink_prepare_reports_directory_creation_errors() {
182 let out = tempfile::tempdir().unwrap();
183 let blocker = out.path().join("blocker");
184 std::fs::write(&blocker, b"x").unwrap();
185 let sink = LocalBlobSink::new(&blocker, DatasetLimits::default());
186 assert_eq!(
187 sink.prepare().await.unwrap_err().code(),
188 ErrorCode::Internal
189 );
190 }
191
192 #[tokio::test]
193 async fn local_blob_sink_counter_resumes_past_existing_files() {
194 let out = tempfile::tempdir().unwrap();
195 std::fs::create_dir_all(out.path().join("real")).unwrap();
196 std::fs::write(out.path().join("real/000000.txt"), b"old").unwrap();
197
198 let sink = LocalBlobSink::new(out.path(), DatasetLimits::default());
199 sink.prepare().await.unwrap();
200 let item = DataItem::new(b"new".to_vec(), Label::Real, MediaType::Text, "s")
201 .unwrap()
202 .with_extension(".txt");
203 sink.write(item).await.unwrap();
204 assert!(out.path().join("real/000001.txt").exists());
205 }
206}