Skip to main content

oximedia_proxy/
linking.rs

1//! Proxy-to-original file linking and reconnection.
2//!
3//! Maintains an in-memory registry of proxy↔original file associations using
4//! a simple FNV-1a checksum for integrity validation, and provides a path-based
5//! reconnection heuristic.
6
7#![allow(dead_code)]
8#![allow(missing_docs)]
9
10// ---------------------------------------------------------------------------
11// FNV-1a helpers
12// ---------------------------------------------------------------------------
13
14/// Compute an FNV-1a 64-bit hash over the given byte slice.
15#[must_use]
16fn fnv1a_64(data: &[u8]) -> u64 {
17    const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
18    const PRIME: u64 = 0x0000_0100_0000_01b3;
19    let mut hash = OFFSET_BASIS;
20    for &byte in data {
21        hash ^= u64::from(byte);
22        hash = hash.wrapping_mul(PRIME);
23    }
24    hash
25}
26
27// ---------------------------------------------------------------------------
28// ProxyLink
29// ---------------------------------------------------------------------------
30
31/// An association between a proxy file and its high-resolution original.
32#[derive(Debug, Clone, PartialEq)]
33pub struct ProxyLink {
34    /// Path to the proxy file.
35    pub proxy_path: String,
36    /// Path to the original (high-resolution) file.
37    pub original_path: String,
38    /// FNV-1a checksum of the proxy content at link creation time.
39    pub checksum: u64,
40    /// Unix timestamp (milliseconds) when the link was created.
41    pub created_ms: u64,
42}
43
44impl ProxyLink {
45    /// Create a new proxy link.
46    #[must_use]
47    pub fn new(
48        proxy_path: impl Into<String>,
49        original_path: impl Into<String>,
50        checksum: u64,
51        created_ms: u64,
52    ) -> Self {
53        Self {
54            proxy_path: proxy_path.into(),
55            original_path: original_path.into(),
56            checksum,
57            created_ms,
58        }
59    }
60
61    /// Verify that `data` matches the stored checksum using FNV-1a hashing.
62    ///
63    /// Returns `true` when the computed hash equals [`Self::checksum`].
64    #[must_use]
65    pub fn is_valid_checksum(&self, data: &[u8]) -> bool {
66        fnv1a_64(data) == self.checksum
67    }
68}
69
70// ---------------------------------------------------------------------------
71// ProxyLinkRegistry
72// ---------------------------------------------------------------------------
73
74/// In-memory registry that maps proxy files to their originals.
75#[derive(Debug, Default)]
76pub struct ProxyLinkRegistry {
77    /// All registered links.
78    pub links: Vec<ProxyLink>,
79}
80
81impl ProxyLinkRegistry {
82    /// Create an empty registry.
83    #[must_use]
84    pub fn new() -> Self {
85        Self::default()
86    }
87
88    /// Register a proxy↔original association.
89    ///
90    /// If a link for the same proxy path already exists it is replaced.
91    pub fn register(&mut self, proxy: &str, original: &str, checksum: u64) {
92        // Remove any existing link for this proxy path.
93        self.links.retain(|l| l.proxy_path != proxy);
94        self.links
95            .push(ProxyLink::new(proxy, original, checksum, 0));
96    }
97
98    /// Find the original path for a given proxy path.
99    ///
100    /// Returns `None` if no link exists for the given proxy.
101    #[must_use]
102    pub fn find_original(&self, proxy: &str) -> Option<&str> {
103        self.links
104            .iter()
105            .find(|l| l.proxy_path == proxy)
106            .map(|l| l.original_path.as_str())
107    }
108
109    /// Find the proxy path for a given original path.
110    ///
111    /// Returns `None` if the original is not linked to any proxy.
112    #[must_use]
113    pub fn find_proxy(&self, original: &str) -> Option<&str> {
114        self.links
115            .iter()
116            .find(|l| l.original_path == original)
117            .map(|l| l.proxy_path.as_str())
118    }
119
120    /// Remove the link for a given proxy path.
121    ///
122    /// Returns `true` if a link was actually removed.
123    pub fn unlink(&mut self, proxy: &str) -> bool {
124        let before = self.links.len();
125        self.links.retain(|l| l.proxy_path != proxy);
126        self.links.len() < before
127    }
128
129    /// Returns `true` if the proxy path has a registered link.
130    #[must_use]
131    pub fn is_linked(&self, proxy: &str) -> bool {
132        self.links.iter().any(|l| l.proxy_path == proxy)
133    }
134}
135
136// ---------------------------------------------------------------------------
137// ReconnectResult
138// ---------------------------------------------------------------------------
139
140/// Outcome of a proxy reconnection attempt.
141#[derive(Debug, PartialEq)]
142pub enum ReconnectResult {
143    /// A unique matching file was found at the given path.
144    Found(String),
145    /// No matching file was found in the supplied search paths.
146    NotFound,
147    /// Multiple candidate files matched (ambiguous).
148    Ambiguous(Vec<String>),
149}
150
151// ---------------------------------------------------------------------------
152// ProxyReconnector
153// ---------------------------------------------------------------------------
154
155/// Reconnects a proxy to its original by scanning a set of search paths.
156pub struct ProxyReconnector;
157
158impl ProxyReconnector {
159    /// Attempt to reconnect `proxy` to its original by matching filename suffix.
160    ///
161    /// For each candidate in `search_paths`, the reconnector checks whether the
162    /// candidate path ends with the same filename (or sub-path) as `proxy`.
163    ///
164    /// # Returns
165    /// * [`ReconnectResult::Found`] – exactly one match.
166    /// * [`ReconnectResult::Ambiguous`] – more than one match.
167    /// * [`ReconnectResult::NotFound`] – no match.
168    #[must_use]
169    pub fn reconnect(proxy: &str, search_paths: &[String]) -> ReconnectResult {
170        // Use the file-name portion of the proxy path as the matching key.
171        let key = proxy.rsplit('/').next().unwrap_or(proxy);
172
173        let matches: Vec<String> = search_paths
174            .iter()
175            .filter(|p| p.ends_with(key))
176            .cloned()
177            .collect();
178
179        match matches.len() {
180            0 => ReconnectResult::NotFound,
181            1 => ReconnectResult::Found(
182                matches
183                    .into_iter()
184                    .next()
185                    .expect("invariant: matches.len() == 1 guarantees a first element"),
186            ),
187            _ => ReconnectResult::Ambiguous(matches),
188        }
189    }
190}
191
192// ---------------------------------------------------------------------------
193// Unit tests
194// ---------------------------------------------------------------------------
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn test_fnv1a_empty() {
202        // Empty data should still produce a deterministic hash.
203        let h1 = fnv1a_64(b"");
204        let h2 = fnv1a_64(b"");
205        assert_eq!(h1, h2);
206    }
207
208    #[test]
209    fn test_fnv1a_different_inputs() {
210        assert_ne!(fnv1a_64(b"hello"), fnv1a_64(b"world"));
211    }
212
213    #[test]
214    fn test_proxy_link_is_valid_checksum_pass() {
215        let data = b"test content";
216        let checksum = fnv1a_64(data);
217        let link = ProxyLink::new("proxy.mp4", "original.mov", checksum, 0);
218        assert!(link.is_valid_checksum(data));
219    }
220
221    #[test]
222    fn test_proxy_link_is_valid_checksum_fail() {
223        let link = ProxyLink::new("proxy.mp4", "original.mov", 0xdeadbeef, 0);
224        assert!(!link.is_valid_checksum(b"some data"));
225    }
226
227    #[test]
228    fn test_registry_register_and_find_original() {
229        let mut reg = ProxyLinkRegistry::new();
230        reg.register("p.mp4", "o.mov", 0);
231        assert_eq!(reg.find_original("p.mp4"), Some("o.mov"));
232    }
233
234    #[test]
235    fn test_registry_find_proxy() {
236        let mut reg = ProxyLinkRegistry::new();
237        reg.register("p.mp4", "o.mov", 0);
238        assert_eq!(reg.find_proxy("o.mov"), Some("p.mp4"));
239    }
240
241    #[test]
242    fn test_registry_find_missing_returns_none() {
243        let reg = ProxyLinkRegistry::new();
244        assert!(reg.find_original("does_not_exist.mp4").is_none());
245    }
246
247    #[test]
248    fn test_registry_unlink_existing() {
249        let mut reg = ProxyLinkRegistry::new();
250        reg.register("p.mp4", "o.mov", 0);
251        let removed = reg.unlink("p.mp4");
252        assert!(removed);
253        assert!(!reg.is_linked("p.mp4"));
254    }
255
256    #[test]
257    fn test_registry_unlink_missing_returns_false() {
258        let mut reg = ProxyLinkRegistry::new();
259        assert!(!reg.unlink("no_such.mp4"));
260    }
261
262    #[test]
263    fn test_registry_is_linked() {
264        let mut reg = ProxyLinkRegistry::new();
265        reg.register("p.mp4", "o.mov", 0);
266        assert!(reg.is_linked("p.mp4"));
267    }
268
269    #[test]
270    fn test_registry_register_replaces_existing() {
271        let mut reg = ProxyLinkRegistry::new();
272        reg.register("p.mp4", "original1.mov", 0);
273        reg.register("p.mp4", "original2.mov", 0);
274        assert_eq!(reg.find_original("p.mp4"), Some("original2.mov"));
275        assert_eq!(reg.links.len(), 1);
276    }
277
278    #[test]
279    fn test_reconnector_found() {
280        let paths = vec![
281            "/media/archive/clip001.mov".to_string(),
282            "/media/archive/clip002.mov".to_string(),
283        ];
284        let result = ProxyReconnector::reconnect("proxy/clip001.mov", &paths);
285        assert_eq!(
286            result,
287            ReconnectResult::Found("/media/archive/clip001.mov".to_string())
288        );
289    }
290
291    #[test]
292    fn test_reconnector_not_found() {
293        let paths = vec!["/media/archive/other.mov".to_string()];
294        let result = ProxyReconnector::reconnect("proxy/clip001.mov", &paths);
295        assert_eq!(result, ReconnectResult::NotFound);
296    }
297
298    #[test]
299    fn test_reconnector_ambiguous() {
300        let paths = vec![
301            "/drive1/clip001.mov".to_string(),
302            "/drive2/clip001.mov".to_string(),
303        ];
304        let result = ProxyReconnector::reconnect("proxy/clip001.mov", &paths);
305        assert!(matches!(result, ReconnectResult::Ambiguous(_)));
306    }
307}