1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10pub struct FileRef {
11 pub id: String,
13 pub sub: String,
15 pub name: String,
17 pub size: u64,
19 pub mime: String,
21 pub sha256: String,
23 #[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 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 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 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 pub fn from_json(json: &str) -> Option<Self> {
88 Self::try_from_json(json).ok()
89 }
90
91 pub fn try_from_json(json: &str) -> Result<Self, serde_json::Error> {
93 serde_json::from_str(json)
94 }
95
96 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 pub fn to_json(&self) -> String {
107 serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
108 }
109
110 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 pub fn relative_url(&self, namespace: &str, table: &str) -> String {
118 format!("/v1/files/{}/{}/{}/{}", namespace, table, self.sub, self.stored_name())
119 }
120
121 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 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 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 pub fn is_image(&self) -> bool {
151 self.mime.starts_with("image/")
152 }
153
154 pub fn is_video(&self) -> bool {
156 self.mime.starts_with("video/")
157 }
158
159 pub fn is_audio(&self) -> bool {
161 self.mime.starts_with("audio/")
162 }
163
164 pub fn is_pdf(&self) -> bool {
166 self.mime == "application/pdf"
167 }
168
169 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 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 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}