use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileRef {
pub id: String,
pub sub: String,
pub name: String,
pub size: u64,
pub mime: String,
pub sha256: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub shard: Option<u32>,
}
impl FileRef {
pub fn from_json(json: &str) -> Option<Self> {
serde_json::from_str(json).ok()
}
pub fn from_json_value(value: &serde_json::Value) -> Option<Self> {
match value {
serde_json::Value::String(s) => Self::from_json(s),
serde_json::Value::Object(_) => serde_json::from_value(value.clone()).ok(),
_ => None,
}
}
pub fn to_json(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
}
pub fn download_url(&self, base_url: &str, namespace: &str, table: &str) -> String {
let base = base_url.trim_end_matches('/');
format!("{}/v1/files/{}/{}/{}/{}", base, namespace, table, self.sub, self.stored_name())
}
pub fn relative_url(&self, namespace: &str, table: &str) -> String {
format!("/v1/files/{}/{}/{}/{}", namespace, table, self.sub, self.stored_name())
}
pub fn stored_name(&self) -> String {
let sanitized = Self::sanitize_filename(&self.name);
let ext = Self::extract_extension(&self.name);
if sanitized.is_empty() {
format!("{}.{}", self.id, ext)
} else {
format!("{}-{}.{}", self.id, sanitized, ext)
}
}
pub fn relative_path(&self) -> String {
let stored_name = self.stored_name();
match self.shard {
Some(shard_id) => format!("shard-{}/{}/{}", shard_id, self.sub, stored_name),
None => format!("{}/{}", self.sub, stored_name),
}
}
pub fn is_image(&self) -> bool {
self.mime.starts_with("image/")
}
pub fn is_video(&self) -> bool {
self.mime.starts_with("video/")
}
pub fn is_audio(&self) -> bool {
self.mime.starts_with("audio/")
}
pub fn is_pdf(&self) -> bool {
self.mime == "application/pdf"
}
pub fn type_description(&self) -> String {
if self.is_image() {
return "Image".to_string();
}
if self.is_video() {
return "Video".to_string();
}
if self.is_audio() {
return "Audio".to_string();
}
if self.is_pdf() {
return "PDF Document".to_string();
}
if let Some((_type_part, subtype)) = self.mime.split_once('/') {
format!("{} File", subtype.to_uppercase())
} else {
"File".to_string()
}
}
pub fn format_size(&self) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
let mut size = self.size as f64;
let mut idx = 0;
while size >= 1024.0 && idx < UNITS.len() - 1 {
size /= 1024.0;
idx += 1;
}
if idx == 0 {
format!("{} {}", size as u64, UNITS[idx])
} else {
format!("{:.1} {}", size, UNITS[idx])
}
}
fn sanitize_filename(name: &str) -> String {
let name_without_ext = name.rsplit_once('.').map(|(n, _)| n).unwrap_or(name);
let sanitized: String = name_without_ext
.chars()
.filter_map(|c| {
if c.is_ascii_alphanumeric() {
Some(c.to_ascii_lowercase())
} else if c == ' ' || c == '_' || c == '-' {
Some('-')
} else {
None
}
})
.take(50)
.collect();
let mut result = String::with_capacity(sanitized.len());
let mut last_was_dash = true;
for c in sanitized.chars() {
if c == '-' {
if !last_was_dash {
result.push(c);
}
last_was_dash = true;
} else {
result.push(c);
last_was_dash = false;
}
}
result.trim_end_matches('-').to_string()
}
fn extract_extension(name: &str) -> String {
name.rsplit_once('.')
.map(|(_, ext)| {
let ext_lower = ext.to_ascii_lowercase();
if ext_lower.len() <= 10 && ext_lower.chars().all(|c| c.is_ascii_alphanumeric()) {
ext_lower
} else {
"bin".to_string()
}
})
.unwrap_or_else(|| "bin".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_from_json_string() {
let json = r#"{"id":"123","sub":"f0001","name":"test.png","size":1024,"mime":"image/png","sha256":"abc"}"#;
let fr = FileRef::from_json(json).unwrap();
assert_eq!(fr.id, "123");
assert_eq!(fr.sub, "f0001");
assert_eq!(fr.name, "test.png");
assert_eq!(fr.size, 1024);
assert!(fr.is_image());
}
#[test]
fn parse_from_json_value_object() {
let val = serde_json::json!({
"id": "456", "sub": "f0002", "name": "doc.pdf",
"size": 2048, "mime": "application/pdf", "sha256": "def"
});
let fr = FileRef::from_json_value(&val).unwrap();
assert!(fr.is_pdf());
assert_eq!(fr.type_description(), "PDF Document");
}
#[test]
fn parse_from_json_value_string() {
let inner = r#"{"id":"789","sub":"f0001","name":"a.txt","size":10,"mime":"text/plain","sha256":"x"}"#;
let val = serde_json::Value::String(inner.to_string());
let fr = FileRef::from_json_value(&val).unwrap();
assert_eq!(fr.id, "789");
}
#[test]
fn download_url_generation() {
let fr = FileRef {
id: "123".into(),
sub: "f0001".into(),
name: "t.png".into(),
size: 0,
mime: "image/png".into(),
sha256: String::new(),
shard: None,
};
assert_eq!(
fr.download_url("http://localhost:2900", "default", "users"),
"http://localhost:2900/v1/files/default/users/f0001/123-t.png"
);
assert_eq!(fr.relative_url("default", "users"), "/v1/files/default/users/f0001/123-t.png");
}
#[test]
fn format_size_units() {
let mk = |size: u64| FileRef {
id: String::new(),
sub: String::new(),
name: String::new(),
size,
mime: String::new(),
sha256: String::new(),
shard: None,
};
assert_eq!(mk(0).format_size(), "0 B");
assert_eq!(mk(512).format_size(), "512 B");
assert_eq!(mk(1024).format_size(), "1.0 KB");
assert_eq!(mk(1_048_576).format_size(), "1.0 MB");
}
#[test]
fn stored_name_and_path() {
let fr = FileRef {
id: "42".into(),
sub: "f0001".into(),
name: "My Document.pdf".into(),
size: 100,
mime: "application/pdf".into(),
sha256: String::new(),
shard: None,
};
assert_eq!(fr.stored_name(), "42-my-document.pdf");
assert_eq!(fr.relative_path(), "f0001/42-my-document.pdf");
}
#[test]
fn stored_name_with_shard() {
let fr = FileRef {
id: "42".into(),
sub: "f0001".into(),
name: "test.png".into(),
size: 100,
mime: "image/png".into(),
sha256: String::new(),
shard: Some(3),
};
assert_eq!(fr.relative_path(), "shard-3/f0001/42-test.png");
}
#[test]
fn cell_as_file() {
use super::super::kalam_cell_value::KalamCellValue;
let cell = KalamCellValue::from(serde_json::json!({
"id": "1", "sub": "f0001", "name": "a.png",
"size": 10, "mime": "image/png", "sha256": "x"
}));
let fr = cell.as_file().unwrap();
assert_eq!(fr.id, "1");
assert!(fr.is_image());
assert!(KalamCellValue::text("Alice").as_file().is_none());
assert!(KalamCellValue::null().as_file().is_none());
}
}