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}