Skip to main content

grafton_ndi/
finder.rs

1//! NDI source discovery and network browsing.
2
3use std::{
4    collections::{HashMap, HashSet},
5    ffi::{CStr, CString},
6    fmt::{self, Display, Formatter},
7    net::IpAddr,
8    ptr,
9    sync::Mutex,
10    time::{Duration, Instant},
11};
12
13use crate::{ndi_lib::*, to_ms_checked, Error, Result, NDI};
14
15/// Configuration for NDI source discovery.
16///
17/// Use the builder pattern to create instances with specific settings.
18///
19/// # Examples
20///
21/// ```
22/// use grafton_ndi::FinderOptions;
23///
24/// // Find all sources including local ones
25/// let finder = FinderOptions::builder()
26///     .show_local_sources(true)
27///     .build();
28///
29/// // Find sources in specific groups
30/// let finder = FinderOptions::builder()
31///     .groups("Public,Studio")
32///     .build();
33///
34/// // Find sources on specific hosts
35/// let finder = FinderOptions::builder()
36///     .extra_ips("192.168.1.100,10.0.0.100")
37///     .build();
38/// ```
39#[derive(Debug, Clone)]
40pub struct FinderOptions {
41    /// Whether to include local sources in discovery.
42    pub show_local_sources: bool,
43    /// Comma-separated list of groups to search (e.g., "Public,Private").
44    pub groups: Option<String>,
45    /// Additional comma-separated IP addresses to query.
46    pub extra_ips: Option<String>,
47}
48
49impl Default for FinderOptions {
50    fn default() -> Self {
51        Self::builder().build()
52    }
53}
54
55impl FinderOptions {
56    /// Create a builder for configuring find options
57    pub fn builder() -> FinderOptionsBuilder {
58        FinderOptionsBuilder::new()
59    }
60}
61
62/// Builder for configuring FinderOptions with ergonomic method chaining
63#[derive(Debug, Clone)]
64pub struct FinderOptionsBuilder {
65    show_local_sources: Option<bool>,
66    groups: Option<String>,
67    extra_ips: Option<String>,
68}
69
70impl FinderOptionsBuilder {
71    /// Creates a new builder with default settings.
72    ///
73    /// Default settings:
74    /// - `show_local_sources`: `true`
75    /// - `groups`: `None` (use SDK-configured default groups)
76    /// - `extra_ips`: `None` (no additional IPs)
77    pub fn new() -> Self {
78        Self {
79            show_local_sources: None,
80            groups: None,
81            extra_ips: None,
82        }
83    }
84
85    /// Configure whether to show local sources
86    #[must_use]
87    pub fn show_local_sources(mut self, show: bool) -> Self {
88        self.show_local_sources = Some(show);
89        self
90    }
91
92    /// Set the groups to search
93    #[must_use]
94    pub fn groups<S: Into<String>>(mut self, groups: S) -> Self {
95        self.groups = Some(groups.into());
96        self
97    }
98
99    /// Set extra IPs to search
100    #[must_use]
101    pub fn extra_ips<S: Into<String>>(mut self, ips: S) -> Self {
102        self.extra_ips = Some(ips.into());
103        self
104    }
105
106    /// Build the FinderOptions
107    #[must_use]
108    pub fn build(self) -> FinderOptions {
109        FinderOptions {
110            show_local_sources: self.show_local_sources.unwrap_or(true),
111            groups: self.groups,
112            extra_ips: self.extra_ips,
113        }
114    }
115}
116
117impl Default for FinderOptionsBuilder {
118    fn default() -> Self {
119        Self::new()
120    }
121}
122
123/// Discovers NDI sources on the network.
124///
125/// `Finder` provides methods to discover and monitor NDI sources. It maintains
126/// a background thread that continuously updates the list of available sources.
127///
128/// # Examples
129///
130/// ```no_run
131/// # use grafton_ndi::{NDI, FinderOptions, Finder};
132/// # use std::time::Duration;
133/// # fn main() -> Result<(), grafton_ndi::Error> {
134/// let ndi = NDI::new()?;
135/// let options = FinderOptions::builder().show_local_sources(true).build();
136/// let finder = Finder::new(&ndi, &options)?;
137///
138/// // Wait for initial discovery
139/// if finder.wait_for_sources(Duration::from_secs(5))? {
140///     let sources = finder.current_sources()?;
141///     for source in sources {
142///         println!("Found: {}", source);
143///     }
144/// }
145/// # Ok(())
146/// # }
147/// ```
148pub struct Finder {
149    instance: NDIlib_find_instance_t,
150    // SDK snapshots and their strings expire on the next snapshot call.
151    sources_lock: Mutex<()>,
152    _groups: Option<CString>,    // Hold ownership of CStrings
153    _extra_ips: Option<CString>, // to ensure they outlive SDK usage
154    _ndi: NDI,
155}
156
157impl Finder {
158    /// Creates a new source finder with the specified settings.
159    ///
160    /// # Arguments
161    ///
162    /// * `ndi` - The NDI instance (cloned internally to keep the runtime alive)
163    /// * `settings` - Configuration for source discovery
164    ///
165    /// # Errors
166    ///
167    /// Returns an error if the finder cannot be created, typically due to
168    /// invalid settings or network issues.
169    pub fn new(ndi: &NDI, settings: &FinderOptions) -> Result<Self> {
170        let groups_cstr = settings
171            .groups
172            .as_deref()
173            .map(CString::new)
174            .transpose()
175            .map_err(Error::InvalidCString)?;
176        let extra_ips_cstr = settings
177            .extra_ips
178            .as_deref()
179            .map(CString::new)
180            .transpose()
181            .map_err(Error::InvalidCString)?;
182
183        let create_settings = NDIlib_find_create_t {
184            show_local_sources: settings.show_local_sources,
185            p_groups: groups_cstr.as_ref().map_or(ptr::null(), |s| s.as_ptr()),
186            p_extra_ips: extra_ips_cstr.as_ref().map_or(ptr::null(), |s| s.as_ptr()),
187        };
188
189        let instance = unsafe { NDIlib_find_create_v2(&create_settings) };
190        if instance.is_null() {
191            return Err(Error::InitializationFailed(
192                "NDIlib_find_create_v2 failed".into(),
193            ));
194        }
195        Ok(Self {
196            instance,
197            sources_lock: Mutex::new(()),
198            _groups: groups_cstr,
199            _extra_ips: extra_ips_cstr,
200            _ndi: ndi.clone(),
201        })
202    }
203
204    /// Waits for the source list to change.
205    ///
206    /// This method blocks until the list of discovered sources changes or the
207    /// timeout expires. Use this to efficiently monitor for source changes.
208    ///
209    /// # Arguments
210    ///
211    /// * `timeout` - Maximum time to wait ([`Duration::ZERO`] = no wait).
212    ///   Must not exceed [`crate::MAX_TIMEOUT`] (~49.7 days).
213    ///
214    /// # Returns
215    ///
216    /// `true` if the source list changed, `false` if the timeout expired.
217    ///
218    /// # Errors
219    ///
220    /// Returns [`Error::InvalidConfiguration`] if `timeout` exceeds [`crate::MAX_TIMEOUT`].
221    ///
222    /// # Examples
223    ///
224    /// ```no_run
225    /// # use grafton_ndi::{NDI, FinderOptions, Finder};
226    /// # use std::time::Duration;
227    /// # fn main() -> Result<(), grafton_ndi::Error> {
228    /// # let ndi = NDI::new()?;
229    /// # let finder = Finder::new(&ndi, &FinderOptions::default())?;
230    /// // Wait up to 5 seconds for changes
231    /// if finder.wait_for_sources(Duration::from_secs(5))? {
232    ///     println!("Source list changed!");
233    /// }
234    /// # Ok(())
235    /// # }
236    /// ```
237    pub fn wait_for_sources(&self, timeout: Duration) -> Result<bool> {
238        let timeout_ms = to_ms_checked(timeout)?;
239        Ok(unsafe { NDIlib_find_wait_for_sources(self.instance, timeout_ms) })
240    }
241
242    /// Returns the current list of discovered sources (snapshot).
243    ///
244    /// This method uses `NDIlib_find_get_current_sources` which provides a snapshot
245    /// of the current source list without any additional network discovery.
246    /// Concurrent snapshots on this finder are serialized until all source names
247    /// and addresses have been copied into owned Rust values.
248    ///
249    /// Available since NDI SDK 6.0.
250    ///
251    /// # Returns
252    ///
253    /// A vector of currently known sources. May be empty if no sources are found.
254    ///
255    /// # Examples
256    ///
257    /// ```no_run
258    /// # use grafton_ndi::{NDI, FinderOptions, Finder};
259    /// # fn main() -> Result<(), grafton_ndi::Error> {
260    /// # let ndi = NDI::new()?;
261    /// # let finder = Finder::new(&ndi, &FinderOptions::default())?;
262    /// // Get current snapshot of sources
263    /// let sources = finder.current_sources()?;
264    ///
265    /// for source in sources {
266    ///     println!("Current source: {}", source);
267    /// }
268    /// # Ok(())
269    /// # }
270    /// ```
271    pub fn current_sources(&self) -> Result<Vec<Source>> {
272        // SAFETY: the SDK returns a live snapshot guarded by sources_lock until
273        // current_sources_with completes its deep copy.
274        unsafe {
275            self.current_sources_with(
276                |num_sources| NDIlib_find_get_current_sources(self.instance, num_sources),
277                |source| Source::try_from_raw(source),
278            )
279        }
280    }
281
282    // Keep acquisition and conversion in one critical section: the SDK owns both
283    // the array and its strings, so locking only the SDK call is insufficient.
284    /// # Safety
285    /// `fetch` must return a live array of the reported count, with readable
286    /// source descriptors and C strings until all copies finish under the lock.
287    unsafe fn current_sources_with(
288        &self,
289        fetch: impl FnOnce(&mut u32) -> *const NDIlib_source_t,
290        mut copy: impl FnMut(*const NDIlib_source_t) -> Result<Source>,
291    ) -> Result<Vec<Source>> {
292        let _snapshot = self
293            .sources_lock
294            .lock()
295            .unwrap_or_else(|poisoned| poisoned.into_inner());
296        let mut num_sources = 0;
297        let sources_ptr = fetch(&mut num_sources);
298        if sources_ptr.is_null() {
299            return Ok(vec![]);
300        }
301
302        // Convert each source, skipping any that fail null checks
303        let mut sources = Vec::with_capacity(num_sources as usize);
304        for i in 0..num_sources {
305            let source_ptr = unsafe { sources_ptr.add(i as usize) };
306            match copy(source_ptr) {
307                Ok(source) => sources.push(source),
308                Err(_e) => {
309                    // Skip invalid sources (null pointers from SDK)
310                    // This is a defensive measure - the SDK should not return null entries,
311                    // but we handle it gracefully if it does
312                    #[cfg(debug_assertions)]
313                    eprintln!("Warning: Skipping invalid source at index {i}: {_e}");
314                }
315            }
316        }
317        Ok(sources)
318    }
319
320    /// Poll discovery until `accept` selects a value or `timeout` elapses.
321    ///
322    /// After each source-list snapshot, `accept` is handed the current sources
323    /// and may return `Some` to stop immediately; otherwise this blocks on the
324    /// next source-list change and retries until the deadline. A single
325    /// [`wait_for_sources`](Self::wait_for_sources) returns on the *first*
326    /// change, yielding only a partial snapshot when discovery is staggered
327    /// (unicast / IP-hint-only networks where responders trickle in across
328    /// several notifications); polling to the deadline makes resolution robust
329    /// against that while still returning the instant `accept` is satisfied.
330    ///
331    /// Returns `Ok(None)` if the deadline passes without `accept` selecting a
332    /// value.
333    fn poll_until_deadline<T>(
334        &self,
335        timeout: Duration,
336        mut accept: impl FnMut(Vec<Source>) -> Option<T>,
337    ) -> Result<Option<T>> {
338        // Validate up front so an over-long timeout is rejected even when the
339        // first snapshot already satisfies `accept`.
340        to_ms_checked(timeout)?;
341        let deadline = Instant::now() + timeout;
342        loop {
343            if let Some(selected) = accept(self.current_sources()?) {
344                return Ok(Some(selected));
345            }
346            let remaining = deadline.saturating_duration_since(Instant::now());
347            if remaining.is_zero() {
348                return Ok(None);
349            }
350            // Block until the source list changes or the remaining window
351            // expires; either way the next iteration re-snapshots and the
352            // zero-`remaining` check then terminates the loop.
353            self.wait_for_sources(remaining)?;
354        }
355    }
356
357    /// Discovers sources for up to `timeout`, returning the complete set.
358    ///
359    /// This honors the full `timeout` window and returns every source observed
360    /// within it. Unlike a single [`wait_for_sources`](Self::wait_for_sources)
361    /// followed by a snapshot — which returns on the *first* source-list change
362    /// and so can miss responders that announce late on staggered or unicast
363    /// (IP-hint-only) networks — enumeration here retains each source observed
364    /// during the window.
365    ///
366    /// For an immediate, non-blocking snapshot of already-known sources use
367    /// [`current_sources`](Self::current_sources); to resolve a single specific
368    /// host use [`SourceCache::find_by_host`].
369    ///
370    /// # Arguments
371    ///
372    /// * `timeout` - Discovery window. [`Duration::ZERO`] returns an immediate
373    ///   snapshot. Must not exceed [`crate::MAX_TIMEOUT`] (~49.7 days).
374    ///
375    /// # Returns
376    ///
377    /// Every source observed within the window, in first-observed order.
378    /// Identity includes the name and the complete address (including its variant);
379    /// a changed descriptor is returned as a distinct source. Network discovery
380    /// cannot guarantee observation of every source. May be empty if none appear.
381    ///
382    /// # Errors
383    ///
384    /// Returns [`Error::InvalidConfiguration`] if `timeout` exceeds [`crate::MAX_TIMEOUT`].
385    ///
386    /// # Examples
387    ///
388    /// ```no_run
389    /// # use grafton_ndi::{NDI, FinderOptions, Finder};
390    /// # use std::time::Duration;
391    /// # fn main() -> Result<(), grafton_ndi::Error> {
392    /// # let ndi = NDI::new()?;
393    /// # let finder = Finder::new(&ndi, &FinderOptions::default())?;
394    /// // Discover for up to 5 seconds, then list everything found
395    /// let sources = finder.find_sources(Duration::from_secs(5))?;
396    ///
397    /// for source in sources {
398    ///     println!("Found: {}", source);
399    /// }
400    /// # Ok(())
401    /// # }
402    /// ```
403    pub fn find_sources(&self, timeout: Duration) -> Result<Vec<Source>> {
404        // Preserve first-observed order and deduplicate by complete descriptor.
405        let mut seen = HashSet::new();
406        let mut discovered = Vec::new();
407        self.poll_until_deadline(timeout, |sources| {
408            accumulate_sources(&mut discovered, &mut seen, sources);
409            None::<()>
410        })?;
411        Ok(discovered)
412    }
413}
414
415impl Drop for Finder {
416    fn drop(&mut self) {
417        unsafe { NDIlib_find_destroy(self.instance) };
418    }
419}
420
421/// # Safety
422///
423/// The SDK finder handle may be moved between threads. Its runtime and creation
424/// strings remain alive with it, and destruction requires exclusive ownership.
425unsafe impl std::marker::Send for Finder {}
426
427/// # Safety
428///
429/// The SDK forbids concurrent `NDIlib_find_get_current_sources` calls on one
430/// instance and invalidates its returned buffers on the next call. `sources_lock`
431/// serializes those calls through the complete deep copy into owned `Source`s.
432/// Waiting for source changes does not invalidate those buffers; destruction
433/// requires exclusive access through `Drop`.
434unsafe impl std::marker::Sync for Finder {}
435
436fn accumulate_sources(
437    discovered: &mut Vec<Source>,
438    seen: &mut HashSet<Source>,
439    sources: Vec<Source>,
440) {
441    for source in sources {
442        if seen.insert(source.clone()) {
443            discovered.push(source);
444        }
445    }
446}
447
448struct Authority<'a> {
449    host: &'a str,
450    port: Option<u16>,
451}
452
453fn parse_authority(address: &str) -> Option<Authority<'_>> {
454    let address = address.split_once("://").map_or(address, |(_, rest)| rest);
455    let authority = address.split(['/', '?', '#']).next()?;
456    let authority = authority.rsplit('@').next()?;
457    if authority.is_empty() {
458        return None;
459    }
460    if let Some(rest) = authority.strip_prefix('[') {
461        let (host, suffix) = rest.split_once(']')?;
462        if host.parse::<std::net::Ipv6Addr>().is_err() {
463            return None;
464        }
465        let port = if suffix.is_empty() {
466            None
467        } else {
468            Some(suffix.strip_prefix(':')?.parse().ok()?)
469        };
470        return Some(Authority { host, port });
471    }
472    if authority.matches(':').count() > 1 {
473        authority.parse::<std::net::Ipv6Addr>().ok()?;
474        return Some(Authority {
475            host: authority,
476            port: None,
477        });
478    }
479    let (host, port) = match authority.split_once(':') {
480        Some((host, port)) => (host, Some(port.parse().ok()?)),
481        None => (authority, None),
482    };
483    if host.is_empty() {
484        return None;
485    }
486    Some(Authority { host, port })
487}
488
489#[derive(Clone, Copy)]
490enum HostQuery<'a> {
491    Ip(IpAddr),
492    Name(&'a str),
493}
494
495impl<'a> HostQuery<'a> {
496    fn parse(host: &'a str) -> Option<Self> {
497        let ip_text = if host.starts_with('[') {
498            host.strip_prefix('[')?.strip_suffix(']')?
499        } else {
500            host
501        };
502        if let Ok(ip) = ip_text.parse::<IpAddr>() {
503            return Some(Self::Ip(ip));
504        }
505        if host != ip_text || host.is_empty() || host.contains('\0') {
506            return None;
507        }
508        let name = host.strip_suffix('.').unwrap_or(host);
509        if name.is_empty()
510            || (name.contains('.') && name.bytes().all(|c| c.is_ascii_digit() || c == b'.'))
511            || !name.split('.').all(|label| {
512                !label.is_empty()
513                    && label.len() <= 63
514                    && label.as_bytes()[0].is_ascii_alphanumeric()
515                    && label.as_bytes()[label.len() - 1].is_ascii_alphanumeric()
516                    && label
517                        .bytes()
518                        .all(|c| c.is_ascii_alphanumeric() || c == b'-')
519            })
520            || name.len() > 253
521        {
522            return None;
523        }
524        Some(Self::Name(name))
525    }
526
527    fn matches(self, candidate: &str) -> bool {
528        match self {
529            Self::Ip(ip) => candidate.parse::<IpAddr>() == Ok(ip),
530            Self::Name(name) => candidate
531                .strip_suffix('.')
532                .unwrap_or(candidate)
533                .eq_ignore_ascii_case(name),
534        }
535    }
536}
537
538/// Canonical retained cache identity. IPs use their numeric display spelling;
539/// names use the parser's final-dot policy and ASCII lowercase. Those domains
540/// are disjoint: valid names cannot contain colons or dotted numeric IP text.
541#[derive(Debug, PartialEq, Eq, Hash)]
542struct HostKey(String);
543
544impl std::borrow::Borrow<str> for HostKey {
545    fn borrow(&self) -> &str {
546        &self.0
547    }
548}
549
550/// Bounded normalization keeps lookups and eviction free of key allocations.
551/// Only a published entry needs an owned string.
552struct HostKeyBuffer {
553    bytes: [u8; 253],
554    len: usize,
555}
556
557impl HostKeyBuffer {
558    fn new(query: HostQuery<'_>) -> Self {
559        let mut key = Self {
560            bytes: [0; 253],
561            len: 0,
562        };
563        match query {
564            HostQuery::Ip(ip) => {
565                use std::io::Write;
566                let mut cursor = std::io::Cursor::new(key.bytes.as_mut_slice());
567                // The longest numeric IP spelling is shorter than 253 bytes.
568                write!(cursor, "{ip}").expect("IP fits in host key buffer");
569                key.len = cursor.position() as usize;
570            }
571            HostQuery::Name(name) => {
572                key.len = name.len();
573                key.bytes[..key.len].copy_from_slice(name.as_bytes());
574                key.bytes[..key.len].make_ascii_lowercase();
575            }
576        }
577        key
578    }
579
580    fn as_str(&self) -> &str {
581        std::str::from_utf8(&self.bytes[..self.len]).expect("host keys are ASCII")
582    }
583
584    fn into_owned(self) -> HostKey {
585        HostKey(self.as_str().to_owned())
586    }
587}
588
589/// Network address of an NDI source.
590///
591/// NDI sources can be addressed via URL (for NDI HX sources) or IP address
592/// (for standard NDI sources).
593#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
594pub enum SourceAddress {
595    /// No address available.
596    #[default]
597    None,
598    /// URL address (typically for NDI HX sources).
599    Url(String),
600    /// IP address (for standard NDI sources).
601    Ip(String),
602}
603
604impl SourceAddress {
605    /// Check whether this address has the exact host or IP.
606    ///
607    /// Hostnames compare without ASCII case or a final DNS dot. Invalid bare-host
608    /// queries return false.
609    ///
610    /// # Arguments
611    ///
612    /// * `host` - The hostname or IP address to search for
613    ///
614    /// # Examples
615    ///
616    /// ```
617    /// use grafton_ndi::SourceAddress;
618    ///
619    /// let addr = SourceAddress::Ip("192.168.1.100:5960".to_string());
620    /// assert!(addr.contains_host("192.168.1.100"));
621    /// assert!(!addr.contains_host("192.168.1"));
622    ///
623    /// let url = SourceAddress::Url("http://camera.local:8080".to_string());
624    /// assert!(url.contains_host("camera.local"));
625    /// ```
626    pub fn contains_host(&self, host: &str) -> bool {
627        HostQuery::parse(host).is_some_and(|query| self.matches_query(query))
628    }
629
630    fn matches_query(&self, query: HostQuery<'_>) -> bool {
631        self.authority()
632            .is_some_and(|authority| query.matches(authority.host))
633    }
634
635    /// Extract an explicit port from conventional host/port or URL syntax.
636    ///
637    /// SDK address strings are opaque; unrecognized syntax returns `None`.
638    ///
639    /// Parses the port from addresses in the format `host:port`.
640    ///
641    /// # Returns
642    ///
643    /// `Some(port)` if a valid port is found, `None` otherwise.
644    ///
645    /// # Examples
646    ///
647    /// ```
648    /// use grafton_ndi::SourceAddress;
649    ///
650    /// let addr = SourceAddress::Ip("192.168.1.100:5960".to_string());
651    /// assert_eq!(addr.port(), Some(5960));
652    ///
653    /// let no_port = SourceAddress::Ip("192.168.1.100".to_string());
654    /// assert_eq!(no_port.port(), None);
655    ///
656    /// let url = SourceAddress::Url("http://camera.local:8080".to_string());
657    /// assert_eq!(url.port(), Some(8080));
658    /// ```
659    pub fn port(&self) -> Option<u16> {
660        self.authority().and_then(|authority| authority.port)
661    }
662
663    // SDK addresses are opaque. Only interpret conventional URL authorities and
664    // host[:port] forms; never inspect their path, query, or fragment as a port.
665    fn authority(&self) -> Option<Authority<'_>> {
666        match self {
667            Self::None => None,
668            Self::Ip(address) | Self::Url(address) => parse_authority(address),
669        }
670    }
671}
672
673/// Represents an NDI source discovered on the network.
674///
675/// Equality and hashing compare the complete name and address, including the
676/// address variant; they do not imply persistent device identity.
677///
678/// Sources contain a human-readable name and network address. The name
679/// typically includes the machine name and source name (e.g., "MACHINE (Source)").
680///
681/// # Examples
682///
683/// ```
684/// use grafton_ndi::{Source, SourceAddress};
685///
686/// let source = Source {
687///     name: "LAPTOP (Camera 1)".to_string(),
688///     address: SourceAddress::Ip("192.168.1.100:5960".to_string()),
689/// };
690///
691/// println!("Source: {}", source); // Displays: LAPTOP (Camera 1)@192.168.1.100:5960
692/// ```
693#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
694pub struct Source {
695    /// The NDI source name (e.g., "MACHINE (Source Name)").
696    pub name: String,
697    /// The network address for connecting to this source.
698    pub address: SourceAddress,
699}
700
701#[repr(C)]
702pub(crate) struct RawSource {
703    _name: CString,
704    _url_address: Option<CString>,
705    _ip_address: Option<CString>,
706    pub raw: NDIlib_source_t,
707}
708
709impl Source {
710    /// Check if this source matches a given host or IP address.
711    ///
712    /// This checks the complete address host, or for hostname queries the
713    /// machine component of an SDK `MACHINE (Source Name)` label.
714    ///
715    /// # Arguments
716    ///
717    /// * `host` - The hostname or IP address to match against
718    ///
719    /// # Examples
720    ///
721    /// ```
722    /// use grafton_ndi::{Source, SourceAddress};
723    ///
724    /// let source = Source {
725    ///     name: "CAMERA1 (Chan1, 192.168.0.107)".to_string(),
726    ///     address: SourceAddress::Ip("192.168.0.107:5960".to_string()),
727    /// };
728    ///
729    /// assert!(source.matches_host("192.168.0.107"));
730    /// assert!(source.matches_host("CAMERA1"));
731    /// assert!(!source.matches_host("192.168.1.1"));
732    /// ```
733    pub fn matches_host(&self, host: &str) -> bool {
734        HostQuery::parse(host).is_some_and(|query| self.matches_query(query))
735    }
736
737    fn matches_query(&self, query: HostQuery<'_>) -> bool {
738        if self.address.matches_query(query) {
739            return true;
740        }
741        match query {
742            HostQuery::Ip(_) => false,
743            HostQuery::Name(_) => self
744                .name
745                .split_once(" (")
746                .filter(|(_, source)| source.ends_with(')'))
747                .is_some_and(|(machine, _)| query.matches(machine)),
748        }
749    }
750
751    /// Extract the host from conventional host/port or URL syntax.
752    ///
753    /// Brackets around IPv6 literals are removed. SDK addresses are opaque;
754    /// unrecognized syntax returns `None`.
755    ///
756    /// For IP-based sources, this returns the IP portion without the port.
757    /// For URL-based sources, this extracts the hostname portion.
758    ///
759    /// # Returns
760    ///
761    /// `Some(ip)` if an IP or hostname is found, `None` otherwise.
762    ///
763    /// # Examples
764    ///
765    /// ```
766    /// use grafton_ndi::{Source, SourceAddress};
767    ///
768    /// let source = Source {
769    ///     name: "CAMERA1".to_string(),
770    ///     address: SourceAddress::Ip("192.168.1.100:5960".to_string()),
771    /// };
772    ///
773    /// assert_eq!(source.ip_address(), Some("192.168.1.100"));
774    /// ```
775    pub fn ip_address(&self) -> Option<&str> {
776        self.address.authority().map(|authority| authority.host)
777    }
778
779    /// Extract the hostname or IP without port.
780    ///
781    /// This is an alias for `ip_address()` for better API discoverability.
782    ///
783    /// # Examples
784    ///
785    /// ```
786    /// use grafton_ndi::{Source, SourceAddress};
787    ///
788    /// let source = Source {
789    ///     name: "CAMERA1".to_string(),
790    ///     address: SourceAddress::Ip("192.168.1.100:5960".to_string()),
791    /// };
792    ///
793    /// assert_eq!(source.host(), Some("192.168.1.100"));
794    /// ```
795    pub fn host(&self) -> Option<&str> {
796        self.ip_address()
797    }
798
799    /// Copy an SDK source descriptor into owned strings after null checks.
800    ///
801    /// Null checks cannot validate dangling or otherwise invalid pointers. The
802    /// SDK must provide a live descriptor and readable NUL-terminated strings.
803    ///
804    /// # Errors
805    ///
806    /// Returns `Error::NullPointer` if:
807    /// - The source pointer itself is null
808    /// - Both the `p_ndi_name` and source address fields are null
809    ///
810    /// # Safety
811    ///
812    /// A non-null `source_ptr` must point to a live `NDIlib_source_t`. Its non-null
813    /// string pointers must remain readable through their NUL terminators until
814    /// copying finishes.
815    pub(crate) unsafe fn try_from_raw(source_ptr: *const NDIlib_source_t) -> Result<Self> {
816        // Check top-level pointer
817        if source_ptr.is_null() {
818            return Err(Error::NullPointer("NDIlib_source_t pointer".into()));
819        }
820
821        let ndi_source = unsafe { &*source_ptr };
822
823        let name = if ndi_source.p_ndi_name.is_null() {
824            // The SDK accepts an address-only descriptor with a null name.
825            if unsafe { ndi_source.__bindgen_anon_1.p_url_address }.is_null() {
826                return Err(Error::NullPointer("NDIlib_source_t::p_ndi_name".into()));
827            }
828            String::new()
829        } else {
830            unsafe {
831                CStr::from_ptr(ndi_source.p_ndi_name)
832                    .to_string_lossy()
833                    .into_owned()
834            }
835        };
836
837        // For unions, we need to determine which field is active.
838        // NDI SDK convention: URL addresses are used for NDI HX sources,
839        // IP addresses for regular sources. We check URL first as it's
840        // typically used for newer/HX sources.
841        let address = unsafe {
842            if !ndi_source.__bindgen_anon_1.p_url_address.is_null() {
843                let url_str = CStr::from_ptr(ndi_source.__bindgen_anon_1.p_url_address)
844                    .to_string_lossy()
845                    .into_owned();
846                if url_str.contains("://") {
847                    SourceAddress::Url(url_str)
848                } else {
849                    SourceAddress::Ip(url_str)
850                }
851            } else {
852                SourceAddress::None
853            }
854        };
855
856        Ok(Source { name, address })
857    }
858
859    /// Convert to raw format for FFI use
860    ///
861    /// # Safety
862    ///
863    /// The returned RawSource struct uses #[repr(C)] to guarantee C-compatible layout
864    /// for safe FFI interop with the NDI SDK.
865    pub(crate) fn to_raw(&self) -> Result<RawSource> {
866        let name = CString::new(self.name.clone()).map_err(Error::InvalidCString)?;
867
868        let (url_address, ip_address, __bindgen_anon_1) = match &self.address {
869            SourceAddress::Url(url) => {
870                let url_cstr = CString::new(url.clone()).map_err(Error::InvalidCString)?;
871                let p_url = url_cstr.as_ptr();
872                (
873                    Some(url_cstr),
874                    None,
875                    NDIlib_source_t__bindgen_ty_1 {
876                        p_url_address: p_url,
877                    },
878                )
879            }
880            SourceAddress::Ip(ip) => {
881                let ip_cstr = CString::new(ip.clone()).map_err(Error::InvalidCString)?;
882                let p_ip = ip_cstr.as_ptr();
883                (
884                    None,
885                    Some(ip_cstr),
886                    NDIlib_source_t__bindgen_ty_1 { p_ip_address: p_ip },
887                )
888            }
889            SourceAddress::None => (
890                None,
891                None,
892                NDIlib_source_t__bindgen_ty_1 {
893                    p_ip_address: ptr::null(),
894                },
895            ),
896        };
897
898        let p_ndi_name = name.as_ptr();
899
900        Ok(RawSource {
901            _name: name,
902            _url_address: url_address,
903            _ip_address: ip_address,
904            raw: NDIlib_source_t {
905                p_ndi_name,
906                __bindgen_anon_1,
907            },
908        })
909    }
910}
911
912impl Display for Source {
913    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
914        match &self.address {
915            SourceAddress::Url(url) => write!(f, "{name}@{url}", name = self.name),
916            SourceAddress::Ip(ip) => write!(f, "{name}@{ip}", name = self.name),
917            SourceAddress::None => write!(f, "{name}", name = self.name),
918        }
919    }
920}
921
922/// Thread-safe cache for NDI source discovery.
923///
924/// `SourceCache` eliminates the need for applications to manually cache NDI instances
925/// and discovered sources. Discovery initializes the runtime on a cache miss.
926/// Cached sources own their strings and need no live runtime. Entries remain
927/// cached until explicitly invalidated or cleared.
928/// Equivalent hostname case/final-dot and numeric IP spellings share one entry.
929/// Hostnames, IP addresses, and DNS aliases otherwise remain distinct identities.
930///
931/// # Thread Safety
932///
933/// `SourceCache` is thread-safe and can be shared across threads using `Arc<SourceCache>`.
934/// Interior mutability is handled internally with proper synchronization.
935///
936/// # Examples
937///
938/// ```no_run
939/// use grafton_ndi::SourceCache;
940/// use std::time::Duration;
941///
942/// # fn main() -> Result<(), grafton_ndi::Error> {
943/// // Create a cache instance
944/// let cache = SourceCache::new();
945///
946/// // Find a source by hostname or IP with automatic caching
947/// let source = cache.find_by_host("192.168.0.107", Duration::from_secs(5))?;
948/// println!("Found source: {}", source);
949///
950/// // Subsequent lookups use the cache
951/// let same_source = cache.find_by_host("192.168.0.107", Duration::from_secs(5))?;
952///
953/// # Ok(())
954/// # }
955/// ```
956pub struct SourceCache {
957    cache: Mutex<CacheState>,
958}
959
960struct CacheState {
961    entries: HashMap<HostKey, Source>,
962    generation: Option<u64>,
963}
964
965impl CacheState {
966    fn invalidate_pending(&mut self) {
967        self.generation = self.generation.and_then(|value| value.checked_add(1));
968    }
969}
970
971impl SourceCache {
972    /// Create a new source cache.
973    ///
974    /// # Examples
975    ///
976    /// ```no_run
977    /// use grafton_ndi::SourceCache;
978    ///
979    /// # fn main() -> Result<(), grafton_ndi::Error> {
980    /// let cache = SourceCache::new();
981    /// # Ok(())
982    /// # }
983    /// ```
984    pub fn new() -> Self {
985        Self::default()
986    }
987
988    /// Find a source by IP address or hostname with built-in caching.
989    ///
990    /// This method handles NDI initialization and source discovery internally.
991    /// If a source matching the host has been previously found, it returns the
992    /// cached result. Otherwise, it performs NDI discovery and caches the result.
993    ///
994    /// # Arguments
995    ///
996    /// * `host` - The hostname or IP address to search for
997    /// * `timeout` - Maximum time to wait for source discovery.
998    ///   Must not exceed [`crate::MAX_TIMEOUT`] (~49.7 days).
999    ///
1000    /// # Returns
1001    ///
1002    /// The discovered source, or an error if no matching source is found or
1003    /// the timeout expires.
1004    ///
1005    /// # Errors
1006    ///
1007    /// - [`Error::NoSourcesFound`] if no source matching the host is discovered
1008    /// - [`Error::InvalidConfiguration`] if `host` is not a bare hostname or IP,
1009    ///   or `timeout` exceeds [`crate::MAX_TIMEOUT`]
1010    /// - Other errors if NDI initialization or discovery fails
1011    ///
1012    /// # Examples
1013    ///
1014    /// ```no_run
1015    /// use grafton_ndi::SourceCache;
1016    /// use std::time::Duration;
1017    ///
1018    /// # fn main() -> Result<(), grafton_ndi::Error> {
1019    /// let cache = SourceCache::new();
1020    ///
1021    /// // Find by IP address
1022    /// let source = cache.find_by_host("192.168.0.107", Duration::from_secs(5))?;
1023    ///
1024    /// // Find by the machine component of an SDK source name
1025    /// let source = cache.find_by_host("CAMERA1", Duration::from_secs(5))?;
1026    /// # Ok(())
1027    /// # }
1028    /// ```
1029    pub fn find_by_host(&self, host: &str, timeout: Duration) -> Result<Source> {
1030        self.find_by_host_with(host, timeout, |query| {
1031            let ndi = NDI::new()?;
1032            let hint = host
1033                .strip_prefix('[')
1034                .and_then(|value| value.strip_suffix(']'))
1035                .unwrap_or(host);
1036            let options = FinderOptions::builder()
1037                .show_local_sources(true)
1038                .extra_ips(hint)
1039                .build();
1040            let finder = Finder::new(&ndi, &options)?;
1041            finder.poll_until_deadline(timeout, |sources| {
1042                sources
1043                    .into_iter()
1044                    .find(|source| source.matches_query(query))
1045            })
1046        })
1047    }
1048
1049    fn find_by_host_with(
1050        &self,
1051        host: &str,
1052        timeout: Duration,
1053        discover: impl FnOnce(HostQuery<'_>) -> Result<Option<Source>>,
1054    ) -> Result<Source> {
1055        let query = HostQuery::parse(host).ok_or_else(|| {
1056            Error::InvalidConfiguration("Host must be a bare hostname or IP address".into())
1057        })?;
1058        to_ms_checked(timeout)?;
1059        let key = HostKeyBuffer::new(query);
1060        let generation = {
1061            let cache = self
1062                .cache
1063                .lock()
1064                .unwrap_or_else(|poisoned| poisoned.into_inner());
1065            if let Some(cached) = cache.entries.get(key.as_str()) {
1066                return Ok(cached.clone());
1067            }
1068            cache.generation
1069        };
1070
1071        // Discovery may wait for the SDK. Leave the cache mutex available to
1072        // invalidation and unrelated cache hits while this lookup is pending.
1073        let source = discover(query)?.ok_or_else(|| Error::NoSourcesFound {
1074            criteria: format!("host: {host}"),
1075        })?;
1076        {
1077            let mut cache = self
1078                .cache
1079                .lock()
1080                .unwrap_or_else(|poisoned| poisoned.into_inner());
1081            if generation.is_some() && cache.generation == generation {
1082                cache
1083                    .entries
1084                    .entry(key.into_owned())
1085                    .or_insert_with(|| source.clone());
1086            }
1087        }
1088        Ok(source)
1089    }
1090
1091    /// Invalidate the cache entry for a specific host.
1092    ///
1093    /// This is useful when a source goes offline or when you want to force
1094    /// a fresh discovery on the next lookup. Discovery started before this
1095    /// call may return to its caller, but cannot repopulate the cache afterward.
1096    /// Any accepted equivalent spelling removes the same host identity.
1097    /// Malformed input removes no entry but still invalidates pending discovery.
1098    ///
1099    /// # Arguments
1100    ///
1101    /// * `host` - The hostname or IP address to remove from the cache
1102    ///
1103    /// # Examples
1104    ///
1105    /// ```no_run
1106    /// use grafton_ndi::SourceCache;
1107    /// use std::time::Duration;
1108    ///
1109    /// # fn main() -> Result<(), grafton_ndi::Error> {
1110    /// let cache = SourceCache::new();
1111    /// let source = cache.find_by_host("192.168.0.107", Duration::from_secs(5))?;
1112    ///
1113    /// // Later, if the source goes offline
1114    /// cache.invalidate("192.168.0.107");
1115    ///
1116    /// // Next lookup will perform fresh discovery
1117    /// # Ok(())
1118    /// # }
1119    /// ```
1120    pub fn invalidate(&self, host: &str) {
1121        let mut cache = self
1122            .cache
1123            .lock()
1124            .unwrap_or_else(|poisoned| poisoned.into_inner());
1125        cache.invalidate_pending();
1126        if let Some(query) = HostQuery::parse(host) {
1127            cache.entries.remove(HostKeyBuffer::new(query).as_str());
1128        }
1129    }
1130
1131    /// Clear all cached sources.
1132    ///
1133    /// This removes all entries from the cache, forcing fresh discovery
1134    /// for all subsequent lookups. Pending discovery cannot repopulate the
1135    /// cache after this call returns.
1136    ///
1137    /// # Examples
1138    ///
1139    /// ```no_run
1140    /// use grafton_ndi::SourceCache;
1141    /// use std::time::Duration;
1142    ///
1143    /// # fn main() -> Result<(), grafton_ndi::Error> {
1144    /// let cache = SourceCache::new();
1145    /// cache.find_by_host("192.168.0.107", Duration::from_secs(5))?;
1146    /// cache.find_by_host("192.168.0.108", Duration::from_secs(5))?;
1147    ///
1148    /// // Clear all cached sources
1149    /// cache.clear();
1150    /// # Ok(())
1151    /// # }
1152    /// ```
1153    pub fn clear(&self) {
1154        let mut cache = self
1155            .cache
1156            .lock()
1157            .unwrap_or_else(|poisoned| poisoned.into_inner());
1158        cache.invalidate_pending();
1159        cache.entries.clear();
1160    }
1161
1162    /// Get the number of distinct cached host identities.
1163    ///
1164    /// This can be useful for monitoring cache usage and debugging.
1165    ///
1166    /// # Examples
1167    ///
1168    /// ```no_run
1169    /// use grafton_ndi::SourceCache;
1170    /// use std::time::Duration;
1171    ///
1172    /// # fn main() -> Result<(), grafton_ndi::Error> {
1173    /// let cache = SourceCache::new();
1174    /// assert_eq!(cache.len(), 0);
1175    ///
1176    /// cache.find_by_host("192.168.0.107", Duration::from_secs(5))?;
1177    /// assert_eq!(cache.len(), 1);
1178    /// # Ok(())
1179    /// # }
1180    /// ```
1181    pub fn len(&self) -> usize {
1182        let cache = self
1183            .cache
1184            .lock()
1185            .unwrap_or_else(|poisoned| poisoned.into_inner());
1186        cache.entries.len()
1187    }
1188
1189    /// Check if the cache is empty.
1190    ///
1191    /// # Examples
1192    ///
1193    /// ```no_run
1194    /// use grafton_ndi::SourceCache;
1195    /// use std::time::Duration;
1196    ///
1197    /// # fn main() -> Result<(), grafton_ndi::Error> {
1198    /// let cache = SourceCache::new();
1199    /// assert!(cache.is_empty());
1200    ///
1201    /// cache.find_by_host("192.168.0.107", Duration::from_secs(5))?;
1202    /// assert!(!cache.is_empty());
1203    /// # Ok(())
1204    /// # }
1205    /// ```
1206    pub fn is_empty(&self) -> bool {
1207        let cache = self
1208            .cache
1209            .lock()
1210            .unwrap_or_else(|poisoned| poisoned.into_inner());
1211        cache.entries.is_empty()
1212    }
1213}
1214
1215impl Default for SourceCache {
1216    fn default() -> Self {
1217        Self {
1218            cache: Mutex::new(CacheState {
1219                entries: HashMap::new(),
1220                generation: Some(0),
1221            }),
1222        }
1223    }
1224}
1225
1226#[cfg(test)]
1227mod tests {
1228    use super::*;
1229
1230    fn host_key(host: &str) -> HostKey {
1231        HostKeyBuffer::new(HostQuery::parse(host).unwrap()).into_owned()
1232    }
1233
1234    #[test]
1235    fn canonical_host_keys_obey_identity_and_hash_laws() {
1236        use std::hash::{DefaultHasher, Hash, Hasher};
1237        let hash = |key: &HostKey| {
1238            let mut state = DefaultHasher::new();
1239            key.hash(&mut state);
1240            let owned_hash = state.finish();
1241            let mut borrowed_state = DefaultHasher::new();
1242            std::borrow::Borrow::<str>::borrow(key).hash(&mut borrowed_state);
1243            assert_eq!(owned_hash, borrowed_state.finish());
1244            owned_hash
1245        };
1246        for variants in [
1247            vec!["camera.local", "CAMERA.LOCAL", "Camera.Local."],
1248            vec!["2001:db8::1", "[2001:0DB8:0:0:0:0:0:1]", "2001:db8:0::1"],
1249            vec!["192.0.2.1", "[192.0.2.1]"],
1250        ] {
1251            for a in &variants {
1252                for b in &variants {
1253                    assert_eq!(host_key(a), host_key(b));
1254                    assert_eq!(hash(&host_key(a)), hash(&host_key(b)));
1255                }
1256            }
1257        }
1258        for name in ["camera", "camera-1.local", "a.b.c", "123", &"a".repeat(63)] {
1259            let expected = host_key(name);
1260            // Vary each ASCII letter's case independently, then the final dot.
1261            for i in 0..name.len() {
1262                let mut bytes = name.as_bytes().to_vec();
1263                bytes[i].make_ascii_uppercase();
1264                let variant = String::from_utf8(bytes).unwrap();
1265                for variant in [variant.clone(), format!("{variant}.")] {
1266                    let key = host_key(&variant);
1267                    assert_eq!(expected, key);
1268                    assert_eq!(hash(&expected), hash(&key));
1269                }
1270            }
1271        }
1272        let maximum = [
1273            "a".repeat(63),
1274            "b".repeat(63),
1275            "c".repeat(63),
1276            "d".repeat(61),
1277        ]
1278        .join(".");
1279        assert_eq!(maximum.len(), 253);
1280        assert_eq!(
1281            host_key(&maximum),
1282            host_key(&format!("{}.", maximum.to_uppercase()))
1283        );
1284        let distinct = [
1285            "camera",
1286            "camera1",
1287            "camera.local",
1288            "192.0.2.1",
1289            "192.0.2.2",
1290            "::1",
1291            "::ffff:192.0.2.1",
1292            "123",
1293        ];
1294        assert_eq!(
1295            distinct
1296                .iter()
1297                .map(|host| host_key(host))
1298                .collect::<HashSet<_>>()
1299                .len(),
1300            distinct.len()
1301        );
1302    }
1303
1304    #[test]
1305    fn equivalent_cache_hits_and_invalidation_share_one_identity() {
1306        for (a, b) in [
1307            ("CAMERA.LOCAL", "camera.local"),
1308            ("camera.local.", "camera.local"),
1309            ("[2001:0db8:0:0:0:0:0:1]", "2001:db8::1"),
1310        ] {
1311            for (first, second) in [(a, b), (b, a)] {
1312                let cache = SourceCache::new();
1313                let old = Source {
1314                    name: "camera".into(),
1315                    address: SourceAddress::Ip("192.0.2.1:5960".into()),
1316                };
1317                let fresh = Source {
1318                    address: SourceAddress::Ip("192.0.2.1:5961".into()),
1319                    ..old.clone()
1320                };
1321                cache
1322                    .find_by_host_with(first, Duration::ZERO, |_| Ok(Some(old.clone())))
1323                    .unwrap();
1324                let (identical, identical_allocs) = crate::test_alloc::allocation_count(|| {
1325                    cache
1326                        .find_by_host_with(first, Duration::ZERO, |_| panic!("cache hit"))
1327                        .unwrap()
1328                });
1329                let (equivalent, equivalent_allocs) = crate::test_alloc::allocation_count(|| {
1330                    cache
1331                        .find_by_host_with(second, Duration::ZERO, |_| {
1332                            panic!("equivalent cache hit")
1333                        })
1334                        .unwrap()
1335                });
1336                assert_eq!(identical, old);
1337                assert_eq!(equivalent, old);
1338                assert_eq!(identical_allocs, 2); // The two owned descriptor strings.
1339                assert_eq!(equivalent_allocs, identical_allocs);
1340                assert_eq!(cache.len(), 1);
1341                assert!(cache
1342                    .find_by_host_with(second, Duration::MAX, |_| panic!("invalid timeout"))
1343                    .is_err());
1344                cache.invalidate(second);
1345                assert!(cache.is_empty());
1346                assert_eq!(
1347                    cache
1348                        .find_by_host_with(first, Duration::ZERO, |_| Ok(Some(fresh.clone())))
1349                        .unwrap(),
1350                    fresh
1351                );
1352                assert_eq!(cache.len(), 1);
1353                cache.invalidate("invalid:host:port");
1354                assert_eq!(cache.len(), 1);
1355                cache.clear();
1356                assert!(cache
1357                    .find_by_host_with(first, Duration::ZERO, |_| Err(Error::InvalidConfiguration(
1358                        "discovery failed".into()
1359                    )))
1360                    .is_err());
1361                assert!(cache
1362                    .find_by_host_with(second, Duration::ZERO, |_| Ok(None))
1363                    .is_err());
1364                assert!(cache.is_empty());
1365            }
1366        }
1367    }
1368
1369    #[test]
1370    fn exact_host_matching_ignores_labels_ports_and_url_payloads() {
1371        let other = Source {
1372            name: "CAMERA10 (192.168.1.1)".into(),
1373            address: SourceAddress::Ip("192.168.1.100:5960".into()),
1374        };
1375        assert!(!other.matches_host("192.168.1.1"));
1376        assert!(!other.matches_host("5960"));
1377        assert!(!other.matches_host("CAMERA1"));
1378        let exact = Source {
1379            name: "CAMERA1 (Program)".into(),
1380            address: SourceAddress::Url(
1381                "http://user:pass@Camera.Local:8080/ndi?next=192.0.2.1".into(),
1382            ),
1383        };
1384        assert!(exact.matches_host("camera.local."));
1385        assert!(exact.matches_host("camera1"));
1386        assert!(!exact.matches_host("192.0.2.1"));
1387        assert!(!exact.matches_host("Program"));
1388        let ipv6 = SourceAddress::Ip("[2001:0db8:0:0:0:0:0:1]:5960".into());
1389        assert!(ipv6.contains_host("2001:db8::1"));
1390        assert!(ipv6.contains_host("[2001:db8::1]"));
1391        for invalid in [
1392            "",
1393            "camera:5960",
1394            "http://camera.local",
1395            "camera/local",
1396            "192.168.1",
1397            "foo\0bar",
1398            "[bad]",
1399        ] {
1400            assert!(HostQuery::parse(invalid).is_none(), "{invalid:?}");
1401        }
1402        let (_, allocations) = crate::test_alloc::allocation_count(|| {
1403            for _ in 0..100 {
1404                assert!(exact.matches_host("CAMERA.LOCAL"));
1405                assert!(ipv6.contains_host("2001:db8::1"));
1406            }
1407        });
1408        assert_eq!(allocations, 0);
1409    }
1410
1411    #[test]
1412    fn cache_selects_exact_host_and_rejects_invalid_queries_before_discovery() {
1413        let cache = SourceCache::new();
1414        let wrong = Source {
1415            name: "CAMERA10 (Program)".into(),
1416            address: SourceAddress::Ip("192.168.1.100:5960".into()),
1417        };
1418        let right = Source {
1419            name: "CAMERA1 (Program)".into(),
1420            address: SourceAddress::Ip("192.168.1.1:5961".into()),
1421        };
1422        let timeout = Duration::ZERO;
1423        let selected = cache
1424            .find_by_host_with("192.168.1.1", timeout, |query| {
1425                Ok([wrong.clone(), right.clone()]
1426                    .into_iter()
1427                    .find(|s| s.matches_query(query)))
1428            })
1429            .unwrap();
1430        assert_eq!(selected, right);
1431        assert_eq!(cache.len(), 1);
1432        assert_eq!(
1433            cache
1434                .find_by_host_with("192.168.1.1", timeout, |_| panic!("cache hit"))
1435                .unwrap(),
1436            right
1437        );
1438        for invalid in ["", "192.168.1.1:5961", "camera?query", "foo\0bar"] {
1439            assert!(matches!(
1440                cache.find_by_host_with(invalid, timeout, |_| panic!(
1441                    "invalid query reached discovery"
1442                )),
1443                Err(Error::InvalidConfiguration(_))
1444            ));
1445        }
1446        assert_eq!(cache.len(), 1);
1447    }
1448
1449    #[test]
1450    fn invalidation_prevents_pending_lookup_from_repopulating_cache() {
1451        use std::sync::{mpsc, Arc};
1452        for clear in [false, true] {
1453            let cache = Arc::new(SourceCache::new());
1454            let (started_tx, started_rx) = mpsc::channel();
1455            let (resume_tx, resume_rx) = mpsc::channel();
1456            let old = Source {
1457                name: "old".into(),
1458                address: SourceAddress::Ip("192.0.2.1:5960".into()),
1459            };
1460            let fresh = Source {
1461                name: "fresh".into(),
1462                address: SourceAddress::Ip("192.0.2.1:5961".into()),
1463            };
1464            let pending = Arc::clone(&cache);
1465            let old_result = old.clone();
1466            let thread = std::thread::spawn(move || {
1467                pending
1468                    .find_by_host_with("CAMERA.LOCAL.", Duration::ZERO, |_| {
1469                        started_tx.send(()).unwrap();
1470                        resume_rx.recv().unwrap();
1471                        Ok(Some(old_result))
1472                    })
1473                    .unwrap()
1474            });
1475            started_rx.recv().unwrap();
1476            if clear {
1477                cache.clear();
1478            } else {
1479                cache.invalidate("camera.local");
1480            }
1481            assert_eq!(cache.len(), 0);
1482            let found = cache
1483                .find_by_host_with("camera.local", Duration::ZERO, |_| Ok(Some(fresh.clone())))
1484                .unwrap();
1485            assert_eq!(found, fresh);
1486            resume_tx.send(()).unwrap();
1487            assert_eq!(thread.join().unwrap(), old);
1488            assert_eq!(
1489                cache
1490                    .find_by_host_with("Camera.Local.", Duration::ZERO, |_| panic!(
1491                        "fresh entry was overwritten"
1492                    ))
1493                    .unwrap(),
1494                fresh
1495            );
1496        }
1497    }
1498
1499    #[test]
1500    fn absent_invalidation_and_empty_clear_suppress_pending_fills() {
1501        use std::sync::{mpsc, Arc};
1502        for clear in [false, true] {
1503            let cache = Arc::new(SourceCache::new());
1504            let (started_tx, started_rx) = mpsc::channel();
1505            let (resume_tx, resume_rx) = mpsc::channel();
1506            let pending = Arc::clone(&cache);
1507            let thread = std::thread::spawn(move || {
1508                pending
1509                    .find_by_host_with("camera.local", Duration::ZERO, |_| {
1510                        started_tx.send(()).unwrap();
1511                        resume_rx.recv().unwrap();
1512                        Ok(Some(Source {
1513                            name: "CAMERA (Program)".into(),
1514                            address: SourceAddress::None,
1515                        }))
1516                    })
1517                    .unwrap()
1518            });
1519            started_rx.recv().unwrap();
1520            if clear {
1521                cache.clear();
1522            } else {
1523                cache.invalidate("absent.local");
1524            }
1525            resume_tx.send(()).unwrap();
1526            thread.join().unwrap();
1527            assert!(cache.is_empty());
1528        }
1529    }
1530
1531    #[test]
1532    fn exhausted_generation_suppresses_publication() {
1533        let cache = SourceCache::new();
1534        cache.cache.lock().unwrap().generation = Some(u64::MAX);
1535        cache.invalidate("absent");
1536        let source = Source {
1537            name: "camera".into(),
1538            address: SourceAddress::Ip("192.0.2.1".into()),
1539        };
1540        assert_eq!(
1541            cache
1542                .find_by_host_with("192.0.2.1", Duration::ZERO, |_| Ok(Some(source.clone())))
1543                .unwrap(),
1544            source
1545        );
1546        assert!(cache.is_empty());
1547    }
1548    use std::ffi::CString;
1549
1550    #[test]
1551    fn defaults_include_local_sources() {
1552        assert!(FinderOptions::default().show_local_sources);
1553        assert_eq!(
1554            FinderOptions::default().show_local_sources,
1555            FinderOptions::builder().build().show_local_sources
1556        );
1557    }
1558
1559    #[test]
1560    fn authority_parsing_separates_hosts_ports_and_url_components() {
1561        for (address, host, port) in [
1562            ("192.0.2.1:5960", "192.0.2.1", Some(5960)),
1563            ("2001:db8::1", "2001:db8::1", None),
1564            ("[::1]:5960", "::1", Some(5960)),
1565            (
1566                "http://camera:8080/path:123?port=456#789",
1567                "camera",
1568                Some(8080),
1569            ),
1570            ("http://camera?x=:123", "camera", None),
1571            ("http://user:pass@[::1]:8080/path", "::1", Some(8080)),
1572        ] {
1573            let source = Source {
1574                name: String::new(),
1575                address: SourceAddress::Url(address.into()),
1576            };
1577            assert_eq!(source.host(), Some(host), "{address}");
1578            assert_eq!(source.address.port(), port, "{address}");
1579        }
1580        assert!(parse_authority("[::1").is_none());
1581        assert!(parse_authority("camera:invalid").is_none());
1582        assert!(parse_authority("").is_none());
1583    }
1584
1585    #[test]
1586    fn discovery_accumulates_structurally_distinct_sources_in_order() {
1587        let first = Source {
1588            name: "Camera".into(),
1589            address: SourceAddress::Ip("192.0.2.1".into()),
1590        };
1591        let mut changed = first.clone();
1592        changed.address = SourceAddress::Ip("192.0.2.2".into());
1593        let mut distinct_variant = first.clone();
1594        distinct_variant.address = SourceAddress::Url("192.0.2.1".into());
1595        let mut seen = HashSet::new();
1596        let mut discovered = Vec::new();
1597        accumulate_sources(&mut discovered, &mut seen, vec![first.clone()]);
1598        accumulate_sources(&mut discovered, &mut seen, Vec::new());
1599        accumulate_sources(
1600            &mut discovered,
1601            &mut seen,
1602            vec![first.clone(), changed.clone(), distinct_variant.clone()],
1603        );
1604        assert_eq!(discovered, vec![first, changed, distinct_variant]);
1605    }
1606
1607    #[test]
1608    fn cache_validates_timeout_before_returning_cached_source() {
1609        let cache = SourceCache::new();
1610        cache.cache.lock().unwrap().entries.insert(
1611            HostKeyBuffer::new(HostQuery::parse("camera").unwrap()).into_owned(),
1612            Source::default(),
1613        );
1614        for host in ["camera", "not_cached"] {
1615            assert!(matches!(
1616                cache.find_by_host(host, Duration::MAX),
1617                Err(Error::InvalidConfiguration(_))
1618            ));
1619        }
1620        assert_eq!(
1621            cache.find_by_host("camera", Duration::ZERO).unwrap(),
1622            Source::default()
1623        );
1624    }
1625
1626    #[test]
1627    fn concurrent_snapshots_keep_sdk_buffers_exclusive_until_copied() {
1628        use std::sync::{
1629            atomic::{AtomicUsize, Ordering},
1630            Barrier, TryLockError,
1631        };
1632
1633        let ndi = NDI::new().unwrap();
1634        let finder = Finder::new(&ndi, &FinderOptions::default()).unwrap();
1635        let start = Barrier::new(8);
1636        let active_snapshot = AtomicUsize::new(0);
1637
1638        std::thread::scope(|scope| {
1639            for index in 0..8 {
1640                let finder = &finder;
1641                let start = &start;
1642                let active_snapshot = &active_snapshot;
1643                scope.spawn(move || {
1644                    let name = CString::new(format!("Camera {index}")).unwrap();
1645                    let address = CString::new(format!("192.0.2.{index}:5960")).unwrap();
1646                    let raw = NDIlib_source_t {
1647                        p_ndi_name: name.as_ptr(),
1648                        __bindgen_anon_1: NDIlib_source_t__bindgen_ty_1 {
1649                            p_ip_address: address.as_ptr(),
1650                        },
1651                    };
1652                    start.wait();
1653                    // SAFETY: raw and both C strings stay live through copying;
1654                    // this test's snapshot lock serializes their use.
1655                    let sources = unsafe { finder.current_sources_with(
1656                        |count| {
1657                            // Model an SDK snapshot whose buffers remain borrowed
1658                            // until conversion finishes, not just until fetch returns.
1659                            assert_eq!(active_snapshot.fetch_add(1, Ordering::SeqCst), 0);
1660                            assert!(matches!(finder.sources_lock.try_lock(), Err(TryLockError::WouldBlock)));
1661                            *count = 1;
1662                            std::thread::yield_now();
1663                            &raw
1664                        },
1665                        |source| {
1666                            // Deterministically catches releasing the lock before
1667                            // copying, even if competing threads are not scheduled.
1668                            assert!(matches!(finder.sources_lock.try_lock(), Err(TryLockError::WouldBlock)));
1669                            std::thread::yield_now();
1670                            let owned = Source::try_from_raw(source);
1671                            assert_eq!(active_snapshot.fetch_sub(1, Ordering::SeqCst), 1);
1672                            owned
1673                        },
1674                    ) }.unwrap();
1675                    drop(name);
1676                    drop(address);
1677                    assert_eq!(sources[0].name, format!("Camera {index}"));
1678                    assert!(matches!(&sources[0].address, SourceAddress::Ip(ip) if ip == &format!("192.0.2.{index}:5960")));
1679                });
1680            }
1681        });
1682        assert_eq!(active_snapshot.load(Ordering::SeqCst), 0);
1683
1684        // A panic while copying must not disable later snapshots. The mutex
1685        // protects a lifetime window, not mutable Rust state needing repair.
1686        std::thread::scope(|scope| {
1687            assert!(scope
1688                .spawn(|| {
1689                    let _guard = finder.sources_lock.lock().unwrap();
1690                    panic!("interrupted snapshot");
1691                })
1692                .join()
1693                .is_err());
1694        });
1695        assert!(unsafe {
1696            finder.current_sources_with(|_| ptr::null(), |_| panic!("empty snapshot must not copy"))
1697        }
1698        .unwrap()
1699        .is_empty());
1700    }
1701
1702    #[test]
1703    fn test_try_from_raw_null_pointer() {
1704        // Test that null pointer is rejected
1705        let result = unsafe { Source::try_from_raw(ptr::null()) };
1706        assert!(result.is_err());
1707        match result {
1708            Err(Error::NullPointer(msg)) => {
1709                assert!(msg.contains("NDIlib_source_t pointer"));
1710            }
1711            _ => panic!("Expected NullPointer error"),
1712        }
1713    }
1714
1715    #[test]
1716    fn test_try_from_raw_null_name() {
1717        // Create a source with null p_ndi_name
1718        let source = NDIlib_source_t {
1719            p_ndi_name: ptr::null(),
1720            __bindgen_anon_1: NDIlib_source_t__bindgen_ty_1 {
1721                p_ip_address: ptr::null(),
1722            },
1723        };
1724
1725        let result = unsafe { Source::try_from_raw(&source as *const _) };
1726        assert!(result.is_err());
1727        match result {
1728            Err(Error::NullPointer(msg)) => {
1729                assert!(msg.contains("p_ndi_name"));
1730            }
1731            _ => panic!("Expected NullPointer error for null name"),
1732        }
1733    }
1734
1735    #[test]
1736    fn test_try_from_raw_address_only_source() {
1737        let url = CString::new("ndi://127.0.0.1:5960").unwrap();
1738        let raw = NDIlib_source_t {
1739            p_ndi_name: ptr::null(),
1740            __bindgen_anon_1: NDIlib_source_t__bindgen_ty_1 {
1741                p_url_address: url.as_ptr(),
1742            },
1743        };
1744        let source = unsafe { Source::try_from_raw(&raw) }.unwrap();
1745        assert!(source.name.is_empty());
1746        assert!(
1747            matches!(source.address, SourceAddress::Url(ref address) if address == "ndi://127.0.0.1:5960")
1748        );
1749        let roundtrip = source.to_raw().unwrap();
1750        assert!(!roundtrip.raw.p_ndi_name.is_null());
1751    }
1752
1753    #[test]
1754    fn test_try_from_raw_valid_source_with_ip() {
1755        // Create valid C strings
1756        let name = CString::new("Test Source").unwrap();
1757        let ip = CString::new("192.168.1.100:5960").unwrap();
1758
1759        let source = NDIlib_source_t {
1760            p_ndi_name: name.as_ptr(),
1761            __bindgen_anon_1: NDIlib_source_t__bindgen_ty_1 {
1762                p_ip_address: ip.as_ptr(),
1763            },
1764        };
1765
1766        let result = unsafe { Source::try_from_raw(&source as *const _) };
1767        assert!(result.is_ok());
1768
1769        let source = result.unwrap();
1770        assert_eq!(source.name, "Test Source");
1771        match source.address {
1772            SourceAddress::Ip(ip_str) => {
1773                assert_eq!(ip_str, "192.168.1.100:5960");
1774            }
1775            _ => panic!("Expected IP address"),
1776        }
1777    }
1778
1779    #[test]
1780    fn test_try_from_raw_valid_source_with_url() {
1781        // Create valid C strings
1782        let name = CString::new("HX Source").unwrap();
1783        let url = CString::new("http://camera.local:8080/ndi").unwrap();
1784
1785        let source = NDIlib_source_t {
1786            p_ndi_name: name.as_ptr(),
1787            __bindgen_anon_1: NDIlib_source_t__bindgen_ty_1 {
1788                p_url_address: url.as_ptr(),
1789            },
1790        };
1791
1792        let result = unsafe { Source::try_from_raw(&source as *const _) };
1793        assert!(result.is_ok());
1794
1795        let source = result.unwrap();
1796        assert_eq!(source.name, "HX Source");
1797        match source.address {
1798            SourceAddress::Url(url_str) => {
1799                assert_eq!(url_str, "http://camera.local:8080/ndi");
1800            }
1801            _ => panic!("Expected URL address"),
1802        }
1803    }
1804
1805    #[test]
1806    fn test_try_from_raw_valid_source_no_address() {
1807        // Create valid C string for name, null for address
1808        let name = CString::new("Source No Addr").unwrap();
1809
1810        let source = NDIlib_source_t {
1811            p_ndi_name: name.as_ptr(),
1812            __bindgen_anon_1: NDIlib_source_t__bindgen_ty_1 {
1813                p_ip_address: ptr::null(),
1814            },
1815        };
1816
1817        let result = unsafe { Source::try_from_raw(&source as *const _) };
1818        assert!(result.is_ok());
1819
1820        let source = result.unwrap();
1821        assert_eq!(source.name, "Source No Addr");
1822        match source.address {
1823            SourceAddress::None => {}
1824            _ => panic!("Expected None address"),
1825        }
1826    }
1827
1828    #[test]
1829    fn test_source_cache_poison_recovery() {
1830        use std::sync::Arc;
1831        use std::thread;
1832
1833        let cache = Arc::new(SourceCache::default());
1834        let cache_clone = Arc::clone(&cache);
1835
1836        // Spawn a thread that acquires the lock and panics
1837        let handle = thread::spawn(move || {
1838            let _lock = cache_clone
1839                .cache
1840                .lock()
1841                .unwrap_or_else(|poisoned| poisoned.into_inner());
1842            panic!("intentional panic while holding lock");
1843        });
1844
1845        // Wait for the thread to panic
1846        let _ = handle.join();
1847
1848        // Verify all cache methods still work after poison
1849        assert!(cache.is_empty(), "is_empty should work after poison");
1850        assert_eq!(cache.len(), 0, "len should work after poison");
1851        cache.invalidate("test");
1852        cache.clear();
1853    }
1854}