Skip to main content

loom_core/
groups.rs

1use std::collections::{BTreeMap, HashSet};
2
3use anyhow::Result;
4
5use crate::registry::RepoEntry;
6
7/// A group entry in the selection UI: either a user-defined config group
8/// or an auto-discovered org group.
9#[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
20/// Resolve named groups to a set of matching repos.
21///
22/// Returns `(matched_repos, warnings)` where warnings describe repo names
23/// that could not be found in the registry. Errors if any group name is
24/// unknown (not defined in config).
25pub fn resolve_groups(
26    group_names: &[String],
27    groups: &BTreeMap<String, Vec<String>>,
28    all_repos: &[RepoEntry],
29) -> Result<(Vec<RepoEntry>, Vec<String>)> {
30    // Deduplicate input group names (preserving first occurrence order)
31    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    // Validate all group names exist
38    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    // Sort by (org, name) for deterministic ordering
81    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        // dsp-api appears in both groups but should be deduplicated
191        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        // Use bare name "api" in the registry so the group entry "dasch-swiss/api"
210        // must match via the format!("{}/{}", r.org, r.name) path, not r.name directly.
211        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}