#![allow(dead_code)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AccessLevel {
Public,
Restricted,
Confidential,
Internal,
}
impl AccessLevel {
#[must_use]
pub fn can_access(&self, user_role: &str) -> bool {
match self {
Self::Public => true,
Self::Restricted => matches!(user_role, "admin" | "staff" | "user"),
Self::Internal => matches!(user_role, "admin" | "staff"),
Self::Confidential => user_role == "admin",
}
}
#[must_use]
pub const fn label(&self) -> &str {
match self {
Self::Public => "public",
Self::Restricted => "restricted",
Self::Confidential => "confidential",
Self::Internal => "internal",
}
}
}
#[derive(Clone, Debug)]
pub struct CatalogEntry {
pub id: String,
pub title: String,
pub description: String,
pub date_created_ms: u64,
pub format: String,
pub duration_secs: Option<f64>,
pub physical_location: Option<String>,
pub digital_path: Option<String>,
pub rights: String,
pub access_level: AccessLevel,
}
impl CatalogEntry {
#[must_use]
pub fn new(
id: impl Into<String>,
title: impl Into<String>,
format: impl Into<String>,
date_created_ms: u64,
rights: impl Into<String>,
access_level: AccessLevel,
) -> Self {
Self {
id: id.into(),
title: title.into(),
description: String::new(),
date_created_ms,
format: format.into(),
duration_secs: None,
physical_location: None,
digital_path: None,
rights: rights.into(),
access_level,
}
}
}
#[derive(Default)]
pub struct CatalogIndex {
entries: Vec<CatalogEntry>,
}
impl CatalogIndex {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, entry: CatalogEntry) {
self.entries.push(entry);
}
#[must_use]
pub fn search_by_title(&self, query: &str) -> Vec<&CatalogEntry> {
let q = query.to_lowercase();
self.entries
.iter()
.filter(|e| e.title.to_lowercase().contains(&q))
.collect()
}
#[must_use]
pub fn search_by_date_range(&self, start_ms: u64, end_ms: u64) -> Vec<&CatalogEntry> {
self.entries
.iter()
.filter(|e| e.date_created_ms >= start_ms && e.date_created_ms <= end_ms)
.collect()
}
#[must_use]
pub fn total_count(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn get_by_id(&self, id: &str) -> Option<&CatalogEntry> {
self.entries.iter().find(|e| e.id == id)
}
}
pub struct CatalogExport;
impl CatalogExport {
#[must_use]
pub fn to_csv(entries: &[CatalogEntry]) -> String {
let mut out =
String::from("id,title,format,date_created_ms,duration_secs,rights,access_level\n");
for e in entries {
let duration = e.duration_secs.map(|d| d.to_string()).unwrap_or_default();
out.push_str(&format!(
"{},{},{},{},{},{},{}\n",
csv_escape(&e.id),
csv_escape(&e.title),
csv_escape(&e.format),
e.date_created_ms,
duration,
csv_escape(&e.rights),
e.access_level.label(),
));
}
out
}
#[must_use]
pub fn to_oai_pmh(entries: &[CatalogEntry]) -> String {
let mut out = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<OAI-PMH xmlns=\"http://www.openarchives.org/OAI/2.0/\">\n\
<responseDate>2026-01-01T00:00:00Z</responseDate>\n\
<request verb=\"ListRecords\"/>\n\
<ListRecords>\n",
);
for e in entries {
out.push_str(" <record>\n <header>\n");
out.push_str(&format!(
" <identifier>{}</identifier>\n",
xml_escape(&e.id)
));
out.push_str(&format!(
" <datestamp>{}</datestamp>\n",
ms_to_iso8601(e.date_created_ms)
));
out.push_str(" </header>\n <metadata>\n <oai_dc:dc\n");
out.push_str(" xmlns:oai_dc=\"http://www.openarchives.org/OAI/2.0/oai_dc/\"\n");
out.push_str(" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n");
out.push_str(&format!(
" <dc:title>{}</dc:title>\n",
xml_escape(&e.title)
));
if !e.description.is_empty() {
out.push_str(&format!(
" <dc:description>{}</dc:description>\n",
xml_escape(&e.description)
));
}
out.push_str(&format!(
" <dc:format>{}</dc:format>\n",
xml_escape(&e.format)
));
out.push_str(&format!(
" <dc:rights>{}</dc:rights>\n",
xml_escape(&e.rights)
));
out.push_str(" </oai_dc:dc>\n </metadata>\n </record>\n");
}
out.push_str("</ListRecords>\n</OAI-PMH>");
out
}
}
pub struct CatalogImport;
impl CatalogImport {
#[must_use]
pub fn from_csv(csv: &str) -> Vec<CatalogEntry> {
let mut entries = Vec::new();
let mut lines = csv.lines();
if lines.next().is_none() {
return entries;
}
for line in lines {
let cols: Vec<&str> = line.splitn(7, ',').collect();
if cols.len() < 7 {
continue;
}
let id = csv_unescape(cols[0]);
let title = csv_unescape(cols[1]);
let format = csv_unescape(cols[2]);
let date_created_ms: u64 = cols[3].trim().parse().unwrap_or(0);
let duration_secs: Option<f64> = cols[4]
.trim()
.parse()
.ok()
.filter(|_| !cols[4].trim().is_empty());
let rights = csv_unescape(cols[5]);
let access_level = match cols[6].trim() {
"public" => AccessLevel::Public,
"restricted" => AccessLevel::Restricted,
"confidential" => AccessLevel::Confidential,
"internal" => AccessLevel::Internal,
_ => AccessLevel::Public,
};
let mut entry =
CatalogEntry::new(id, title, format, date_created_ms, rights, access_level);
entry.duration_secs = duration_secs;
entries.push(entry);
}
entries
}
}
fn csv_escape(s: &str) -> String {
if s.contains(',') || s.contains('\n') || s.contains('"') {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
fn csv_unescape(s: &str) -> String {
let s = s.trim();
if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 {
s[1..s.len() - 1].replace("\"\"", "\"")
} else {
s.to_string()
}
}
fn ms_to_iso8601(ms: u64) -> String {
let secs = ms / 1_000;
let days = secs / 86_400;
let year = 1970 + days / 365;
let day_of_year = days % 365;
let month = day_of_year / 30 + 1;
let day = day_of_year % 30 + 1;
format!("{year:04}-{month:02}-{day:02}T00:00:00Z")
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_entry(id: &str, title: &str, ms: u64) -> CatalogEntry {
CatalogEntry::new(id, title, "dpx", ms, "CC0", AccessLevel::Public)
}
#[test]
fn test_access_level_public() {
assert!(AccessLevel::Public.can_access("anyone"));
assert!(AccessLevel::Public.can_access("guest"));
}
#[test]
fn test_access_level_restricted() {
assert!(AccessLevel::Restricted.can_access("user"));
assert!(!AccessLevel::Restricted.can_access("guest"));
}
#[test]
fn test_access_level_internal() {
assert!(AccessLevel::Internal.can_access("staff"));
assert!(!AccessLevel::Internal.can_access("user"));
}
#[test]
fn test_access_level_confidential() {
assert!(AccessLevel::Confidential.can_access("admin"));
assert!(!AccessLevel::Confidential.can_access("staff"));
}
#[test]
fn test_catalog_index_add_and_count() {
let mut idx = CatalogIndex::new();
idx.add(sample_entry("a1", "Sunset Reel", 1_000_000));
idx.add(sample_entry("a2", "Night Scene", 2_000_000));
assert_eq!(idx.total_count(), 2);
}
#[test]
fn test_search_by_title() {
let mut idx = CatalogIndex::new();
idx.add(sample_entry("a1", "Sunset Reel", 1_000_000));
idx.add(sample_entry("a2", "Night Scene", 2_000_000));
idx.add(sample_entry("a3", "Sunset Beach", 3_000_000));
let results = idx.search_by_title("sunset");
assert_eq!(results.len(), 2);
}
#[test]
fn test_search_by_date_range() {
let mut idx = CatalogIndex::new();
idx.add(sample_entry("a1", "A", 1_000));
idx.add(sample_entry("a2", "B", 5_000));
idx.add(sample_entry("a3", "C", 9_000));
let results = idx.search_by_date_range(2_000, 8_000);
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "a2");
}
#[test]
fn test_catalog_export_csv_header() {
let entries = vec![sample_entry("id1", "My Film", 0)];
let csv = CatalogExport::to_csv(&entries);
assert!(csv.starts_with("id,title,format,"));
assert!(csv.contains("My Film"));
}
#[test]
fn test_catalog_export_oai_pmh() {
let entries = vec![sample_entry("oai:1", "Test", 86_400_000)];
let xml = CatalogExport::to_oai_pmh(&entries);
assert!(xml.contains("<OAI-PMH"));
assert!(xml.contains("<dc:title>Test</dc:title>"));
assert!(xml.contains("oai:1"));
}
#[test]
fn test_catalog_import_from_csv() {
let csv = "id,title,format,date_created_ms,duration_secs,rights,access_level\n\
film001,My Documentary,mp4,1700000000000,3600.5,CC-BY,public\n";
let entries = CatalogImport::from_csv(csv);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].id, "film001");
assert_eq!(entries[0].title, "My Documentary");
assert_eq!(entries[0].format, "mp4");
assert!((entries[0].duration_secs.unwrap() - 3600.5).abs() < 1e-6);
assert_eq!(entries[0].access_level, AccessLevel::Public);
}
#[test]
fn test_catalog_csv_roundtrip() {
let original = vec![
sample_entry("r1", "Film A", 1_000_000),
sample_entry("r2", "Film, B", 2_000_000),
];
let csv = CatalogExport::to_csv(&original);
let imported = CatalogImport::from_csv(&csv);
assert_eq!(imported.len(), 2);
assert_eq!(imported[0].id, original[0].id);
}
}
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AssetKind {
Video,
Audio,
Image,
Document,
Subtitle,
Sidecar,
}
impl AssetKind {
#[must_use]
pub const fn is_media(self) -> bool {
matches!(self, Self::Video | Self::Audio | Self::Image)
}
}
#[allow(dead_code)]
#[derive(Clone, Debug)]
pub struct AssetCatalogEntry {
pub id: u64,
pub path: String,
pub kind: AssetKind,
pub size_bytes: u64,
pub created_epoch: u64,
pub tags: Vec<String>,
}
impl AssetCatalogEntry {
#[must_use]
pub fn has_tag(&self, t: &str) -> bool {
self.tags.iter().any(|tag| tag == t)
}
}
#[allow(dead_code)]
#[derive(Default, Debug)]
pub struct ArchiveCatalog {
pub entries: Vec<AssetCatalogEntry>,
next_id: u64,
}
impl ArchiveCatalog {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add(
&mut self,
path: impl Into<String>,
kind: AssetKind,
size_bytes: u64,
created_epoch: u64,
tags: Vec<String>,
) -> u64 {
let id = self.next_id;
self.next_id += 1;
self.entries.push(AssetCatalogEntry {
id,
path: path.into(),
kind,
size_bytes,
created_epoch,
tags,
});
id
}
#[must_use]
pub fn find_by_id(&self, id: u64) -> Option<&AssetCatalogEntry> {
self.entries.iter().find(|e| e.id == id)
}
#[must_use]
pub fn find_by_tag(&self, tag: &str) -> Vec<&AssetCatalogEntry> {
self.entries.iter().filter(|e| e.has_tag(tag)).collect()
}
#[must_use]
pub fn search_path(&self, query: &str) -> Vec<&AssetCatalogEntry> {
let q = query.to_lowercase();
self.entries
.iter()
.filter(|e| e.path.to_lowercase().contains(&q))
.collect()
}
#[must_use]
pub fn total_size_bytes(&self) -> u64 {
self.entries.iter().map(|e| e.size_bytes).sum()
}
#[must_use]
pub fn kind_count(&self, kind: AssetKind) -> usize {
self.entries.iter().filter(|e| e.kind == kind).count()
}
}
#[cfg(test)]
mod asset_catalog_tests {
use super::*;
fn make_catalog() -> ArchiveCatalog {
let mut c = ArchiveCatalog::new();
c.add(
"videos/intro.mp4",
AssetKind::Video,
1_000_000,
1_000,
vec!["featured".into()],
);
c.add(
"audio/bg.wav",
AssetKind::Audio,
500_000,
2_000,
vec!["music".into(), "featured".into()],
);
c.add(
"images/thumb.jpg",
AssetKind::Image,
200_000,
3_000,
vec!["thumbnail".into()],
);
c.add(
"docs/readme.pdf",
AssetKind::Document,
50_000,
4_000,
vec![],
);
c
}
#[test]
fn test_asset_kind_is_media_video() {
assert!(AssetKind::Video.is_media());
}
#[test]
fn test_asset_kind_is_media_audio() {
assert!(AssetKind::Audio.is_media());
}
#[test]
fn test_asset_kind_not_media_document() {
assert!(!AssetKind::Document.is_media());
}
#[test]
fn test_asset_kind_not_media_sidecar() {
assert!(!AssetKind::Sidecar.is_media());
}
#[test]
fn test_has_tag_true() {
let c = make_catalog();
assert!(c.find_by_id(0).unwrap().has_tag("featured"));
}
#[test]
fn test_has_tag_false() {
let c = make_catalog();
assert!(!c.find_by_id(0).unwrap().has_tag("music"));
}
#[test]
fn test_find_by_id_present() {
let c = make_catalog();
assert!(c.find_by_id(2).is_some());
}
#[test]
fn test_find_by_id_missing() {
let c = make_catalog();
assert!(c.find_by_id(999).is_none());
}
#[test]
fn test_find_by_tag() {
let c = make_catalog();
let results = c.find_by_tag("featured");
assert_eq!(results.len(), 2);
}
#[test]
fn test_search_path_case_insensitive() {
let c = make_catalog();
let results = c.search_path("VIDEOS");
assert_eq!(results.len(), 1);
assert_eq!(results[0].path, "videos/intro.mp4");
}
#[test]
fn test_total_size_bytes() {
let c = make_catalog();
assert_eq!(c.total_size_bytes(), 1_750_000);
}
#[test]
fn test_kind_count_video() {
let c = make_catalog();
assert_eq!(c.kind_count(AssetKind::Video), 1);
}
#[test]
fn test_kind_count_zero() {
let c = make_catalog();
assert_eq!(c.kind_count(AssetKind::Subtitle), 0);
}
}