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 ahash::AHashMap;
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: AHashMap<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: AHashMap::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    #[must_use]
74    pub fn get(&self, id: u32) -> Option<&str> {
75        self.links
76            .get(id as usize)
77            .and_then(|slot| slot.as_ref())
78            .map(|s| s.as_str())
79    }
80
81    /// Unregister a link by ID.
82    pub fn unregister(&mut self, id: u32) {
83        if id == 0 {
84            return;
85        }
86
87        let Some(slot) = self.links.get_mut(id as usize) else {
88            return;
89        };
90
91        if let Some(url) = slot.take() {
92            self.lookup.remove(&url);
93            self.free_list.push(id);
94        }
95    }
96
97    /// Clear all links.
98    pub fn clear(&mut self) {
99        self.links.clear();
100        self.links.push(None);
101        self.lookup.clear();
102        self.free_list.clear();
103    }
104
105    /// Number of registered links.
106    pub fn len(&self) -> usize {
107        self.links.iter().filter(|slot| slot.is_some()).count()
108    }
109
110    /// Check if the registry is empty.
111    #[inline]
112    pub fn is_empty(&self) -> bool {
113        self.len() == 0
114    }
115
116    /// Check if the registry contains a link ID.
117    #[inline]
118    pub fn contains(&self, id: u32) -> bool {
119        self.get(id).is_some()
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn register_and_get() {
129        let mut registry = LinkRegistry::new();
130        let id = registry.register("https://example.com");
131        assert_eq!(registry.get(id), Some("https://example.com"));
132    }
133
134    #[test]
135    fn deduplication() {
136        let mut registry = LinkRegistry::new();
137        let id1 = registry.register("https://example.com");
138        let id2 = registry.register("https://example.com");
139        assert_eq!(id1, id2);
140        assert_eq!(registry.len(), 1);
141    }
142
143    #[test]
144    fn multiple_urls() {
145        let mut registry = LinkRegistry::new();
146        let id1 = registry.register("https://one.com");
147        let id2 = registry.register("https://two.com");
148        assert_ne!(id1, id2);
149        assert_eq!(registry.get(id1), Some("https://one.com"));
150        assert_eq!(registry.get(id2), Some("https://two.com"));
151    }
152
153    #[test]
154    fn unregister_reuses_id() {
155        let mut registry = LinkRegistry::new();
156        let id = registry.register("https://example.com");
157        assert!(registry.contains(id));
158        registry.unregister(id);
159        assert!(!registry.contains(id));
160        let reused = registry.register("https://new.com");
161        assert_eq!(reused, id);
162    }
163
164    #[test]
165    fn clear() {
166        let mut registry = LinkRegistry::new();
167        registry.register("https://one.com");
168        registry.register("https://two.com");
169        assert_eq!(registry.len(), 2);
170        registry.clear();
171        assert!(registry.is_empty());
172    }
173
174    // --- Edge case tests ---
175
176    #[test]
177    fn id_zero_is_reserved() {
178        let registry = LinkRegistry::new();
179        assert_eq!(registry.get(0), None);
180    }
181
182    #[test]
183    fn unregister_zero_is_noop() {
184        let mut registry = LinkRegistry::new();
185        registry.register("https://example.com");
186        registry.unregister(0);
187        assert_eq!(registry.len(), 1);
188    }
189
190    #[test]
191    fn get_out_of_bounds_returns_none() {
192        let registry = LinkRegistry::new();
193        assert_eq!(registry.get(999), None);
194        assert_eq!(registry.get(u32::MAX), None);
195    }
196
197    #[test]
198    fn unregister_out_of_bounds_is_safe() {
199        let mut registry = LinkRegistry::new();
200        registry.unregister(999);
201        registry.unregister(u32::MAX);
202        // No panic, no effect
203        assert!(registry.is_empty());
204    }
205
206    #[test]
207    fn unregister_twice_is_safe() {
208        let mut registry = LinkRegistry::new();
209        let id = registry.register("https://example.com");
210        registry.unregister(id);
211        registry.unregister(id); // Second call is no-op
212        assert!(registry.is_empty());
213    }
214
215    #[test]
216    fn register_returns_nonzero() {
217        let mut registry = LinkRegistry::new();
218        for i in 0..20 {
219            let id = registry.register(&format!("https://example.com/{i}"));
220            assert_ne!(id, 0, "register must never return id 0");
221        }
222    }
223
224    #[test]
225    fn contains_after_unregister() {
226        let mut registry = LinkRegistry::new();
227        let id = registry.register("https://example.com");
228        assert!(registry.contains(id));
229        registry.unregister(id);
230        assert!(!registry.contains(id));
231    }
232
233    #[test]
234    fn contains_invalid_id() {
235        let registry = LinkRegistry::new();
236        assert!(!registry.contains(0));
237        assert!(!registry.contains(999));
238    }
239
240    #[test]
241    fn dedup_after_unregister_gets_new_id() {
242        let mut registry = LinkRegistry::new();
243        let id1 = registry.register("https://example.com");
244        registry.unregister(id1);
245        // Re-register same URL — lookup cleared, so gets new (reused) id
246        let id2 = registry.register("https://example.com");
247        assert_eq!(id2, id1); // Reuses freed slot
248        assert_eq!(registry.get(id2), Some("https://example.com"));
249        assert_eq!(registry.len(), 1);
250    }
251
252    #[test]
253    fn free_list_lifo_order() {
254        let mut registry = LinkRegistry::new();
255        let a = registry.register("https://a.com");
256        let b = registry.register("https://b.com");
257        let c = registry.register("https://c.com");
258
259        // Free in order a, b, c — free_list is [a, b, c]
260        registry.unregister(a);
261        registry.unregister(b);
262        registry.unregister(c);
263
264        // LIFO: next alloc pops c, then b, then a
265        let new1 = registry.register("https://new1.com");
266        assert_eq!(new1, c);
267        let new2 = registry.register("https://new2.com");
268        assert_eq!(new2, b);
269        let new3 = registry.register("https://new3.com");
270        assert_eq!(new3, a);
271    }
272
273    #[test]
274    fn len_tracks_operations() {
275        let mut registry = LinkRegistry::new();
276        assert_eq!(registry.len(), 0);
277
278        let id1 = registry.register("https://one.com");
279        assert_eq!(registry.len(), 1);
280
281        let id2 = registry.register("https://two.com");
282        assert_eq!(registry.len(), 2);
283
284        // Dedup doesn't increase len
285        registry.register("https://one.com");
286        assert_eq!(registry.len(), 2);
287
288        registry.unregister(id1);
289        assert_eq!(registry.len(), 1);
290
291        registry.unregister(id2);
292        assert_eq!(registry.len(), 0);
293        assert!(registry.is_empty());
294    }
295
296    #[test]
297    fn register_after_clear_works() {
298        let mut registry = LinkRegistry::new();
299        registry.register("https://one.com");
300        registry.register("https://two.com");
301        registry.clear();
302
303        let id = registry.register("https://fresh.com");
304        assert_ne!(id, 0);
305        assert_eq!(registry.get(id), Some("https://fresh.com"));
306        assert_eq!(registry.len(), 1);
307    }
308
309    #[test]
310    fn many_registrations() {
311        let mut registry = LinkRegistry::new();
312        let mut ids = Vec::new();
313        for i in 0..100 {
314            let url = format!("https://example.com/{i}");
315            ids.push(registry.register(&url));
316        }
317        assert_eq!(registry.len(), 100);
318
319        // All IDs unique and non-zero
320        for (i, &id) in ids.iter().enumerate() {
321            assert_ne!(id, 0);
322            let url = format!("https://example.com/{i}");
323            assert_eq!(registry.get(id), Some(url.as_str()));
324        }
325
326        // All IDs distinct
327        let mut sorted = ids.clone();
328        sorted.sort();
329        sorted.dedup();
330        assert_eq!(sorted.len(), ids.len());
331    }
332
333    // ================================================================
334    // Edge-case tests (bd-39nm2)
335    // ================================================================
336
337    #[test]
338    fn default_trait_creates_empty_registry() {
339        let registry = LinkRegistry::default();
340        assert!(registry.is_empty());
341        assert_eq!(registry.len(), 0);
342        assert_eq!(registry.get(0), None);
343    }
344
345    #[test]
346    fn clone_independence() {
347        let mut original = LinkRegistry::new();
348        let id = original.register("https://example.com");
349        let mut cloned = original.clone();
350
351        // Mutate clone
352        cloned.unregister(id);
353        assert!(!cloned.contains(id));
354
355        // Original unaffected
356        assert!(original.contains(id));
357        assert_eq!(original.get(id), Some("https://example.com"));
358    }
359
360    #[test]
361    fn debug_formatting() {
362        let mut registry = LinkRegistry::new();
363        registry.register("https://example.com");
364        let dbg = format!("{:?}", registry);
365        assert!(dbg.contains("LinkRegistry"));
366        assert!(dbg.contains("example.com"));
367    }
368
369    #[test]
370    fn register_empty_url() {
371        let mut registry = LinkRegistry::new();
372        let id = registry.register("");
373        assert_ne!(id, 0);
374        assert_eq!(registry.get(id), Some(""));
375        assert_eq!(registry.len(), 1);
376    }
377
378    #[test]
379    fn register_url_with_special_chars() {
380        let mut registry = LinkRegistry::new();
381        let url = "https://example.com/path?q=hello world&foo=bar#section";
382        let id = registry.register(url);
383        assert_eq!(registry.get(id), Some(url));
384    }
385
386    #[test]
387    fn register_url_with_unicode() {
388        let mut registry = LinkRegistry::new();
389        let url = "https://例え.jp/日本語";
390        let id = registry.register(url);
391        assert_eq!(registry.get(id), Some(url));
392    }
393
394    #[test]
395    fn register_very_long_url() {
396        let mut registry = LinkRegistry::new();
397        let url = format!("https://example.com/{}", "a".repeat(10_000));
398        let id = registry.register(&url);
399        assert_eq!(registry.get(id), Some(url.as_str()));
400    }
401
402    #[test]
403    fn is_empty_on_fresh_registry() {
404        let registry = LinkRegistry::new();
405        assert!(registry.is_empty());
406        assert_eq!(registry.len(), 0);
407    }
408
409    #[test]
410    fn is_empty_after_register_unregister() {
411        let mut registry = LinkRegistry::new();
412        let id = registry.register("https://test.com");
413        assert!(!registry.is_empty());
414        registry.unregister(id);
415        assert!(registry.is_empty());
416    }
417
418    #[test]
419    fn clear_multiple_times() {
420        let mut registry = LinkRegistry::new();
421        registry.register("https://a.com");
422        registry.clear();
423        assert!(registry.is_empty());
424        registry.clear(); // Double clear
425        assert!(registry.is_empty());
426
427        // Still usable after double clear
428        let id = registry.register("https://b.com");
429        assert_ne!(id, 0);
430        assert_eq!(registry.get(id), Some("https://b.com"));
431    }
432
433    #[test]
434    fn ids_sequential_when_no_free_list() {
435        let mut registry = LinkRegistry::new();
436        let id1 = registry.register("https://a.com");
437        let id2 = registry.register("https://b.com");
438        let id3 = registry.register("https://c.com");
439        assert_eq!(id1, 1);
440        assert_eq!(id2, 2);
441        assert_eq!(id3, 3);
442    }
443
444    #[test]
445    fn free_list_mixed_with_fresh_allocation() {
446        let mut registry = LinkRegistry::new();
447        let id1 = registry.register("https://a.com");
448        let id2 = registry.register("https://b.com");
449        let id3 = registry.register("https://c.com");
450
451        // Free id2 only
452        registry.unregister(id2);
453
454        // Next register reuses id2 (from free list)
455        let id4 = registry.register("https://d.com");
456        assert_eq!(id4, id2);
457
458        // Next register allocates fresh id4
459        let id5 = registry.register("https://e.com");
460        assert_eq!(id5, 4); // Fresh allocation
461        assert_eq!(registry.len(), 4); // a, c, d, e
462
463        // Verify all valid
464        assert_eq!(registry.get(id1), Some("https://a.com"));
465        assert_eq!(registry.get(id4), Some("https://d.com"));
466        assert_eq!(registry.get(id3), Some("https://c.com"));
467        assert_eq!(registry.get(id5), Some("https://e.com"));
468    }
469
470    #[test]
471    fn unregister_does_not_affect_others() {
472        let mut registry = LinkRegistry::new();
473        let id1 = registry.register("https://a.com");
474        let id2 = registry.register("https://b.com");
475        let id3 = registry.register("https://c.com");
476
477        registry.unregister(id2);
478
479        assert_eq!(registry.get(id1), Some("https://a.com"));
480        assert_eq!(registry.get(id2), None);
481        assert_eq!(registry.get(id3), Some("https://c.com"));
482        assert_eq!(registry.len(), 2);
483    }
484
485    #[test]
486    fn dedup_still_works_after_cycle() {
487        let mut registry = LinkRegistry::new();
488        let id1 = registry.register("https://a.com");
489        registry.unregister(id1);
490        let id2 = registry.register("https://a.com");
491        // Reuses slot
492        assert_eq!(id1, id2);
493        // Now dedup works for the re-registered URL
494        let id3 = registry.register("https://a.com");
495        assert_eq!(id2, id3);
496        assert_eq!(registry.len(), 1);
497    }
498
499    #[test]
500    fn register_all_freed_then_register_new() {
501        let mut registry = LinkRegistry::new();
502        let ids: Vec<u32> = (0..5)
503            .map(|i| registry.register(&format!("https://u{i}.com")))
504            .collect();
505
506        // Free all
507        for &id in &ids {
508            registry.unregister(id);
509        }
510        assert!(registry.is_empty());
511
512        // Register new URLs — should reuse freed IDs
513        let new_ids: Vec<u32> = (0..5)
514            .map(|i| registry.register(&format!("https://new{i}.com")))
515            .collect();
516        assert_eq!(registry.len(), 5);
517
518        // All new IDs should be from the original set (reused)
519        for &new_id in &new_ids {
520            assert!(ids.contains(&new_id));
521        }
522    }
523
524    #[test]
525    fn get_returns_none_after_clear() {
526        let mut registry = LinkRegistry::new();
527        let id = registry.register("https://example.com");
528        registry.clear();
529        assert_eq!(registry.get(id), None);
530    }
531
532    #[test]
533    fn contains_zero_always_false() {
534        let mut registry = LinkRegistry::new();
535        assert!(!registry.contains(0));
536        registry.register("https://example.com");
537        assert!(!registry.contains(0));
538    }
539
540    #[test]
541    fn clone_preserves_free_list() {
542        let mut registry = LinkRegistry::new();
543        let id1 = registry.register("https://a.com");
544        let _id2 = registry.register("https://b.com");
545        registry.unregister(id1);
546
547        let mut cloned = registry.clone();
548        // Clone should have id1 in free list
549        let id3 = cloned.register("https://c.com");
550        assert_eq!(id3, id1); // Reuses freed slot
551    }
552
553    mod property {
554        use super::*;
555        use proptest::prelude::*;
556
557        fn arb_url() -> impl Strategy<Value = String> {
558            "[a-z]{3,12}".prop_map(|s| format!("https://{s}.com"))
559        }
560
561        proptest! {
562            #![proptest_config(ProptestConfig::with_cases(256))]
563
564            /// Register/get roundtrip always returns the original URL.
565            #[test]
566            fn register_get_roundtrip(url in arb_url()) {
567                let mut registry = LinkRegistry::new();
568                let id = registry.register(&url);
569                prop_assert_ne!(id, 0);
570                prop_assert_eq!(registry.get(id), Some(url.as_str()));
571            }
572
573            /// Duplicate registration returns the same ID.
574            #[test]
575            fn dedup_same_id(url in arb_url()) {
576                let mut registry = LinkRegistry::new();
577                let id1 = registry.register(&url);
578                let id2 = registry.register(&url);
579                prop_assert_eq!(id1, id2);
580                prop_assert_eq!(registry.len(), 1);
581            }
582
583            /// Distinct URLs produce distinct IDs.
584            #[test]
585            fn distinct_urls_distinct_ids(count in 2usize..20) {
586                let mut registry = LinkRegistry::new();
587                let mut ids = Vec::new();
588                for i in 0..count {
589                    ids.push(registry.register(&format!("https://u{i}.com")));
590                }
591                for i in 0..ids.len() {
592                    for j in (i + 1)..ids.len() {
593                        prop_assert_ne!(ids[i], ids[j]);
594                    }
595                }
596            }
597
598            /// len tracks correctly through register/unregister cycles.
599            #[test]
600            fn len_invariant(n_register in 1usize..15, n_unregister in 0usize..15) {
601                let mut registry = LinkRegistry::new();
602                let mut ids = Vec::new();
603                for i in 0..n_register {
604                    ids.push(registry.register(&format!("https://r{i}.com")));
605                }
606                prop_assert_eq!(registry.len(), n_register);
607
608                let actual_unreg = n_unregister.min(n_register);
609                for id in &ids[..actual_unreg] {
610                    registry.unregister(*id);
611                }
612                prop_assert_eq!(registry.len(), n_register - actual_unreg);
613            }
614
615            /// Unregister + re-register reuses the freed slot.
616            #[test]
617            fn slot_reuse(url1 in arb_url(), url2 in arb_url()) {
618                let mut registry = LinkRegistry::new();
619                let id1 = registry.register(&url1);
620                registry.unregister(id1);
621                let id2 = registry.register(&url2);
622                prop_assert_eq!(id1, id2);
623                prop_assert_eq!(registry.get(id2), Some(url2.as_str()));
624            }
625
626            /// Clear resets everything; old IDs return None.
627            #[test]
628            fn clear_resets(count in 1usize..15) {
629                let mut registry = LinkRegistry::new();
630                let mut ids = Vec::new();
631                for i in 0..count {
632                    ids.push(registry.register(&format!("https://c{i}.com")));
633                }
634                registry.clear();
635                prop_assert!(registry.is_empty());
636                for id in &ids {
637                    prop_assert_eq!(registry.get(*id), None);
638                }
639            }
640        }
641    }
642}