Skip to main content

openentropy_core/
source_resolution.rs

1//! Source-name resolution helpers shared across CLI and SDK surfaces.
2
3use std::collections::HashSet;
4
5/// Matching policy used when resolving requested source names.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum SourceMatchMode {
8    /// Match only exact source names.
9    ExactOnly,
10    /// Match exact names first, then fall back to case-insensitive substring matching.
11    ExactThenSubstringInsensitive,
12}
13
14/// Result of resolving user-requested source names against available source names.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct SourceResolution {
17    pub resolved: Vec<String>,
18    pub missing: Vec<String>,
19}
20
21/// Resolve requested source names against an available source-name list.
22///
23/// Resolved names are returned in available-source order for determinism.
24/// Duplicate requests for the same underlying source are silently deduplicated.
25#[must_use]
26pub fn resolve_source_names(
27    available: &[String],
28    requested: &[String],
29    mode: SourceMatchMode,
30) -> SourceResolution {
31    let mut used_indices = HashSet::new();
32    let mut missing = Vec::new();
33
34    for name in requested {
35        if let Some(idx) = find_matching_index(available, name, mode, Some(&used_indices)) {
36            used_indices.insert(idx);
37            continue;
38        }
39
40        // If this request resolves to a source that is already selected, treat it
41        // as a duplicate alias/request instead of surfacing it as missing.
42        if find_matching_index(available, name, mode, None).is_some() {
43            continue;
44        }
45
46        missing.push(name.clone());
47    }
48
49    let mut indices: Vec<usize> = used_indices.into_iter().collect();
50    indices.sort_unstable();
51    let resolved = indices
52        .into_iter()
53        .map(|idx| available[idx].clone())
54        .collect();
55
56    SourceResolution { resolved, missing }
57}
58
59fn find_matching_index(
60    available: &[String],
61    requested: &str,
62    mode: SourceMatchMode,
63    used_indices: Option<&HashSet<usize>>,
64) -> Option<usize> {
65    let is_unused = |idx: &usize| used_indices.is_none_or(|used| !used.contains(idx));
66
67    if let Some((idx, _)) = available
68        .iter()
69        .enumerate()
70        .find(|(idx, source)| is_unused(idx) && source.as_str() == requested)
71    {
72        return Some(idx);
73    }
74
75    if mode == SourceMatchMode::ExactOnly {
76        return None;
77    }
78
79    let lower = requested.to_lowercase();
80    available
81        .iter()
82        .enumerate()
83        .find(|(idx, source)| is_unused(idx) && source.to_lowercase().contains(&lower))
84        .map(|(idx, _)| idx)
85}
86
87#[cfg(test)]
88mod tests {
89    use super::{SourceMatchMode, resolve_source_names};
90
91    fn available_sources() -> Vec<String> {
92        vec![
93            "clock_jitter".to_string(),
94            "thermal_noise".to_string(),
95            "mach_timing".to_string(),
96        ]
97    }
98
99    #[test]
100    fn exact_only_resolves_and_deduplicates() {
101        let resolution = resolve_source_names(
102            &available_sources(),
103            &["thermal_noise".to_string(), "thermal_noise".to_string()],
104            SourceMatchMode::ExactOnly,
105        );
106
107        assert_eq!(resolution.resolved, vec!["thermal_noise"]);
108        assert!(resolution.missing.is_empty());
109    }
110
111    #[test]
112    fn exact_only_reports_missing_names() {
113        let resolution = resolve_source_names(
114            &available_sources(),
115            &["missing_source".to_string()],
116            SourceMatchMode::ExactOnly,
117        );
118
119        assert!(resolution.resolved.is_empty());
120        assert_eq!(resolution.missing, vec!["missing_source"]);
121    }
122
123    #[test]
124    fn partial_mode_prefers_exact_then_partial() {
125        let resolution = resolve_source_names(
126            &available_sources(),
127            &["mach".to_string(), "clock_jitter".to_string()],
128            SourceMatchMode::ExactThenSubstringInsensitive,
129        );
130
131        assert_eq!(
132            resolution.resolved,
133            vec!["clock_jitter".to_string(), "mach_timing".to_string()]
134        );
135        assert!(resolution.missing.is_empty());
136    }
137
138    #[test]
139    fn partial_mode_deduplicates_aliases_for_same_source() {
140        let resolution = resolve_source_names(
141            &available_sources(),
142            &["clock".to_string(), "clock_jitter".to_string()],
143            SourceMatchMode::ExactThenSubstringInsensitive,
144        );
145
146        assert_eq!(resolution.resolved, vec!["clock_jitter"]);
147        assert!(resolution.missing.is_empty());
148    }
149}