1use crate::user::User;
4use crate::user_file::UserFile;
5
6pub struct UserList {
7 user_file: UserFile,
8}
9
10impl UserList {
11 pub fn new(user_file: UserFile) -> Self {
12 UserList { user_file }
13 }
14
15 pub fn add(&self, user: &User) {
16 let _ = self.user_file.write(user);
17 }
18
19 pub fn list(&self) -> Vec<User> {
20 self.user_file.read()
21 }
22
23 pub fn find(&self, search_terms: &[String]) -> Result<Vec<User>, String> {
25 let all = self.list();
26 if search_terms.is_empty() {
27 return Ok(vec![]);
28 }
29 let mut matches_per_term: Vec<Vec<User>> = Vec::new();
30 for term in search_terms {
31 let m = Self::matching_users(&all, term);
32 if m.is_empty() {
33 return Err(format!("No user found matching '{}'", term));
34 }
35 matches_per_term.push(m);
36 }
37 Self::unique_combination(&matches_per_term)
38 .ok_or_else(|| {
39 format!(
40 "Couldn't find a combination of users matching {}",
41 search_terms
42 .iter()
43 .map(|s| format!("'{}'", s))
44 .collect::<Vec<_>>()
45 .join(", ")
46 )
47 })
48 }
49
50 fn matching_users(all: &[User], search_term: &str) -> Vec<User> {
51 let term_lower = search_term.to_lowercase();
52 let mut result: Vec<User> = all
53 .iter()
54 .filter(|u| {
55 u.name()
57 .split_whitespace()
58 .any(|w| w.to_lowercase() == term_lower)
59 || u.name().to_lowercase().split_whitespace().any(|w| {
60 w.starts_with(&term_lower) || term_lower.starts_with(&w.to_lowercase())
61 })
62 || u.initials().contains(&term_lower)
63 || u.name().to_lowercase().contains(&term_lower)
64 || u.email().to_lowercase().contains(&term_lower)
65 })
66 .cloned()
67 .collect();
68 result.dedup();
69 result
70 }
71
72 fn unique_combination(term_matches: &[Vec<User>]) -> Option<Vec<User>> {
73 let mut combinations: Vec<Vec<User>> = vec![vec![]];
74 for matches in term_matches {
75 let mut new_combos = Vec::new();
76 for combo in &combinations {
77 for u in matches {
78 if !combo.iter().any(|c| c == u) {
79 let mut extended = combo.clone();
80 extended.push(u.clone());
81 new_combos.push(extended);
82 }
83 }
84 }
85 combinations = new_combos;
86 }
87 combinations.into_iter().find(|c| c.len() == term_matches.len())
88 }
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94 use crate::user_file::UserFile;
95 use std::fs;
96
97 fn list_with_users(users: &[(&str, &str)]) -> (UserList, tempfile::TempDir) {
98 let dir = tempfile::tempdir().unwrap();
99 let path = dir.path().join(".git-su");
100 fs::write(&path, "").unwrap();
101 let uf = UserFile::new(&path);
102 for (name, email) in users {
103 uf.write(&User::new(*name, *email)).unwrap();
104 }
105 (UserList::new(uf), dir)
106 }
107
108 #[test]
109 fn find_by_initials() {
110 let (list, _dir) = list_with_users(&[
111 ("Jane Doe", "jane@example.com"),
112 ("Bob Smith", "bob@example.com"),
113 ]);
114 let found = list.find(&["jd".to_string()]).unwrap();
115 assert_eq!(found.len(), 1);
116 assert_eq!(found[0].name(), "Jane Doe");
117 }
118
119 #[test]
120 fn find_by_name_part() {
121 let (list, _dir) = list_with_users(&[("Alice Cooper", "alice@example.com")]);
122 let found = list.find(&["alice".to_string()]).unwrap();
123 assert_eq!(found.len(), 1);
124 assert_eq!(found[0].name(), "Alice Cooper");
125 }
126
127 #[test]
128 fn find_no_match() {
129 let (list, _dir) = list_with_users(&[("Jane Doe", "jane@example.com")]);
130 let r = list.find(&["xyz".to_string()]);
131 assert!(r.is_err());
132 }
133
134 #[test]
135 fn find_pair_two_terms() {
136 let (list, _dir) = list_with_users(&[
137 ("Jane Doe", "jane@example.com"),
138 ("Bob Smith", "bob@example.com"),
139 ]);
140 let found = list.find(&["jd".to_string(), "bob".to_string()]).unwrap();
141 assert_eq!(found.len(), 2);
142 assert_eq!(found[0].name(), "Jane Doe");
143 assert_eq!(found[1].name(), "Bob Smith");
144 }
145
146 #[test]
147 fn list_returns_added_users() {
148 let (list, _dir) = list_with_users(&[("Jane Doe", "jane@example.com")]);
149 let all = list.list();
150 assert_eq!(all.len(), 1);
151 assert_eq!(all[0].email(), "jane@example.com");
152 }
153}