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