Skip to main content

rskit_util/strings/
resolve.rs

1//! Resolve a user shorthand to the unique candidate it names.
2//!
3//! A recurring command-line need: the user types a short token (`core`,
4//! `billing`) and the tool must map it to exactly one item from a known set,
5//! erroring rather than guessing when the token is ambiguous. This mirrors how
6//! `git`, `cargo -p`, and `kubectl` accept a shorthand only when it is
7//! unambiguous. The helper is error-policy-neutral — it distinguishes "no match"
8//! (returned as `None`) from "several matches" (returned as a typed
9//! [`Ambiguity`]) so the caller wraps each outcome in whatever domain error
10//! carries the right context.
11
12use std::fmt;
13
14/// The candidates a shorthand matched when more than one qualified.
15///
16/// Carries the offending `input` and every matching candidate in the order they
17/// were encountered, so the caller can render an actionable "did you mean one of
18/// …?" diagnostic. Held by value so it outlives the borrowed candidate iterator.
19#[derive(Debug, Clone, Eq, PartialEq)]
20pub struct Ambiguity<T> {
21    /// The shorthand that matched more than one candidate.
22    pub input: String,
23    /// Every candidate whose key equalled `input`, in encounter order.
24    pub matches: Vec<T>,
25}
26
27impl<T: fmt::Display> fmt::Display for Ambiguity<T> {
28    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
29        write!(formatter, "'{}' is ambiguous; matched ", self.input)?;
30        for (index, candidate) in self.matches.iter().enumerate() {
31            if index > 0 {
32                formatter.write_str(", ")?;
33            }
34            write!(formatter, "{candidate}")?;
35        }
36        Ok(())
37    }
38}
39
40impl<T: fmt::Debug + fmt::Display> std::error::Error for Ambiguity<T> {}
41
42/// Resolve `input` to the unique candidate whose key equals it.
43///
44/// `key_of` projects each candidate to the string compared against `input`.
45/// Returns `Ok(Some(candidate))` when exactly one candidate's key equals `input`,
46/// `Ok(None)` when none do (the caller decides whether an empty match is an error
47/// and how to list the alternatives), and an [`Ambiguity`] error listing the
48/// matches when two or more share the key. Comparison is exact and
49/// case-sensitive; callers wanting fuzzy resolution use
50/// [`nearest`](super::nearest) instead.
51///
52/// # Examples
53/// ```
54/// use rskit_util::strings::{resolve_unique, Ambiguity};
55///
56/// fn tail<'a>(candidate: &'a &str) -> &'a str {
57///     candidate.rsplit('/').next().unwrap_or(candidate)
58/// }
59/// let candidates = ["service/user", "job/report", "service/report"];
60///
61/// assert_eq!(resolve_unique("user", candidates, tail), Ok(Some("service/user")));
62/// assert_eq!(resolve_unique("missing", candidates, tail), Ok(None));
63/// assert!(matches!(resolve_unique("report", candidates, tail), Err(Ambiguity { .. })));
64/// ```
65///
66/// # Errors
67/// Returns [`Ambiguity`] when `input` matches more than one candidate.
68pub fn resolve_unique<T, I, F>(
69    input: &str,
70    candidates: I,
71    key_of: F,
72) -> Result<Option<T>, Ambiguity<T>>
73where
74    I: IntoIterator<Item = T>,
75    F: Fn(&T) -> &str,
76{
77    let mut hits = candidates
78        .into_iter()
79        .filter(|candidate| key_of(candidate) == input);
80    let Some(first) = hits.next() else {
81        return Ok(None);
82    };
83    let Some(second) = hits.next() else {
84        return Ok(Some(first));
85    };
86    let mut matches = vec![first, second];
87    matches.extend(hits);
88    Err(Ambiguity {
89        input: input.to_owned(),
90        matches,
91    })
92}
93
94#[cfg(test)]
95mod tests {
96    use super::{Ambiguity, resolve_unique};
97
98    fn tail<'a>(candidate: &'a &str) -> &'a str {
99        candidate.rsplit('/').next().unwrap_or(candidate)
100    }
101
102    #[test]
103    fn unique_match_resolves() {
104        let candidates = ["service/user", "job/report"];
105        assert_eq!(
106            resolve_unique("user", candidates, tail),
107            Ok(Some("service/user"))
108        );
109    }
110
111    #[test]
112    fn no_match_is_none() {
113        let candidates = ["service/user", "job/report"];
114        assert_eq!(resolve_unique("ghost", candidates, tail), Ok(None));
115    }
116
117    #[test]
118    fn several_matches_are_ambiguous_in_order() {
119        let candidates = ["service/report", "job/report"];
120        let error = resolve_unique("report", candidates, tail).unwrap_err();
121        assert_eq!(
122            error,
123            Ambiguity {
124                input: "report".to_owned(),
125                matches: vec!["service/report", "job/report"],
126            }
127        );
128    }
129
130    #[test]
131    fn ambiguity_renders_the_candidates() {
132        let candidates = ["service/report", "job/report"];
133        let error = resolve_unique("report", candidates, tail).unwrap_err();
134        assert_eq!(
135            error.to_string(),
136            "'report' is ambiguous; matched service/report, job/report"
137        );
138    }
139
140    #[test]
141    fn ambiguity_is_a_standard_error() {
142        fn wrap(error: impl std::error::Error) -> String {
143            error.to_string()
144        }
145        let candidates = ["service/report", "job/report"];
146        let error = resolve_unique("report", candidates, tail).unwrap_err();
147        assert_eq!(
148            wrap(error),
149            "'report' is ambiguous; matched service/report, job/report"
150        );
151    }
152
153    #[test]
154    fn comparison_is_case_sensitive() {
155        let candidates = ["service/User"];
156        assert_eq!(resolve_unique("user", candidates, tail), Ok(None));
157    }
158}