Skip to main content

link_common/models/
file_ref.rs

1//! FileRef model for FILE datatype in the KalamDB SDK.
2//!
3//! This is the **canonical client-side** definition of a file reference.
4//! Every SDK (TypeScript via the wasm/JSON wrapper, Dart via flutter_rust_bridge)
5//! should derive its `FileRef` type from this struct rather than
6//! re-implementing parsing, URL generation, and utility methods.
7
8use serde::{Deserialize, Serialize};
9
10/// File reference stored as JSON in FILE columns.
11///
12/// Contains all metadata needed to locate and serve the file.
13/// The server stores this as a JSON string inside FILE-typed columns.
14///
15/// # JSON example
16///
17/// ```json
18/// {
19///   "id": "1234567890123456789",
20///   "sub": "f0001",
21///   "name": "document.pdf",
22///   "size": 1048576,
23///   "mime": "application/pdf",
24///   "sha256": "abc123..."
25/// }
26/// ```
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct FileRef {
29    /// Unique file identifier (Snowflake ID).
30    pub id: String,
31
32    /// Subfolder name (e.g., `"f0001"`, `"f0002"`).
33    pub sub: String,
34
35    /// Original filename (preserved for display/download).
36    pub name: String,
37
38    /// File size in bytes.
39    pub size: u64,
40
41    /// MIME type (e.g., `"image/png"`, `"application/pdf"`).
42    pub mime: String,
43
44    /// SHA-256 hash of file content (hex-encoded).
45    pub sha256: String,
46
47    /// Optional shard ID for shared tables.
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub shard: Option<u32>,
50}
51
52impl FileRef {
53    // ------------------------------------------------------------------
54    // Constructors / Parsing
55    // ------------------------------------------------------------------
56
57    /// Parse a `FileRef` from a raw JSON string (as stored in FILE columns).
58    pub fn from_json(json: &str) -> Option<Self> {
59        serde_json::from_str(json).ok()
60    }
61
62    /// Try to extract a `FileRef` from a [`serde_json::Value`].
63    ///
64    /// Handles both:
65    /// - A JSON **string** (the value is parsed as JSON)
66    /// - A JSON **object** (deserialized directly)
67    pub fn from_json_value(value: &serde_json::Value) -> Option<Self> {
68        match value {
69            serde_json::Value::String(s) => Self::from_json(s),
70            serde_json::Value::Object(_) => serde_json::from_value(value.clone()).ok(),
71            _ => None,
72        }
73    }
74
75    /// Serialize back to a JSON string.
76    pub fn to_json(&self) -> String {
77        serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
78    }
79
80    // ------------------------------------------------------------------
81    // URL generation
82    // ------------------------------------------------------------------
83
84    /// Full download URL for this file.
85    ///
86    /// ```text
87    /// {base_url}/v1/files/{namespace}/{table}/{sub}/{stored_name}
88    /// ```
89    pub fn download_url(&self, base_url: &str, namespace: &str, table: &str) -> String {
90        let base = base_url.trim_end_matches('/');
91        format!("{}/v1/files/{}/{}/{}/{}", base, namespace, table, self.sub, self.stored_name())
92    }
93
94    /// Relative HTTP path (no host) for this file.
95    ///
96    /// ```text
97    /// /v1/files/{namespace}/{table}/{sub}/{stored_name}
98    /// ```
99    pub fn relative_url(&self, namespace: &str, table: &str) -> String {
100        format!("/v1/files/{}/{}/{}/{}", namespace, table, self.sub, self.stored_name())
101    }
102
103    // ------------------------------------------------------------------
104    // Storage helpers (match backend `kalamdb-system` logic)
105    // ------------------------------------------------------------------
106
107    /// Stored filename on disk.
108    ///
109    /// Format: `{id}-{sanitized_name}.{ext}` or `{id}.{ext}` when the
110    /// original name contains only non-ASCII characters.
111    pub fn stored_name(&self) -> String {
112        let sanitized = Self::sanitize_filename(&self.name);
113        let ext = Self::extract_extension(&self.name);
114
115        if sanitized.is_empty() {
116            format!("{}.{}", self.id, ext)
117        } else {
118            format!("{}-{}.{}", self.id, sanitized, ext)
119        }
120    }
121
122    /// Relative path within the table folder.
123    ///
124    /// - User tables: `{sub}/{stored_name}`
125    /// - Shared tables with shard: `shard-{n}/{sub}/{stored_name}`
126    pub fn relative_path(&self) -> String {
127        let stored_name = self.stored_name();
128        match self.shard {
129            Some(shard_id) => format!("shard-{}/{}/{}", shard_id, self.sub, stored_name),
130            None => format!("{}/{}", self.sub, stored_name),
131        }
132    }
133
134    // ------------------------------------------------------------------
135    // MIME helpers
136    // ------------------------------------------------------------------
137
138    /// Returns `true` if the MIME type indicates an image.
139    pub fn is_image(&self) -> bool {
140        self.mime.starts_with("image/")
141    }
142
143    /// Returns `true` if the MIME type indicates a video.
144    pub fn is_video(&self) -> bool {
145        self.mime.starts_with("video/")
146    }
147
148    /// Returns `true` if the MIME type indicates audio.
149    pub fn is_audio(&self) -> bool {
150        self.mime.starts_with("audio/")
151    }
152
153    /// Returns `true` if the MIME type indicates a PDF.
154    pub fn is_pdf(&self) -> bool {
155        self.mime == "application/pdf"
156    }
157
158    /// Human-readable file type description.
159    ///
160    /// Examples: `"Image"`, `"Video"`, `"PDF Document"`, `"PNG File"`.
161    pub fn type_description(&self) -> String {
162        if self.is_image() {
163            return "Image".to_string();
164        }
165        if self.is_video() {
166            return "Video".to_string();
167        }
168        if self.is_audio() {
169            return "Audio".to_string();
170        }
171        if self.is_pdf() {
172            return "PDF Document".to_string();
173        }
174        // Extract subtype from MIME
175        if let Some((_type_part, subtype)) = self.mime.split_once('/') {
176            format!("{} File", subtype.to_uppercase())
177        } else {
178            "File".to_string()
179        }
180    }
181
182    // ------------------------------------------------------------------
183    // Size formatting
184    // ------------------------------------------------------------------
185
186    /// Format file size in human-readable units.
187    ///
188    /// Examples: `"0 B"`, `"256 KB"`, `"1.5 MB"`, `"3.2 GB"`.
189    pub fn format_size(&self) -> String {
190        const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
191        let mut size = self.size as f64;
192        let mut idx = 0;
193
194        while size >= 1024.0 && idx < UNITS.len() - 1 {
195            size /= 1024.0;
196            idx += 1;
197        }
198
199        if idx == 0 {
200            format!("{} {}", size as u64, UNITS[idx])
201        } else {
202            format!("{:.1} {}", size, UNITS[idx])
203        }
204    }
205
206    // ------------------------------------------------------------------
207    // Filename sanitization (mirrors backend logic)
208    // ------------------------------------------------------------------
209
210    /// Sanitize filename for storage (lowercase, ASCII-only, dashes).
211    fn sanitize_filename(name: &str) -> String {
212        let name_without_ext = name.rsplit_once('.').map(|(n, _)| n).unwrap_or(name);
213
214        let sanitized: String = name_without_ext
215            .chars()
216            .filter_map(|c| {
217                if c.is_ascii_alphanumeric() {
218                    Some(c.to_ascii_lowercase())
219                } else if c == ' ' || c == '_' || c == '-' {
220                    Some('-')
221                } else {
222                    None
223                }
224            })
225            .take(50)
226            .collect();
227
228        // Collapse multiple dashes, strip leading/trailing dashes.
229        let mut result = String::with_capacity(sanitized.len());
230        let mut last_was_dash = true;
231        for c in sanitized.chars() {
232            if c == '-' {
233                if !last_was_dash {
234                    result.push(c);
235                }
236                last_was_dash = true;
237            } else {
238                result.push(c);
239                last_was_dash = false;
240            }
241        }
242        result.trim_end_matches('-').to_string()
243    }
244
245    /// Extract file extension, defaulting to `"bin"`.
246    fn extract_extension(name: &str) -> String {
247        name.rsplit_once('.')
248            .map(|(_, ext)| {
249                let ext_lower = ext.to_ascii_lowercase();
250                if ext_lower.len() <= 10 && ext_lower.chars().all(|c| c.is_ascii_alphanumeric()) {
251                    ext_lower
252                } else {
253                    "bin".to_string()
254                }
255            })
256            .unwrap_or_else(|| "bin".to_string())
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn parse_from_json_string() {
266        let json = r#"{"id":"123","sub":"f0001","name":"test.png","size":1024,"mime":"image/png","sha256":"abc"}"#;
267        let fr = FileRef::from_json(json).unwrap();
268        assert_eq!(fr.id, "123");
269        assert_eq!(fr.sub, "f0001");
270        assert_eq!(fr.name, "test.png");
271        assert_eq!(fr.size, 1024);
272        assert!(fr.is_image());
273    }
274
275    #[test]
276    fn parse_from_json_value_object() {
277        let val = serde_json::json!({
278            "id": "456", "sub": "f0002", "name": "doc.pdf",
279            "size": 2048, "mime": "application/pdf", "sha256": "def"
280        });
281        let fr = FileRef::from_json_value(&val).unwrap();
282        assert!(fr.is_pdf());
283        assert_eq!(fr.type_description(), "PDF Document");
284    }
285
286    #[test]
287    fn parse_from_json_value_string() {
288        let inner = r#"{"id":"789","sub":"f0001","name":"a.txt","size":10,"mime":"text/plain","sha256":"x"}"#;
289        let val = serde_json::Value::String(inner.to_string());
290        let fr = FileRef::from_json_value(&val).unwrap();
291        assert_eq!(fr.id, "789");
292    }
293
294    #[test]
295    fn download_url_generation() {
296        let fr = FileRef {
297            id: "123".into(),
298            sub: "f0001".into(),
299            name: "t.png".into(),
300            size: 0,
301            mime: "image/png".into(),
302            sha256: String::new(),
303            shard: None,
304        };
305        assert_eq!(
306            fr.download_url("http://localhost:2900", "default", "users"),
307            "http://localhost:2900/v1/files/default/users/f0001/123-t.png"
308        );
309        assert_eq!(fr.relative_url("default", "users"), "/v1/files/default/users/f0001/123-t.png");
310    }
311
312    #[test]
313    fn format_size_units() {
314        let mk = |size: u64| FileRef {
315            id: String::new(),
316            sub: String::new(),
317            name: String::new(),
318            size,
319            mime: String::new(),
320            sha256: String::new(),
321            shard: None,
322        };
323        assert_eq!(mk(0).format_size(), "0 B");
324        assert_eq!(mk(512).format_size(), "512 B");
325        assert_eq!(mk(1024).format_size(), "1.0 KB");
326        assert_eq!(mk(1_048_576).format_size(), "1.0 MB");
327    }
328
329    #[test]
330    fn stored_name_and_path() {
331        let fr = FileRef {
332            id: "42".into(),
333            sub: "f0001".into(),
334            name: "My Document.pdf".into(),
335            size: 100,
336            mime: "application/pdf".into(),
337            sha256: String::new(),
338            shard: None,
339        };
340        assert_eq!(fr.stored_name(), "42-my-document.pdf");
341        assert_eq!(fr.relative_path(), "f0001/42-my-document.pdf");
342    }
343
344    #[test]
345    fn stored_name_with_shard() {
346        let fr = FileRef {
347            id: "42".into(),
348            sub: "f0001".into(),
349            name: "test.png".into(),
350            size: 100,
351            mime: "image/png".into(),
352            sha256: String::new(),
353            shard: Some(3),
354        };
355        assert_eq!(fr.relative_path(), "shard-3/f0001/42-test.png");
356    }
357
358    #[test]
359    fn cell_as_file() {
360        use super::super::kalam_cell_value::KalamCellValue;
361
362        // JSON object → FileRef
363        let cell = KalamCellValue::from(serde_json::json!({
364            "id": "1", "sub": "f0001", "name": "a.png",
365            "size": 10, "mime": "image/png", "sha256": "x"
366        }));
367        let fr = cell.as_file().unwrap();
368        assert_eq!(fr.id, "1");
369        assert!(fr.is_image());
370
371        // Non-file value → None
372        assert!(KalamCellValue::text("Alice").as_file().is_none());
373
374        // Null → None
375        assert!(KalamCellValue::null().as_file().is_none());
376    }
377}