Skip to main content

ftui_render/
link_registry.rs

1#![forbid(unsafe_code)]
2
3//! OSC 8 hyperlink registry.
4//!
5//! The `LinkRegistry` maps link IDs to URLs. This allows cells to store
6//! compact 24-bit link IDs instead of full URL strings.
7//!
8//! # Usage
9//!
10//! ```
11//! use ftui_render::link_registry::LinkRegistry;
12//!
13//! let mut registry = LinkRegistry::new();
14//! let id = registry.register("https://example.com");
15//! assert_eq!(registry.get(id), Some("https://example.com"));
16//! ```
17
18use std::collections::HashMap;
19
20const MAX_LINK_ID: u32 = 0x00FF_FFFF;
21
22/// Registry for OSC 8 hyperlink URLs.
23#[derive(Debug, Clone, Default)]
24pub struct LinkRegistry {
25    /// Link slots indexed by ID (0 reserved for "no link").
26    links: Vec<Option<String>>,
27    /// URL to ID lookup for deduplication.
28    lookup: HashMap<String, u32>,
29    /// Reusable IDs from removed links.
30    free_list: Vec<u32>,
31}
32
33impl LinkRegistry {
34    /// Create a new empty registry.
35    pub fn new() -> Self {
36        Self {
37            links: vec![None],
38            lookup: HashMap::new(),
39            free_list: Vec::new(),
40        }
41    }
42
43    /// Register a URL and return its link ID.
44    ///
45    /// If the URL is already registered, returns the existing ID.
46    pub fn register(&mut self, url: &str) -> u32 {
47        if let Some(&id) = self.lookup.get(url) {
48            return id;
49        }
50
51        let id = if let Some(id) = self.free_list.pop() {
52            id
53        } else {
54            let id = self.links.len() as u32;
55            debug_assert!(id <= MAX_LINK_ID, "link id overflow");
56            if id > MAX_LINK_ID {
57                return 0;
58            }
59            self.links.push(None);
60            id
61        };
62
63        if id == 0 || id > MAX_LINK_ID {
64            return 0;
65        }
66
67        self.links[id as usize] = Some(url.to_string());
68        self.lookup.insert(url.to_string(), id);
69        id
70    }
71
72    /// Get the URL for a link ID.
73    pub fn get(&self, id: u32) -> Option<&str> {
74        self.links
75            .get(id as usize)
76            .and_then(|slot| slot.as_ref())
77            .map(|s| s.as_str())
78    }
79
80    /// Unregister a link by ID.
81    pub fn unregister(&mut self, id: u32) {
82        if id == 0 {
83            return;
84        }
85
86        let Some(slot) = self.links.get_mut(id as usize) else {
87            return;
88        };
89
90        if let Some(url) = slot.take() {
91            self.lookup.remove(&url);
92            self.free_list.push(id);
93        }
94    }
95
96    /// Clear all links.
97    pub fn clear(&mut self) {
98        self.links.clear();
99        self.links.push(None);
100        self.lookup.clear();
101        self.free_list.clear();
102    }
103
104    /// Number of registered links.
105    pub fn len(&self) -> usize {
106        self.links.iter().filter(|slot| slot.is_some()).count()
107    }
108
109    /// Check if the registry is empty.
110    pub fn is_empty(&self) -> bool {
111        self.len() == 0
112    }
113
114    /// Check if the registry contains a link ID.
115    pub fn contains(&self, id: u32) -> bool {
116        self.get(id).is_some()
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn register_and_get() {
126        let mut registry = LinkRegistry::new();
127        let id = registry.register("https://example.com");
128        assert_eq!(registry.get(id), Some("https://example.com"));
129    }
130
131    #[test]
132    fn deduplication() {
133        let mut registry = LinkRegistry::new();
134        let id1 = registry.register("https://example.com");
135        let id2 = registry.register("https://example.com");
136        assert_eq!(id1, id2);
137        assert_eq!(registry.len(), 1);
138    }
139
140    #[test]
141    fn multiple_urls() {
142        let mut registry = LinkRegistry::new();
143        let id1 = registry.register("https://one.com");
144        let id2 = registry.register("https://two.com");
145        assert_ne!(id1, id2);
146        assert_eq!(registry.get(id1), Some("https://one.com"));
147        assert_eq!(registry.get(id2), Some("https://two.com"));
148    }
149
150    #[test]
151    fn unregister_reuses_id() {
152        let mut registry = LinkRegistry::new();
153        let id = registry.register("https://example.com");
154        assert!(registry.contains(id));
155        registry.unregister(id);
156        assert!(!registry.contains(id));
157        let reused = registry.register("https://new.com");
158        assert_eq!(reused, id);
159    }
160
161    #[test]
162    fn clear() {
163        let mut registry = LinkRegistry::new();
164        registry.register("https://one.com");
165        registry.register("https://two.com");
166        assert_eq!(registry.len(), 2);
167        registry.clear();
168        assert!(registry.is_empty());
169    }
170
171    // --- Edge case tests ---
172
173    #[test]
174    fn id_zero_is_reserved() {
175        let registry = LinkRegistry::new();
176        assert_eq!(registry.get(0), None);
177    }
178
179    #[test]
180    fn unregister_zero_is_noop() {
181        let mut registry = LinkRegistry::new();
182        registry.register("https://example.com");
183        registry.unregister(0);
184        assert_eq!(registry.len(), 1);
185    }
186
187    #[test]
188    fn get_out_of_bounds_returns_none() {
189        let registry = LinkRegistry::new();
190        assert_eq!(registry.get(999), None);
191        assert_eq!(registry.get(u32::MAX), None);
192    }
193
194    #[test]
195    fn unregister_out_of_bounds_is_safe() {
196        let mut registry = LinkRegistry::new();
197        registry.unregister(999);
198        registry.unregister(u32::MAX);
199        // No panic, no effect
200        assert!(registry.is_empty());
201    }
202
203    #[test]
204    fn unregister_twice_is_safe() {
205        let mut registry = LinkRegistry::new();
206        let id = registry.register("https://example.com");
207        registry.unregister(id);
208        registry.unregister(id); // Second call is no-op
209        assert!(registry.is_empty());
210    }
211
212    #[test]
213    fn register_returns_nonzero() {
214        let mut registry = LinkRegistry::new();
215        for i in 0..20 {
216            let id = registry.register(&format!("https://example.com/{i}"));
217            assert_ne!(id, 0, "register must never return id 0");
218        }
219    }
220
221    #[test]
222    fn contains_after_unregister() {
223        let mut registry = LinkRegistry::new();
224        let id = registry.register("https://example.com");
225        assert!(registry.contains(id));
226        registry.unregister(id);
227        assert!(!registry.contains(id));
228    }
229
230    #[test]
231    fn contains_invalid_id() {
232        let registry = LinkRegistry::new();
233        assert!(!registry.contains(0));
234        assert!(!registry.contains(999));
235    }
236
237    #[test]
238    fn dedup_after_unregister_gets_new_id() {
239        let mut registry = LinkRegistry::new();
240        let id1 = registry.register("https://example.com");
241        registry.unregister(id1);
242        // Re-register same URL — lookup cleared, so gets new (reused) id
243        let id2 = registry.register("https://example.com");
244        assert_eq!(id2, id1); // Reuses freed slot
245        assert_eq!(registry.get(id2), Some("https://example.com"));
246        assert_eq!(registry.len(), 1);
247    }
248
249    #[test]
250    fn free_list_lifo_order() {
251        let mut registry = LinkRegistry::new();
252        let a = registry.register("https://a.com");
253        let b = registry.register("https://b.com");
254        let c = registry.register("https://c.com");
255
256        // Free in order a, b, c — free_list is [a, b, c]
257        registry.unregister(a);
258        registry.unregister(b);
259        registry.unregister(c);
260
261        // LIFO: next alloc pops c, then b, then a
262        let new1 = registry.register("https://new1.com");
263        assert_eq!(new1, c);
264        let new2 = registry.register("https://new2.com");
265        assert_eq!(new2, b);
266        let new3 = registry.register("https://new3.com");
267        assert_eq!(new3, a);
268    }
269
270    #[test]
271    fn len_tracks_operations() {
272        let mut registry = LinkRegistry::new();
273        assert_eq!(registry.len(), 0);
274
275        let id1 = registry.register("https://one.com");
276        assert_eq!(registry.len(), 1);
277
278        let id2 = registry.register("https://two.com");
279        assert_eq!(registry.len(), 2);
280
281        // Dedup doesn't increase len
282        registry.register("https://one.com");
283        assert_eq!(registry.len(), 2);
284
285        registry.unregister(id1);
286        assert_eq!(registry.len(), 1);
287
288        registry.unregister(id2);
289        assert_eq!(registry.len(), 0);
290        assert!(registry.is_empty());
291    }
292
293    #[test]
294    fn register_after_clear_works() {
295        let mut registry = LinkRegistry::new();
296        registry.register("https://one.com");
297        registry.register("https://two.com");
298        registry.clear();
299
300        let id = registry.register("https://fresh.com");
301        assert_ne!(id, 0);
302        assert_eq!(registry.get(id), Some("https://fresh.com"));
303        assert_eq!(registry.len(), 1);
304    }
305
306    #[test]
307    fn many_registrations() {
308        let mut registry = LinkRegistry::new();
309        let mut ids = Vec::new();
310        for i in 0..100 {
311            let url = format!("https://example.com/{i}");
312            ids.push(registry.register(&url));
313        }
314        assert_eq!(registry.len(), 100);
315
316        // All IDs unique and non-zero
317        for (i, &id) in ids.iter().enumerate() {
318            assert_ne!(id, 0);
319            let url = format!("https://example.com/{i}");
320            assert_eq!(registry.get(id), Some(url.as_str()));
321        }
322
323        // All IDs distinct
324        let mut sorted = ids.clone();
325        sorted.sort();
326        sorted.dedup();
327        assert_eq!(sorted.len(), ids.len());
328    }
329
330    mod property {
331        use super::*;
332        use proptest::prelude::*;
333
334        fn arb_url() -> impl Strategy<Value = String> {
335            "[a-z]{3,12}".prop_map(|s| format!("https://{s}.com"))
336        }
337
338        proptest! {
339            #![proptest_config(ProptestConfig::with_cases(256))]
340
341            /// Register/get roundtrip always returns the original URL.
342            #[test]
343            fn register_get_roundtrip(url in arb_url()) {
344                let mut registry = LinkRegistry::new();
345                let id = registry.register(&url);
346                prop_assert_ne!(id, 0);
347                prop_assert_eq!(registry.get(id), Some(url.as_str()));
348            }
349
350            /// Duplicate registration returns the same ID.
351            #[test]
352            fn dedup_same_id(url in arb_url()) {
353                let mut registry = LinkRegistry::new();
354                let id1 = registry.register(&url);
355                let id2 = registry.register(&url);
356                prop_assert_eq!(id1, id2);
357                prop_assert_eq!(registry.len(), 1);
358            }
359
360            /// Distinct URLs produce distinct IDs.
361            #[test]
362            fn distinct_urls_distinct_ids(count in 2usize..20) {
363                let mut registry = LinkRegistry::new();
364                let mut ids = Vec::new();
365                for i in 0..count {
366                    ids.push(registry.register(&format!("https://u{i}.com")));
367                }
368                for i in 0..ids.len() {
369                    for j in (i + 1)..ids.len() {
370                        prop_assert_ne!(ids[i], ids[j]);
371                    }
372                }
373            }
374
375            /// len tracks correctly through register/unregister cycles.
376            #[test]
377            fn len_invariant(n_register in 1usize..15, n_unregister in 0usize..15) {
378                let mut registry = LinkRegistry::new();
379                let mut ids = Vec::new();
380                for i in 0..n_register {
381                    ids.push(registry.register(&format!("https://r{i}.com")));
382                }
383                prop_assert_eq!(registry.len(), n_register);
384
385                let actual_unreg = n_unregister.min(n_register);
386                for id in &ids[..actual_unreg] {
387                    registry.unregister(*id);
388                }
389                prop_assert_eq!(registry.len(), n_register - actual_unreg);
390            }
391
392            /// Unregister + re-register reuses the freed slot.
393            #[test]
394            fn slot_reuse(url1 in arb_url(), url2 in arb_url()) {
395                let mut registry = LinkRegistry::new();
396                let id1 = registry.register(&url1);
397                registry.unregister(id1);
398                let id2 = registry.register(&url2);
399                prop_assert_eq!(id1, id2);
400                prop_assert_eq!(registry.get(id2), Some(url2.as_str()));
401            }
402
403            /// Clear resets everything; old IDs return None.
404            #[test]
405            fn clear_resets(count in 1usize..15) {
406                let mut registry = LinkRegistry::new();
407                let mut ids = Vec::new();
408                for i in 0..count {
409                    ids.push(registry.register(&format!("https://c{i}.com")));
410                }
411                registry.clear();
412                prop_assert!(registry.is_empty());
413                for id in &ids {
414                    prop_assert_eq!(registry.get(*id), None);
415                }
416            }
417        }
418    }
419}