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