Skip to main content

rskit_dataset/
item.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use rskit_errors::{AppError, AppResult, ErrorCode};
5
6use crate::{DataPayload, DatasetLimits, Label, MediaType};
7
8/// Capability an item must provide to flow through the generic collection engine.
9///
10/// The engine stays item-agnostic: it routes and counts items by [`label`](DatasetItem::label) and
11/// resumes sources from [`source_offset`](DatasetItem::source_offset), without knowing the concrete
12/// item type. Both blob samples ([`DataItem`]) and tabular records implement this trait.
13pub trait DatasetItem: Send + 'static {
14    /// Classification label used to route and count the item. Defaults to [`Label::Real`].
15    fn label(&self) -> Label {
16        Label::Real
17    }
18
19    /// Source-provided resume cursor observed after this item, if any.
20    fn source_offset(&self) -> Option<usize> {
21        None
22    }
23}
24
25/// A single data sample flowing through the dataset pipeline.
26#[derive(Debug, Clone)]
27pub struct DataItem {
28    /// Payload bytes or file reference.
29    payload: DataPayload,
30    /// Dataset label.
31    pub label: Label,
32    /// Media kind for the sample.
33    pub media_type: MediaType,
34    /// Logical source name.
35    pub source_name: String,
36    /// File extension including the leading dot.
37    pub extension: String,
38    /// String metadata attached to the sample.
39    pub metadata: HashMap<String, String>,
40    /// Source-reported resume cursor after this item, if available.
41    source_offset: Option<usize>,
42}
43
44impl DataItem {
45    /// Create an item from bounded in-memory bytes.
46    pub fn new(
47        bytes: Vec<u8>,
48        label: Label,
49        media_type: MediaType,
50        source_name: impl Into<String>,
51    ) -> AppResult<Self> {
52        Self::new_bytes(bytes, label, media_type, source_name)
53    }
54
55    /// Create an item from bounded in-memory bytes.
56    pub fn new_bytes(
57        bytes: Vec<u8>,
58        label: Label,
59        media_type: MediaType,
60        source_name: impl Into<String>,
61    ) -> AppResult<Self> {
62        Self::new_bytes_with_limits(
63            bytes,
64            label,
65            media_type,
66            source_name,
67            &DatasetLimits::default(),
68        )
69    }
70
71    /// Create an item from bounded in-memory bytes with explicit limits.
72    pub fn new_bytes_with_limits(
73        bytes: Vec<u8>,
74        label: Label,
75        media_type: MediaType,
76        source_name: impl Into<String>,
77        limits: &DatasetLimits,
78    ) -> AppResult<Self> {
79        Ok(Self {
80            payload: DataPayload::bytes(bytes, limits)?,
81            label,
82            media_type,
83            source_name: source_name.into(),
84            extension: ".jpg".to_string(),
85            metadata: HashMap::new(),
86            source_offset: None,
87        })
88    }
89
90    /// Create an item from a file path for streaming large payloads.
91    #[must_use]
92    pub fn new_file(
93        path: impl Into<PathBuf>,
94        label: Label,
95        media_type: MediaType,
96        source_name: impl Into<String>,
97    ) -> Self {
98        Self {
99            payload: DataPayload::file(path),
100            label,
101            media_type,
102            source_name: source_name.into(),
103            extension: ".bin".to_string(),
104            metadata: HashMap::new(),
105            source_offset: None,
106        }
107    }
108
109    /// Borrow the item payload.
110    #[must_use]
111    pub fn payload(&self) -> &DataPayload {
112        &self.payload
113    }
114
115    /// Replace the payload after validating it against explicit limits.
116    pub fn try_with_payload(
117        mut self,
118        payload: DataPayload,
119        limits: &DatasetLimits,
120    ) -> AppResult<Self> {
121        if payload.is_bytes() && payload.len()? > limits.max_in_memory_bytes as u64 {
122            return Err(AppError::new(
123                ErrorCode::InvalidInput,
124                format!(
125                    "in-memory dataset payload exceeds max_in_memory_bytes={}",
126                    limits.max_in_memory_bytes
127                ),
128            ));
129        }
130        self.payload = payload;
131        Ok(self)
132    }
133
134    /// Return the source-provided resume cursor after this item, if available.
135    #[must_use]
136    pub fn source_offset(&self) -> Option<usize> {
137        self.source_offset
138    }
139
140    /// Attach a source-provided resume cursor.
141    #[must_use]
142    pub fn with_source_offset(mut self, offset: usize) -> Self {
143        self.source_offset = Some(offset);
144        self
145    }
146
147    /// Set the output extension.
148    #[must_use]
149    pub fn with_extension(mut self, ext: impl Into<String>) -> Self {
150        self.extension = ext.into();
151        self
152    }
153
154    /// Attach string metadata.
155    #[must_use]
156    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
157        self.metadata.insert(key.into(), value.into());
158        self
159    }
160
161    /// Validate the item against configured limits and path safety rules.
162    pub fn validate(&self, limits: &DatasetLimits) -> AppResult<()> {
163        validate_extension(&self.extension)?;
164        let len = self.payload.len()?;
165        if self.payload.is_bytes() && len > limits.max_in_memory_bytes as u64 {
166            return Err(AppError::new(
167                ErrorCode::InvalidInput,
168                format!(
169                    "in-memory dataset payload is {len} bytes, exceeding max_in_memory_bytes={}",
170                    limits.max_in_memory_bytes
171                ),
172            ));
173        }
174        Ok(())
175    }
176
177    /// Write this item to `path` using the configured payload limits.
178    pub fn write_to_path(&self, path: &Path, limits: &DatasetLimits) -> AppResult<u64> {
179        self.validate(limits)?;
180        self.payload.write_to_path(path, limits)
181    }
182}
183
184fn validate_extension(extension: &str) -> AppResult<()> {
185    let extension = extension.trim_start_matches('.');
186    if extension.is_empty()
187        || extension.contains('/')
188        || extension.contains('\\')
189        || extension == "."
190        || extension == ".."
191        || extension.contains("..")
192    {
193        return Err(AppError::new(
194            ErrorCode::InvalidInput,
195            format!("invalid dataset item extension: {extension:?}"),
196        ));
197    }
198    rskit_validation::input::validate_safe_path(extension)
199}
200
201impl crate::DatasetItem for DataItem {
202    fn label(&self) -> Label {
203        self.label
204    }
205
206    fn source_offset(&self) -> Option<usize> {
207        self.source_offset
208    }
209}