1use rskit_errors::{AppError, AppResult, ErrorCode};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::Path;
9
10#[derive(Debug, Clone, Serialize, Deserialize, Default)]
12pub struct Manifest {
13 #[serde(default)]
14 pub sources: HashMap<String, SourceEntry>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct SourceEntry {
21 pub config: serde_json::Value,
23 pub stats: SourceStats,
25 pub status: String,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, Default)]
31pub struct SourceStats {
32 pub total: usize,
34 pub real: usize,
36 pub ai: usize,
38 #[serde(default)]
40 pub fetched_offset: usize,
41}
42
43const MANIFEST_FILE: &str = ".manifest.json";
44const MAX_MANIFEST_BYTES: u64 = 1024 * 1024;
45
46#[derive(Debug, Clone)]
48pub enum CacheStatus {
49 Done(SourceStats),
51 Partial(SourceStats),
53 NotCached,
55}
56
57impl Manifest {
58 pub fn load(output_dir: &Path) -> AppResult<Self> {
60 let path = output_dir.join(MANIFEST_FILE);
61 match read_manifest_bounded(&path) {
62 Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| {
63 AppError::new(
64 ErrorCode::InvalidInput,
65 format!("manifest parse failed for {}: {e}", path.display()),
66 )
67 }),
68 Err(error) if error.code() == ErrorCode::NotFound => Ok(Self::default()),
69 Err(error) => Err(error),
70 }
71 }
72
73 pub fn save(&self, output_dir: &Path) -> AppResult<()> {
75 let path = output_dir.join(MANIFEST_FILE);
76 let tmp_path = output_dir.join(format!("{MANIFEST_FILE}.tmp"));
77 let content = serde_json::to_string_pretty(self).map_err(|e| {
78 AppError::new(
79 ErrorCode::Internal,
80 format!("manifest serialize failed: {e}"),
81 )
82 })?;
83 std::fs::write(&tmp_path, content).map_err(|e| {
84 AppError::new(ErrorCode::Internal, format!("manifest write failed: {e}"))
85 })?;
86 std::fs::rename(&tmp_path, &path).map_err(|e| {
87 AppError::new(
88 ErrorCode::Internal,
89 format!(
90 "manifest replace failed from {} to {}: {e}",
91 tmp_path.display(),
92 path.display()
93 ),
94 )
95 })?;
96 Ok(())
97 }
98
99 pub fn cache_status(
101 &self,
102 source_name: &str,
103 config: &serde_json::Value,
104 max_items: Option<usize>,
105 ) -> CacheStatus {
106 let entry = match self.sources.get(source_name) {
107 Some(e) => e,
108 None => return CacheStatus::NotCached,
109 };
110 if &entry.config != config {
111 return CacheStatus::NotCached;
112 }
113 match entry.status.as_str() {
114 "done" => CacheStatus::Done(entry.stats.clone()),
115 "partial" if entry.stats.total > 0 => {
116 if let Some(max) = max_items {
119 let remaining = max.saturating_sub(entry.stats.total);
120 if remaining <= 5 || (entry.stats.total * 100 / max.max(1)) >= 99 {
121 return CacheStatus::Done(entry.stats.clone());
122 }
123 }
124 CacheStatus::Partial(entry.stats.clone())
125 }
126 _ => CacheStatus::NotCached,
127 }
128 }
129
130 pub fn mark_done(
132 &mut self,
133 source_name: String,
134 config: serde_json::Value,
135 stats: SourceStats,
136 ) {
137 self.sources.insert(
138 source_name,
139 SourceEntry {
140 config,
141 stats,
142 status: "done".to_string(),
143 },
144 );
145 }
146
147 pub fn mark_partial(
149 &mut self,
150 source_name: String,
151 config: serde_json::Value,
152 stats: SourceStats,
153 ) {
154 self.sources.insert(
155 source_name,
156 SourceEntry {
157 config,
158 stats,
159 status: "partial".to_string(),
160 },
161 );
162 }
163}
164
165fn read_manifest_bounded(path: &Path) -> AppResult<Vec<u8>> {
166 use std::io::Read as _;
167
168 let mut file = std::fs::File::open(path).map_err(|e| {
169 if e.kind() == std::io::ErrorKind::NotFound {
170 return AppError::new(
171 ErrorCode::NotFound,
172 format!("manifest not found: {}", path.display()),
173 );
174 }
175 AppError::new(
176 ErrorCode::Internal,
177 format!("manifest read failed for {}: {e}", path.display()),
178 )
179 })?;
180 let mut bytes = Vec::new();
181 file.by_ref()
182 .take(MAX_MANIFEST_BYTES + 1)
183 .read_to_end(&mut bytes)
184 .map_err(|e| {
185 AppError::new(
186 ErrorCode::Internal,
187 format!("manifest read failed for {}: {e}", path.display()),
188 )
189 })?;
190 if bytes.len() as u64 > MAX_MANIFEST_BYTES {
191 return Err(AppError::new(
192 ErrorCode::InvalidInput,
193 format!(
194 "manifest {} exceeded max {MAX_MANIFEST_BYTES} bytes while reading",
195 path.display()
196 ),
197 ));
198 }
199 Ok(bytes)
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use tempfile::TempDir;
206
207 #[test]
208 fn test_manifest_round_trip() {
209 let dir = TempDir::new().unwrap();
210 let mut manifest = Manifest::default();
211
212 let config = serde_json::json!({"repo": "org/dataset", "split": "train"});
213 manifest.mark_done(
214 "hf:org/dataset".to_string(),
215 config.clone(),
216 SourceStats {
217 total: 1000,
218 real: 500,
219 ai: 400,
220 fetched_offset: 1200,
221 },
222 );
223
224 manifest.save(dir.path()).unwrap();
225 let loaded = Manifest::load(dir.path()).unwrap();
226
227 match loaded.cache_status("hf:org/dataset", &config, Some(1000)) {
228 CacheStatus::Done(stats) => {
229 assert_eq!(stats.total, 1000);
230 assert_eq!(stats.real, 500);
231 assert_eq!(stats.ai, 400);
232 assert_eq!(stats.fetched_offset, 1200);
233 }
234 other => panic!("expected Done, got {other:?}"),
235 }
236 }
237
238 #[test]
239 fn test_manifest_config_mismatch() {
240 let dir = TempDir::new().unwrap();
241 let mut manifest = Manifest::default();
242
243 let config1 = serde_json::json!({"repo": "org/dataset", "max_items": 1000});
244 manifest.mark_done(
245 "source".to_string(),
246 config1,
247 SourceStats {
248 total: 1000,
249 real: 500,
250 ai: 500,
251 fetched_offset: 1000,
252 },
253 );
254 manifest.save(dir.path()).unwrap();
255
256 let loaded = Manifest::load(dir.path()).unwrap();
257 let config2 = serde_json::json!({"repo": "org/dataset", "max_items": 500});
258 assert!(matches!(
259 loaded.cache_status("source", &config2, Some(500)),
260 CacheStatus::NotCached
261 ));
262 }
263
264 #[test]
265 fn test_manifest_empty_dir() {
266 let dir = TempDir::new().unwrap();
267 let manifest = Manifest::load(dir.path()).unwrap();
268 assert!(manifest.sources.is_empty());
269 }
270
271 #[test]
272 fn test_manifest_partial_with_items_resumes() {
273 let dir = TempDir::new().unwrap();
274 let mut manifest = Manifest::default();
275
276 let config = serde_json::json!({"repo": "org/dataset", "split": "train"});
277 manifest.mark_partial(
278 "hf:org/dataset".to_string(),
279 config.clone(),
280 SourceStats {
281 total: 500,
282 real: 250,
283 ai: 250,
284 fetched_offset: 500,
285 },
286 );
287 manifest.save(dir.path()).unwrap();
288
289 let loaded = Manifest::load(dir.path()).unwrap();
290 match loaded.cache_status("hf:org/dataset", &config, Some(1000)) {
291 CacheStatus::Partial(stats) => assert_eq!(stats.total, 500),
292 other => panic!("expected Partial, got {other:?}"),
293 }
294 }
295
296 #[test]
297 fn test_manifest_partial_with_zero_items_not_cached() {
298 let dir = TempDir::new().unwrap();
299 let mut manifest = Manifest::default();
300
301 let config = serde_json::json!({"repo": "org/dataset"});
302 manifest.mark_partial(
303 "source".to_string(),
304 config.clone(),
305 SourceStats {
306 total: 0,
307 real: 0,
308 ai: 0,
309 fetched_offset: 0,
310 },
311 );
312 manifest.save(dir.path()).unwrap();
313
314 let loaded = Manifest::load(dir.path()).unwrap();
315 assert!(matches!(
316 loaded.cache_status("source", &config, Some(100)),
317 CacheStatus::NotCached
318 ));
319 }
320
321 #[test]
322 fn test_manifest_partial_resumes_not_skips() {
323 let dir = TempDir::new().unwrap();
324 let mut manifest = Manifest::default();
325
326 let config = serde_json::json!({"repo": "org/dataset", "split": "train"});
327 manifest.mark_partial(
328 "hf:org/dataset".to_string(),
329 config.clone(),
330 SourceStats {
331 total: 500,
332 real: 250,
333 ai: 250,
334 fetched_offset: 600,
335 },
336 );
337 manifest.save(dir.path()).unwrap();
338
339 let loaded = Manifest::load(dir.path()).unwrap();
340 match loaded.cache_status("hf:org/dataset", &config, Some(1000)) {
341 CacheStatus::Partial(stats) => {
342 assert_eq!(stats.fetched_offset, 600);
343 assert_eq!(stats.total, 500);
344 }
345 other => panic!("expected Partial, got {other:?}"),
346 }
347 }
348
349 #[test]
350 fn test_manifest_done_is_not_partial() {
351 let dir = TempDir::new().unwrap();
352 let mut manifest = Manifest::default();
353
354 let config = serde_json::json!({"repo": "org/dataset"});
355 manifest.mark_done(
356 "src".to_string(),
357 config.clone(),
358 SourceStats {
359 total: 1000,
360 real: 500,
361 ai: 500,
362 fetched_offset: 1000,
363 },
364 );
365 manifest.save(dir.path()).unwrap();
366
367 let loaded = Manifest::load(dir.path()).unwrap();
368 match loaded.cache_status("src", &config, Some(1000)) {
369 CacheStatus::Done(stats) => assert_eq!(stats.total, 1000),
370 other => panic!("expected Done, got {other:?}"),
371 }
372 }
373
374 #[test]
375 fn cache_status_handles_mismatch_zero_partial_unknown_and_nearly_complete() {
376 let config = serde_json::json!({"repo": "org/dataset"});
377 let mut manifest = Manifest::default();
378
379 assert!(matches!(
380 manifest.cache_status("missing", &config, Some(100)),
381 CacheStatus::NotCached
382 ));
383
384 manifest.sources.insert(
385 "unknown".to_string(),
386 SourceEntry {
387 config: config.clone(),
388 stats: SourceStats::default(),
389 status: "unknown".to_string(),
390 },
391 );
392 assert!(matches!(
393 manifest.cache_status("unknown", &config, Some(100)),
394 CacheStatus::NotCached
395 ));
396
397 manifest.mark_partial(
398 "zero".to_string(),
399 config.clone(),
400 SourceStats {
401 total: 0,
402 real: 0,
403 ai: 0,
404 fetched_offset: 0,
405 },
406 );
407 assert!(matches!(
408 manifest.cache_status("zero", &config, Some(100)),
409 CacheStatus::NotCached
410 ));
411
412 manifest.mark_partial(
413 "almost".to_string(),
414 config.clone(),
415 SourceStats {
416 total: 995,
417 real: 995,
418 ai: 0,
419 fetched_offset: 995,
420 },
421 );
422 assert!(matches!(
423 manifest.cache_status("almost", &config, Some(1000)),
424 CacheStatus::Done(_)
425 ));
426
427 assert!(matches!(
428 manifest.cache_status("almost", &serde_json::json!({"other": true}), Some(1000)),
429 CacheStatus::NotCached
430 ));
431
432 manifest.mark_partial(
433 "unknown-max".to_string(),
434 config.clone(),
435 SourceStats {
436 total: 10,
437 real: 5,
438 ai: 5,
439 fetched_offset: 10,
440 },
441 );
442 assert!(matches!(
443 manifest.cache_status("unknown-max", &config, None),
444 CacheStatus::Partial(_)
445 ));
446 }
447
448 #[test]
449 fn manifest_load_reports_non_directory_output_path() {
450 let file = tempfile::NamedTempFile::new().unwrap();
451 let err = Manifest::load(file.path()).unwrap_err();
452 assert_eq!(err.code(), ErrorCode::Internal);
453 assert!(err.to_string().contains("manifest read failed"));
454 }
455
456 #[test]
457 fn manifest_load_rejects_invalid_json_and_oversized_files() {
458 let dir = TempDir::new().unwrap();
459 std::fs::write(dir.path().join(MANIFEST_FILE), b"{not json}").unwrap();
460 let err = Manifest::load(dir.path()).unwrap_err();
461 assert_eq!(err.code(), ErrorCode::InvalidInput);
462 assert!(err.to_string().contains("parse"));
463
464 let dir = TempDir::new().unwrap();
465 std::fs::write(
466 dir.path().join(MANIFEST_FILE),
467 vec![b' '; MAX_MANIFEST_BYTES as usize + 1],
468 )
469 .unwrap();
470 let err = Manifest::load(dir.path()).unwrap_err();
471 assert_eq!(err.code(), ErrorCode::InvalidInput);
472 assert!(err.to_string().contains("exceeded max"));
473
474 let dir = TempDir::new().unwrap();
475 std::fs::create_dir(dir.path().join(MANIFEST_FILE)).unwrap();
476 let err = Manifest::load(dir.path()).unwrap_err();
477 assert_eq!(err.code(), ErrorCode::Internal);
478 }
479
480 #[test]
481 fn manifest_save_errors_when_output_directory_is_missing_or_not_directory() {
482 let missing_parent = TempDir::new().unwrap().path().join("gone");
483 let manifest = Manifest::default();
484 let err = manifest.save(&missing_parent).unwrap_err();
485 assert_eq!(err.code(), ErrorCode::Internal);
486 assert!(err.to_string().contains("write"));
487
488 let dir = TempDir::new().unwrap();
489 let file = dir.path().join("not-dir");
490 std::fs::write(&file, b"x").unwrap();
491 let err = manifest.save(&file).unwrap_err();
492 assert_eq!(err.code(), ErrorCode::Internal);
493
494 let dir = TempDir::new().unwrap();
495 std::fs::create_dir(dir.path().join(MANIFEST_FILE)).unwrap();
496 let err = manifest.save(dir.path()).unwrap_err();
497 assert_eq!(err.code(), ErrorCode::Internal);
498 }
499}