openentropy_core/
source_resolution.rs1use std::collections::HashSet;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum SourceMatchMode {
8 ExactOnly,
10 ExactThenSubstringInsensitive,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct SourceResolution {
17 pub resolved: Vec<String>,
18 pub missing: Vec<String>,
19}
20
21#[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 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}