Skip to main content

rskit_dataset/
manifest.rs

1//! `.manifest.json` cache logic — skip re-downloading completed sources.
2//!
3//! Same format as the Python version for compatibility.
4
5use rskit_errors::{AppError, AppResult, ErrorCode};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::Path;
9
10/// Build manifest persisted to `.manifest.json` in the output directory.
11#[derive(Debug, Clone, Serialize, Deserialize, Default)]
12pub struct Manifest {
13    #[serde(default)]
14    /// Cache entries keyed by source name.
15    pub sources: HashMap<String, SourceEntry>,
16}
17
18/// Cache entry for a single source.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct SourceEntry {
21    /// Source cache key/configuration used when the entry was written.
22    pub config: serde_json::Value,
23    /// Source item statistics.
24    pub stats: SourceStats,
25    /// Cache status string (`done` or `partial`).
26    pub status: String,
27}
28
29/// Statistics for a completed source.
30#[derive(Debug, Clone, Serialize, Deserialize, Default)]
31pub struct SourceStats {
32    /// Total items collected for the source.
33    pub total: usize,
34    /// Real-labeled item count.
35    pub real: usize,
36    /// AI-generated item count.
37    pub ai: usize,
38    /// Source-reported resume cursor. Falls back to fetched source-item count for count-based sources.
39    #[serde(default)]
40    pub fetched_offset: usize,
41}
42
43const MANIFEST_FILE: &str = ".manifest.json";
44const MAX_MANIFEST_BYTES: u64 = 1024 * 1024;
45
46/// Result of checking whether a source has cached data.
47#[derive(Debug, Clone)]
48pub enum CacheStatus {
49    /// Source fully completed — skip entirely.
50    Done(SourceStats),
51    /// Source partially completed — resume from where it left off.
52    Partial(SourceStats),
53    /// No usable cache — fetch from scratch.
54    NotCached,
55}
56
57impl Manifest {
58    /// Load manifest from the output directory. Returns empty manifest if not found.
59    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    /// Save manifest to the output directory.
74    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    /// Check cache status with distinction between done (skip) and partial (resume).
100    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 we're within 1% of max (or ≤5 remaining), consider it done.
117                // Scanning 1000s of rows for 1-2 more items is wasteful.
118                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    /// Record a completed source.
131    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    /// Record a partially completed source (e.g., cancelled).
148    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}