Skip to main content

oximedia_proxy/
relink_proxy.rs

1#![allow(dead_code)]
2
3//! Proxy relinking engine for reconnecting proxies to moved or renamed source media.
4//!
5//! When source media files are moved, renamed, or migrated to a different
6//! storage volume, the proxy-to-original links become stale. This module
7//! provides tools to detect broken links and relink proxies to their
8//! new source locations.
9
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12
13/// Status of a single proxy link check.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum LinkHealth {
16    /// The link is valid; the source file exists at the expected path.
17    Valid,
18    /// The source file is missing at the expected path.
19    Broken,
20    /// The link was successfully repaired by relinking.
21    Relinked,
22    /// The link could not be repaired.
23    Unresolvable,
24}
25
26/// A record associating a proxy file with its original source media.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct ProxyLinkRecord {
29    /// Unique link identifier.
30    pub link_id: String,
31    /// Path to the proxy file.
32    pub proxy_path: PathBuf,
33    /// Expected path to the original source file.
34    pub source_path: PathBuf,
35    /// Current health status of the link.
36    pub health: LinkHealth,
37    /// File size of the source in bytes (for verification).
38    pub source_size_bytes: u64,
39    /// Optional checksum of the source for identity verification.
40    pub source_checksum: Option<String>,
41}
42
43impl ProxyLinkRecord {
44    /// Create a new link record.
45    pub fn new(link_id: &str, proxy: &str, source: &str) -> Self {
46        Self {
47            link_id: link_id.to_string(),
48            proxy_path: PathBuf::from(proxy),
49            source_path: PathBuf::from(source),
50            health: LinkHealth::Valid,
51            source_size_bytes: 0,
52            source_checksum: None,
53        }
54    }
55
56    /// Set the expected source file size.
57    pub fn with_source_size(mut self, bytes: u64) -> Self {
58        self.source_size_bytes = bytes;
59        self
60    }
61
62    /// Set the source checksum.
63    pub fn with_checksum(mut self, checksum: &str) -> Self {
64        self.source_checksum = Some(checksum.to_string());
65        self
66    }
67}
68
69/// A mapping rule that transforms old source paths to new ones.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct RelinkRule {
72    /// Prefix to strip from old paths.
73    pub old_prefix: String,
74    /// Prefix to prepend to produce new paths.
75    pub new_prefix: String,
76    /// Optional file extension filter (e.g., ".mxf").
77    pub extension_filter: Option<String>,
78}
79
80impl RelinkRule {
81    /// Create a new relink rule that maps one path prefix to another.
82    pub fn new(old_prefix: &str, new_prefix: &str) -> Self {
83        Self {
84            old_prefix: old_prefix.to_string(),
85            new_prefix: new_prefix.to_string(),
86            extension_filter: None,
87        }
88    }
89
90    /// Limit this rule to files with a specific extension.
91    pub fn with_extension(mut self, ext: &str) -> Self {
92        self.extension_filter = Some(ext.to_string());
93        self
94    }
95
96    /// Try to apply this rule to a source path, returning the new path if applicable.
97    pub fn apply(&self, source: &Path) -> Option<PathBuf> {
98        let source_str = source.to_string_lossy();
99        if !source_str.starts_with(&self.old_prefix) {
100            return None;
101        }
102        if let Some(ext) = &self.extension_filter {
103            if let Some(file_ext) = source.extension() {
104                let dot_ext = format!(".{}", file_ext.to_string_lossy());
105                if dot_ext != *ext {
106                    return None;
107                }
108            } else {
109                return None;
110            }
111        }
112        let remainder = &source_str[self.old_prefix.len()..];
113        Some(PathBuf::from(format!("{}{}", self.new_prefix, remainder)))
114    }
115}
116
117/// Result of a batch relink operation.
118#[derive(Debug, Clone)]
119pub struct RelinkReport {
120    /// Total number of links checked.
121    pub total_checked: usize,
122    /// Number of links that were valid before relinking.
123    pub already_valid: usize,
124    /// Number of links successfully relinked.
125    pub relinked: usize,
126    /// Number of links that could not be resolved.
127    pub unresolvable: usize,
128    /// Map of link_id to new source path for relinked entries.
129    pub relink_map: HashMap<String, PathBuf>,
130}
131
132/// Engine that manages proxy relinking operations.
133#[derive(Debug)]
134pub struct RelinkEngine {
135    /// Rules to apply in order.
136    rules: Vec<RelinkRule>,
137}
138
139impl RelinkEngine {
140    /// Create a new relink engine with no rules.
141    pub fn new() -> Self {
142        Self { rules: Vec::new() }
143    }
144
145    /// Add a relink rule.
146    pub fn add_rule(&mut self, rule: RelinkRule) {
147        self.rules.push(rule);
148    }
149
150    /// Return the number of rules configured.
151    pub fn rule_count(&self) -> usize {
152        self.rules.len()
153    }
154
155    /// Attempt to relink a single record by applying rules in order.
156    /// Returns the new path if a rule matched, or None.
157    pub fn try_relink(&self, record: &ProxyLinkRecord) -> Option<PathBuf> {
158        for rule in &self.rules {
159            if let Some(new_path) = rule.apply(&record.source_path) {
160                return Some(new_path);
161            }
162        }
163        None
164    }
165
166    /// Run relinking on a batch of records, updating their health status.
167    pub fn relink_batch(&self, records: &mut [ProxyLinkRecord]) -> RelinkReport {
168        let total_checked = records.len();
169        let mut already_valid = 0;
170        let mut relinked = 0;
171        let mut unresolvable = 0;
172        let mut relink_map = HashMap::new();
173
174        for record in records.iter_mut() {
175            if record.health == LinkHealth::Valid {
176                already_valid += 1;
177                continue;
178            }
179            if record.health == LinkHealth::Broken {
180                if let Some(new_path) = self.try_relink(record) {
181                    record.source_path = new_path.clone();
182                    record.health = LinkHealth::Relinked;
183                    relink_map.insert(record.link_id.clone(), new_path);
184                    relinked += 1;
185                } else {
186                    record.health = LinkHealth::Unresolvable;
187                    unresolvable += 1;
188                }
189            }
190        }
191
192        RelinkReport {
193            total_checked,
194            already_valid,
195            relinked,
196            unresolvable,
197            relink_map,
198        }
199    }
200
201    /// Check all records and mark broken links (source file missing).
202    /// This operates on in-memory path string checks only (no filesystem access).
203    pub fn mark_broken_by_prefix(records: &mut [ProxyLinkRecord], missing_prefix: &str) {
204        for record in records.iter_mut() {
205            let source_str = record.source_path.to_string_lossy();
206            if source_str.starts_with(missing_prefix) {
207                record.health = LinkHealth::Broken;
208            }
209        }
210    }
211
212    /// Extract the filename from a path for matching purposes.
213    pub fn filename(path: &Path) -> Option<String> {
214        path.file_name().map(|n| n.to_string_lossy().to_string())
215    }
216
217    /// Build a lookup map from filename to link records for fuzzy relinking.
218    pub fn build_filename_index(records: &[ProxyLinkRecord]) -> HashMap<String, Vec<usize>> {
219        let mut index: HashMap<String, Vec<usize>> = HashMap::new();
220        for (i, record) in records.iter().enumerate() {
221            if let Some(name) = Self::filename(&record.source_path) {
222                index.entry(name).or_default().push(i);
223            }
224        }
225        index
226    }
227}
228
229impl Default for RelinkEngine {
230    fn default() -> Self {
231        Self::new()
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn test_relink_rule_apply() {
241        let rule = RelinkRule::new("/old/volume/", "/new/volume/");
242        let result = rule.apply(Path::new("/old/volume/clips/a.mxf"));
243        assert_eq!(result, Some(PathBuf::from("/new/volume/clips/a.mxf")));
244    }
245
246    #[test]
247    fn test_relink_rule_no_match() {
248        let rule = RelinkRule::new("/old/volume/", "/new/volume/");
249        let result = rule.apply(Path::new("/other/path/a.mxf"));
250        assert!(result.is_none());
251    }
252
253    #[test]
254    fn test_relink_rule_with_extension() {
255        let rule = RelinkRule::new("/old/", "/new/").with_extension(".mxf");
256        let mxf = rule.apply(Path::new("/old/clip.mxf"));
257        let mp4 = rule.apply(Path::new("/old/clip.mp4"));
258        assert!(mxf.is_some());
259        assert!(mp4.is_none());
260    }
261
262    #[test]
263    fn test_link_record_new() {
264        let rec = ProxyLinkRecord::new("lk1", "/proxy/a.mp4", "/src/a.mxf");
265        assert_eq!(rec.link_id, "lk1");
266        assert_eq!(rec.health, LinkHealth::Valid);
267    }
268
269    #[test]
270    fn test_link_record_with_size() {
271        let rec =
272            ProxyLinkRecord::new("lk1", "/proxy/a.mp4", "/src/a.mxf").with_source_size(1_000_000);
273        assert_eq!(rec.source_size_bytes, 1_000_000);
274    }
275
276    #[test]
277    fn test_link_record_with_checksum() {
278        let rec = ProxyLinkRecord::new("lk1", "/proxy/a.mp4", "/src/a.mxf").with_checksum("abc123");
279        assert_eq!(rec.source_checksum, Some("abc123".to_string()));
280    }
281
282    #[test]
283    fn test_engine_try_relink() {
284        let mut engine = RelinkEngine::new();
285        engine.add_rule(RelinkRule::new("/old/", "/new/"));
286        let rec = ProxyLinkRecord::new("lk1", "/proxy/a.mp4", "/old/a.mxf");
287        let result = engine.try_relink(&rec);
288        assert_eq!(result, Some(PathBuf::from("/new/a.mxf")));
289    }
290
291    #[test]
292    fn test_engine_try_relink_no_match() {
293        let engine = RelinkEngine::new();
294        let rec = ProxyLinkRecord::new("lk1", "/proxy/a.mp4", "/unknown/a.mxf");
295        assert!(engine.try_relink(&rec).is_none());
296    }
297
298    #[test]
299    fn test_relink_batch() {
300        let mut engine = RelinkEngine::new();
301        engine.add_rule(RelinkRule::new("/old/", "/new/"));
302
303        let mut records = vec![
304            ProxyLinkRecord::new("lk1", "/proxy/a.mp4", "/old/a.mxf"),
305            ProxyLinkRecord::new("lk2", "/proxy/b.mp4", "/old/b.mxf"),
306            ProxyLinkRecord::new("lk3", "/proxy/c.mp4", "/mystery/c.mxf"),
307        ];
308        // Mark all as broken first
309        for r in &mut records {
310            r.health = LinkHealth::Broken;
311        }
312        let report = engine.relink_batch(&mut records);
313        assert_eq!(report.total_checked, 3);
314        assert_eq!(report.relinked, 2);
315        assert_eq!(report.unresolvable, 1);
316    }
317
318    #[test]
319    fn test_relink_batch_already_valid() {
320        let engine = RelinkEngine::new();
321        let mut records = vec![ProxyLinkRecord::new("lk1", "/proxy/a.mp4", "/src/a.mxf")];
322        let report = engine.relink_batch(&mut records);
323        assert_eq!(report.already_valid, 1);
324        assert_eq!(report.relinked, 0);
325    }
326
327    #[test]
328    fn test_mark_broken_by_prefix() {
329        let mut records = vec![
330            ProxyLinkRecord::new("lk1", "/proxy/a.mp4", "/dead/a.mxf"),
331            ProxyLinkRecord::new("lk2", "/proxy/b.mp4", "/alive/b.mxf"),
332        ];
333        RelinkEngine::mark_broken_by_prefix(&mut records, "/dead/");
334        assert_eq!(records[0].health, LinkHealth::Broken);
335        assert_eq!(records[1].health, LinkHealth::Valid);
336    }
337
338    #[test]
339    fn test_filename_extraction() {
340        assert_eq!(
341            RelinkEngine::filename(Path::new("/a/b/clip.mxf")),
342            Some("clip.mxf".to_string())
343        );
344    }
345
346    #[test]
347    fn test_build_filename_index() {
348        let records = vec![
349            ProxyLinkRecord::new("lk1", "/proxy/a.mp4", "/src/clip.mxf"),
350            ProxyLinkRecord::new("lk2", "/proxy/b.mp4", "/other/clip.mxf"),
351        ];
352        let index = RelinkEngine::build_filename_index(&records);
353        assert_eq!(
354            index.get("clip.mxf").expect("should succeed in test").len(),
355            2
356        );
357    }
358
359    #[test]
360    fn test_rule_count() {
361        let mut engine = RelinkEngine::new();
362        assert_eq!(engine.rule_count(), 0);
363        engine.add_rule(RelinkRule::new("/a/", "/b/"));
364        assert_eq!(engine.rule_count(), 1);
365    }
366
367    #[test]
368    fn test_default_engine() {
369        let engine = RelinkEngine::default();
370        assert_eq!(engine.rule_count(), 0);
371    }
372
373    #[test]
374    fn test_relink_report_map() {
375        let mut engine = RelinkEngine::new();
376        engine.add_rule(RelinkRule::new("/old/", "/new/"));
377        let mut records = vec![{
378            let mut r = ProxyLinkRecord::new("lk1", "/proxy/a.mp4", "/old/a.mxf");
379            r.health = LinkHealth::Broken;
380            r
381        }];
382        let report = engine.relink_batch(&mut records);
383        assert!(report.relink_map.contains_key("lk1"));
384        assert_eq!(
385            report
386                .relink_map
387                .get("lk1")
388                .expect("should succeed in test"),
389            &PathBuf::from("/new/a.mxf")
390        );
391    }
392}