1#![allow(dead_code)]
2
3use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum LinkHealth {
16 Valid,
18 Broken,
20 Relinked,
22 Unresolvable,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct ProxyLinkRecord {
29 pub link_id: String,
31 pub proxy_path: PathBuf,
33 pub source_path: PathBuf,
35 pub health: LinkHealth,
37 pub source_size_bytes: u64,
39 pub source_checksum: Option<String>,
41}
42
43impl ProxyLinkRecord {
44 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 pub fn with_source_size(mut self, bytes: u64) -> Self {
58 self.source_size_bytes = bytes;
59 self
60 }
61
62 pub fn with_checksum(mut self, checksum: &str) -> Self {
64 self.source_checksum = Some(checksum.to_string());
65 self
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct RelinkRule {
72 pub old_prefix: String,
74 pub new_prefix: String,
76 pub extension_filter: Option<String>,
78}
79
80impl RelinkRule {
81 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 pub fn with_extension(mut self, ext: &str) -> Self {
92 self.extension_filter = Some(ext.to_string());
93 self
94 }
95
96 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#[derive(Debug, Clone)]
119pub struct RelinkReport {
120 pub total_checked: usize,
122 pub already_valid: usize,
124 pub relinked: usize,
126 pub unresolvable: usize,
128 pub relink_map: HashMap<String, PathBuf>,
130}
131
132#[derive(Debug)]
134pub struct RelinkEngine {
135 rules: Vec<RelinkRule>,
137}
138
139impl RelinkEngine {
140 pub fn new() -> Self {
142 Self { rules: Vec::new() }
143 }
144
145 pub fn add_rule(&mut self, rule: RelinkRule) {
147 self.rules.push(rule);
148 }
149
150 pub fn rule_count(&self) -> usize {
152 self.rules.len()
153 }
154
155 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 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 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 pub fn filename(path: &Path) -> Option<String> {
214 path.file_name().map(|n| n.to_string_lossy().to_string())
215 }
216
217 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 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}