1use std::collections::{BTreeMap, HashSet};
2
3use anyhow::Result;
4
5use crate::registry::RepoEntry;
6
7#[derive(Debug, Clone)]
10pub enum GroupEntry {
11 ConfigGroup {
12 name: String,
13 repo_names: Vec<String>,
14 },
15 OrgGroup {
16 name: String,
17 },
18}
19
20pub fn resolve_groups(
26 group_names: &[String],
27 groups: &BTreeMap<String, Vec<String>>,
28 all_repos: &[RepoEntry],
29) -> Result<(Vec<RepoEntry>, Vec<String>)> {
30 let mut seen = HashSet::new();
32 let unique_names: Vec<&String> = group_names
33 .iter()
34 .filter(|n| seen.insert(n.as_str()))
35 .collect();
36
37 for name in &unique_names {
39 if !groups.contains_key(name.as_str()) {
40 let available: Vec<&str> = groups.keys().map(|s| s.as_str()).collect();
41 if available.is_empty() {
42 anyhow::bail!(
43 "Group '{}' not found. No groups defined in config.toml.",
44 name
45 );
46 } else {
47 anyhow::bail!(
48 "Group '{}' not found. Available groups: {}",
49 name,
50 available.join(", ")
51 );
52 }
53 }
54 }
55
56 let mut matched = Vec::new();
57 let mut warnings = Vec::new();
58 let mut seen_paths: HashSet<std::path::PathBuf> = HashSet::new();
59
60 for group_name in &unique_names {
61 let repo_names = &groups[group_name.as_str()];
62 for repo_name in repo_names {
63 let found = all_repos.iter().find(|r| r.matches_name(repo_name));
64 match found {
65 Some(r) => {
66 if seen_paths.insert(r.path.clone()) {
67 matched.push(r.clone());
68 }
69 }
70 None => {
71 warnings.push(format!(
72 "Group '{}': repo '{}' not found in registry",
73 group_name, repo_name
74 ));
75 }
76 }
77 }
78 }
79
80 matched.sort_by(|a, b| (&a.org, &a.name).cmp(&(&b.org, &b.name)));
82
83 Ok((matched, warnings))
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89 use std::path::PathBuf;
90
91 fn make_repo(name: &str, org: &str) -> RepoEntry {
92 RepoEntry {
93 name: name.to_string(),
94 org: org.to_string(),
95 path: PathBuf::from(format!("/code/{}/{}", org, name)),
96 remote_url: None,
97 }
98 }
99
100 fn make_groups(entries: &[(&str, &[&str])]) -> BTreeMap<String, Vec<String>> {
101 entries
102 .iter()
103 .map(|(name, repos)| {
104 (
105 name.to_string(),
106 repos.iter().map(|s| s.to_string()).collect(),
107 )
108 })
109 .collect()
110 }
111
112 #[test]
113 fn test_resolve_all_match() {
114 let repos = vec![
115 make_repo("dsp-api", "dasch-swiss"),
116 make_repo("dsp-das", "dasch-swiss"),
117 make_repo("sipi", "dasch-swiss"),
118 ];
119 let groups = make_groups(&[("dsp-stack", &["dsp-api", "dsp-das", "sipi"])]);
120
121 let (matched, warnings) =
122 resolve_groups(&["dsp-stack".to_string()], &groups, &repos).unwrap();
123
124 assert_eq!(matched.len(), 3);
125 assert!(warnings.is_empty());
126 }
127
128 #[test]
129 fn test_resolve_partial_match() {
130 let repos = vec![
131 make_repo("dsp-api", "dasch-swiss"),
132 make_repo("sipi", "dasch-swiss"),
133 ];
134 let groups = make_groups(&[("stack", &["dsp-api", "dsp-das", "sipi"])]);
135
136 let (matched, warnings) = resolve_groups(&["stack".to_string()], &groups, &repos).unwrap();
137
138 assert_eq!(matched.len(), 2);
139 assert_eq!(warnings.len(), 1);
140 assert!(warnings[0].contains("dsp-das"));
141 }
142
143 #[test]
144 fn test_resolve_no_match() {
145 let repos = vec![make_repo("unrelated", "org")];
146 let groups = make_groups(&[("stack", &["dsp-api"])]);
147
148 let (matched, warnings) = resolve_groups(&["stack".to_string()], &groups, &repos).unwrap();
149
150 assert!(matched.is_empty());
151 assert_eq!(warnings.len(), 1);
152 }
153
154 #[test]
155 fn test_resolve_unknown_group() {
156 let repos = vec![make_repo("dsp-api", "dasch-swiss")];
157 let groups = make_groups(&[("dsp-stack", &["dsp-api"])]);
158
159 let err = resolve_groups(&["nonexistent".to_string()], &groups, &repos).unwrap_err();
160
161 assert!(err.to_string().contains("not found"));
162 assert!(err.to_string().contains("dsp-stack"));
163 }
164
165 #[test]
166 fn test_resolve_unknown_group_no_groups_defined() {
167 let repos = vec![make_repo("dsp-api", "dasch-swiss")];
168 let groups = BTreeMap::new();
169
170 let err = resolve_groups(&["nonexistent".to_string()], &groups, &repos).unwrap_err();
171
172 assert!(err.to_string().contains("No groups defined"));
173 }
174
175 #[test]
176 fn test_resolve_overlapping_groups_deduplicates() {
177 let repos = vec![
178 make_repo("dsp-api", "dasch-swiss"),
179 make_repo("dsp-das", "dasch-swiss"),
180 make_repo("sipi", "dasch-swiss"),
181 ];
182 let groups = make_groups(&[
183 ("stack", &["dsp-api", "dsp-das"]),
184 ("full", &["dsp-api", "sipi"]),
185 ]);
186
187 let (matched, warnings) =
188 resolve_groups(&["stack".to_string(), "full".to_string()], &groups, &repos).unwrap();
189
190 assert_eq!(matched.len(), 3);
192 assert!(warnings.is_empty());
193 }
194
195 #[test]
196 fn test_resolve_duplicate_group_names_deduplicated() {
197 let repos = vec![make_repo("dsp-api", "dasch-swiss")];
198 let groups = make_groups(&[("stack", &["dsp-api"])]);
199
200 let (matched, warnings) =
201 resolve_groups(&["stack".to_string(), "stack".to_string()], &groups, &repos).unwrap();
202
203 assert_eq!(matched.len(), 1);
204 assert!(warnings.is_empty());
205 }
206
207 #[test]
208 fn test_resolve_by_org_name() {
209 let repos = vec![make_repo("api", "dasch-swiss")];
212 let groups = make_groups(&[("stack", &["dasch-swiss/api"])]);
213
214 let (matched, warnings) = resolve_groups(&["stack".to_string()], &groups, &repos).unwrap();
215
216 assert_eq!(matched.len(), 1);
217 assert_eq!(matched[0].name, "api");
218 assert!(warnings.is_empty());
219 }
220
221 #[test]
222 fn test_resolve_sorted_by_org_name() {
223 let repos = vec![
224 make_repo("sipi", "dasch-swiss"),
225 make_repo("tools", "acme"),
226 make_repo("dsp-api", "dasch-swiss"),
227 ];
228 let groups = make_groups(&[("all", &["sipi", "tools", "dsp-api"])]);
229
230 let (matched, _) = resolve_groups(&["all".to_string()], &groups, &repos).unwrap();
231
232 assert_eq!(matched[0].org, "acme");
233 assert_eq!(matched[1].name, "dsp-api");
234 assert_eq!(matched[2].name, "sipi");
235 }
236}