Skip to main content

kalamdb_commons/models/
file_ref.rs

1//! FileRef model for KalamDB FILE columns.
2//!
3//! FILE columns store this value as JSON. The JSON is intentionally table-agnostic:
4//! table identity is bound by callers when constructing download URLs.
5
6use serde::{Deserialize, Serialize};
7
8/// File reference stored as JSON in FILE columns.
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10pub struct FileRef {
11    /// Unique file identifier.
12    pub id: String,
13    /// Subfolder name, for example `"f0001"`.
14    pub sub: String,
15    /// Original filename preserved for display and download.
16    pub name: String,
17    /// File size in bytes.
18    pub size: u64,
19    /// MIME type.
20    pub mime: String,
21    /// SHA-256 hash of file content, hex encoded.
22    pub sha256: String,
23    /// Optional shard ID for shared tables.
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub shard: Option<u32>,
26}
27
28const SUBFOLDER_PREFIX: &str = "f";
29const SUBFOLDER_DIGITS: usize = 4;
30
31impl FileRef {
32    /// Create a new file reference.
33    pub fn new(
34        id: String,
35        subfolder: String,
36        name: String,
37        size: u64,
38        mime: String,
39        sha256: String,
40    ) -> Self {
41        Self {
42            id,
43            sub: subfolder,
44            name,
45            size,
46            mime,
47            sha256,
48            shard: None,
49        }
50    }
51
52    /// Create a new file reference with shared-table shard metadata.
53    pub fn with_shard(
54        id: String,
55        subfolder: String,
56        name: String,
57        size: u64,
58        mime: String,
59        sha256: String,
60        shard: u32,
61    ) -> Self {
62        Self {
63            id,
64            sub: subfolder,
65            name,
66            size,
67            mime,
68            sha256,
69            shard: Some(shard),
70        }
71    }
72
73    /// Create a partial file reference for download path validation.
74    pub fn partial(id: String, subfolder: String) -> Self {
75        Self {
76            id,
77            sub: subfolder,
78            name: String::new(),
79            size: 0,
80            mime: String::new(),
81            sha256: String::new(),
82            shard: None,
83        }
84    }
85
86    /// Parse a file reference from a raw JSON string.
87    pub fn from_json(json: &str) -> Option<Self> {
88        Self::try_from_json(json).ok()
89    }
90
91    /// Parse a file reference from a raw JSON string, preserving serde errors.
92    pub fn try_from_json(json: &str) -> Result<Self, serde_json::Error> {
93        serde_json::from_str(json)
94    }
95
96    /// Try to extract a file reference from a JSON value.
97    pub fn from_json_value(value: &serde_json::Value) -> Option<Self> {
98        match value {
99            serde_json::Value::String(s) => Self::from_json(s),
100            serde_json::Value::Object(_) => serde_json::from_value(value.clone()).ok(),
101            _ => None,
102        }
103    }
104
105    /// Serialize to JSON for storage in FILE columns.
106    pub fn to_json(&self) -> String {
107        serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
108    }
109
110    /// Full download URL for this file.
111    pub fn download_url(&self, base_url: &str, namespace: &str, table: &str) -> String {
112        let base = base_url.trim_end_matches('/');
113        format!("{}/v1/files/{}/{}/{}/{}", base, namespace, table, self.sub, self.stored_name())
114    }
115
116    /// Relative HTTP path for this file.
117    pub fn relative_url(&self, namespace: &str, table: &str) -> String {
118        format!("/v1/files/{}/{}/{}/{}", namespace, table, self.sub, self.stored_name())
119    }
120
121    /// Stored filename on disk.
122    pub fn stored_name(&self) -> String {
123        let sanitized = Self::sanitize_filename(&self.name);
124        let ext = Self::extract_extension(&self.name);
125
126        if sanitized.is_empty() {
127            format!("{}.{}", self.id, ext)
128        } else {
129            format!("{}-{}.{}", self.id, sanitized, ext)
130        }
131    }
132
133    /// Validate subfolder name format, for example `"f0001"`.
134    pub fn is_valid_subfolder(subfolder: &str) -> bool {
135        subfolder.len() == SUBFOLDER_PREFIX.len() + SUBFOLDER_DIGITS
136            && subfolder.starts_with(SUBFOLDER_PREFIX)
137            && subfolder[SUBFOLDER_PREFIX.len()..].chars().all(|c| c.is_ascii_digit())
138    }
139
140    /// Relative path within the table folder.
141    pub fn relative_path(&self) -> String {
142        let stored_name = self.stored_name();
143        match self.shard {
144            Some(shard_id) => format!("shard-{}/{}/{}", shard_id, self.sub, stored_name),
145            None => format!("{}/{}", self.sub, stored_name),
146        }
147    }
148
149    /// Returns true if the MIME type indicates an image.
150    pub fn is_image(&self) -> bool {
151        self.mime.starts_with("image/")
152    }
153
154    /// Returns true if the MIME type indicates a video.
155    pub fn is_video(&self) -> bool {
156        self.mime.starts_with("video/")
157    }
158
159    /// Returns true if the MIME type indicates audio.
160    pub fn is_audio(&self) -> bool {
161        self.mime.starts_with("audio/")
162    }
163
164    /// Returns true if the MIME type indicates a PDF.
165    pub fn is_pdf(&self) -> bool {
166        self.mime == "application/pdf"
167    }
168
169    /// Human-readable file type description.
170    pub fn type_description(&self) -> String {
171        if self.is_image() {
172            return "Image".to_string();
173        }
174        if self.is_video() {
175            return "Video".to_string();
176        }
177        if self.is_audio() {
178            return "Audio".to_string();
179        }
180        if self.is_pdf() {
181            return "PDF Document".to_string();
182        }
183        if let Some((_type_part, subtype)) = self.mime.split_once('/') {
184            format!("{} File", subtype.to_uppercase())
185        } else {
186            "File".to_string()
187        }
188    }
189
190    /// Format file size in human-readable units.
191    pub fn format_size(&self) -> String {
192        const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
193        let mut size = self.size as f64;
194        let mut idx = 0;
195
196        while size >= 1024.0 && idx < UNITS.len() - 1 {
197            size /= 1024.0;
198            idx += 1;
199        }
200
201        if idx == 0 {
202            format!("{} {}", size as u64, UNITS[idx])
203        } else {
204            format!("{:.1} {}", size, UNITS[idx])
205        }
206    }
207
208    /// Validate MIME type against an allowlist.
209    pub fn validate_mime_type(&self, allowed_mimes: &[String]) -> bool {
210        if allowed_mimes.is_empty() {
211            return true;
212        }
213        allowed_mimes.iter().any(|allowed| {
214            if allowed.ends_with("/*") {
215                let prefix = allowed.trim_end_matches("/*");
216                self.mime.starts_with(prefix)
217            } else {
218                &self.mime == allowed
219            }
220        })
221    }
222
223    fn sanitize_filename(name: &str) -> String {
224        let name_without_ext = name.rsplit_once('.').map(|(n, _)| n).unwrap_or(name);
225
226        let sanitized: String = name_without_ext
227            .chars()
228            .filter_map(|c| {
229                if c.is_ascii_alphanumeric() {
230                    Some(c.to_ascii_lowercase())
231                } else if c == ' ' || c == '_' || c == '-' {
232                    Some('-')
233                } else {
234                    None
235                }
236            })
237            .take(50)
238            .collect();
239
240        let mut result = String::with_capacity(sanitized.len());
241        let mut last_was_dash = true;
242        for c in sanitized.chars() {
243            if c == '-' {
244                if !last_was_dash {
245                    result.push(c);
246                }
247                last_was_dash = true;
248            } else {
249                result.push(c);
250                last_was_dash = false;
251            }
252        }
253        result.trim_end_matches('-').to_string()
254    }
255
256    fn extract_extension(name: &str) -> String {
257        name.rsplit_once('.')
258            .map(|(_, ext)| {
259                let ext_lower = ext.to_ascii_lowercase();
260                if ext_lower.len() <= 10 && ext_lower.chars().all(|c| c.is_ascii_alphanumeric()) {
261                    ext_lower
262                } else {
263                    "bin".to_string()
264                }
265            })
266            .unwrap_or_else(|| "bin".to_string())
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn json_shape_remains_table_agnostic() {
276        let file_ref = FileRef::new(
277            "123".to_string(),
278            "f0001".to_string(),
279            "test.pdf".to_string(),
280            1024,
281            "application/pdf".to_string(),
282            "abc123".to_string(),
283        );
284
285        let json = file_ref.to_json();
286        assert!(json.contains("\"id\":\"123\""));
287        assert!(!json.contains("namespace"));
288        assert!(!json.contains("table"));
289        assert_eq!(FileRef::from_json(&json), Some(file_ref));
290    }
291
292    #[test]
293    fn stored_name_and_relative_path_match_existing_shape() {
294        let file_ref = FileRef::with_shard(
295            "42".to_string(),
296            "f0001".to_string(),
297            "My Document.pdf".to_string(),
298            100,
299            "application/pdf".to_string(),
300            "hash".to_string(),
301            3,
302        );
303
304        assert_eq!(file_ref.stored_name(), "42-my-document.pdf");
305        assert_eq!(file_ref.relative_path(), "shard-3/f0001/42-my-document.pdf");
306    }
307
308    #[test]
309    fn parses_json_values_and_formats_metadata() {
310        let value = serde_json::json!({
311            "id": "1",
312            "sub": "f0001",
313            "name": "photo.png",
314            "size": 1048576,
315            "mime": "image/png",
316            "sha256": "hash"
317        });
318        let file_ref = FileRef::from_json_value(&value).expect("file ref");
319
320        assert!(file_ref.is_image());
321        assert_eq!(file_ref.type_description(), "Image");
322        assert_eq!(file_ref.format_size(), "1.0 MB");
323    }
324
325    #[test]
326    fn validates_subfolder_names() {
327        assert!(FileRef::is_valid_subfolder("f0001"));
328        assert!(!FileRef::is_valid_subfolder("f001"));
329        assert!(!FileRef::is_valid_subfolder("../x"));
330    }
331}