Skip to main content

superstac_engine/
discovery.rs

1use std::collections::HashMap;
2
3use superstac_core::models::catalog::Catalog;
4
5/// One collection ID aggregated across all catalogs that serve it.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct CollectionAvailability {
8    pub id: String,
9    /// Catalog IDs that serve this collection (sorted).
10    pub catalogs: Vec<String>,
11}
12
13/// Aggregate the introspected `supported_collections` across all catalogs into
14/// a flat list of "collection -> serving catalogs." Catalogs whose
15/// `supported_collections` is `None` (not yet introspected) contribute nothing.
16pub fn aggregate_collections(catalogs: &[Catalog]) -> Vec<CollectionAvailability> {
17    let mut by_id: HashMap<String, Vec<String>> = HashMap::new();
18    for catalog in catalogs {
19        if let Some(supported) = &catalog.supported_collections {
20            for collection_id in supported {
21                by_id
22                    .entry(collection_id.clone())
23                    .or_default()
24                    .push(catalog.id.clone());
25            }
26        }
27    }
28    let mut result: Vec<CollectionAvailability> = by_id
29        .into_iter()
30        .map(|(id, mut catalogs)| {
31            catalogs.sort();
32            CollectionAvailability { id, catalogs }
33        })
34        .collect();
35    result.sort_by(|a, b| a.id.cmp(&b.id));
36    result
37}
38
39/// Return the IDs of every catalog whose introspected set contains
40/// `collection_id`. Catalogs with `None` (uninitialized) are excluded — we
41/// only report definite matches.
42pub fn catalogs_supporting(catalogs: &[Catalog], collection_id: &str) -> Vec<String> {
43    catalogs
44        .iter()
45        .filter(|c| {
46            c.supported_collections
47                .as_ref()
48                .map(|s| s.contains(collection_id))
49                .unwrap_or(false)
50        })
51        .map(|c| c.id.clone())
52        .collect()
53}
54
55/// Per-catalog view: catalog ID -> sorted list of canonical collection IDs.
56/// Catalogs with no introspection data are omitted.
57pub fn collections_by_catalog(catalogs: &[Catalog]) -> HashMap<String, Vec<String>> {
58    catalogs
59        .iter()
60        .filter_map(|c| {
61            c.supported_collections.as_ref().map(|s| {
62                let mut ids: Vec<String> = s.iter().cloned().collect();
63                ids.sort();
64                (c.id.clone(), ids)
65            })
66        })
67        .collect()
68}
69
70/// Return the requested collections that no known-introspected catalog can
71/// serve. Conservative: if any catalog has unknown (None) capabilities, we
72/// don't flag the collection (it might be served).
73pub fn unsupported_collections(catalogs: &[Catalog], requested: &[String]) -> Vec<String> {
74    requested
75        .iter()
76        .filter(|q| {
77            let could_be_supported = catalogs.iter().any(|c| match &c.supported_collections {
78                None => true,
79                Some(set) => set.contains(q.as_str()),
80            });
81            !could_be_supported
82        })
83        .cloned()
84        .collect()
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use std::collections::HashSet;
91
92    fn catalog(id: &str, supported: Option<&[&str]>) -> Catalog {
93        let mut c = Catalog::new(id, Some(id), "https://example.com", None::<String>, None).unwrap();
94        c.supported_collections = supported.map(|s| s.iter().map(|x| x.to_string()).collect());
95        c
96    }
97
98    #[test]
99    fn aggregate_collections_groups_by_id_and_sorts() {
100        let catalogs = vec![
101            catalog("a", Some(&["sentinel-2-l2a", "landsat-c2-l2"])),
102            catalog("b", Some(&["sentinel-2-l2a", "naip"])),
103            catalog("c", None), // contributes nothing
104        ];
105
106        let result = aggregate_collections(&catalogs);
107
108        assert_eq!(result.len(), 3);
109        // Output is sorted by collection id
110        assert_eq!(result[0].id, "landsat-c2-l2");
111        assert_eq!(result[0].catalogs, vec!["a"]);
112        assert_eq!(result[1].id, "naip");
113        assert_eq!(result[1].catalogs, vec!["b"]);
114        assert_eq!(result[2].id, "sentinel-2-l2a");
115        assert_eq!(result[2].catalogs, vec!["a", "b"]);
116    }
117
118    #[test]
119    fn catalogs_supporting_returns_only_definite_matches() {
120        let catalogs = vec![
121            catalog("a", Some(&["sentinel-2-l2a"])),
122            catalog("b", Some(&["landsat-c2-l2"])),
123            catalog("c", None),
124        ];
125
126        assert_eq!(
127            catalogs_supporting(&catalogs, "sentinel-2-l2a"),
128            vec!["a".to_string()]
129        );
130        assert!(catalogs_supporting(&catalogs, "made-up").is_empty());
131    }
132
133    #[test]
134    fn collections_by_catalog_skips_uninitialized() {
135        let catalogs = vec![
136            catalog("a", Some(&["x", "y"])),
137            catalog("b", None),
138        ];
139
140        let map = collections_by_catalog(&catalogs);
141        assert_eq!(map.len(), 1);
142        assert_eq!(map["a"], vec!["x".to_string(), "y".to_string()]);
143        assert!(!map.contains_key("b"));
144    }
145
146    #[test]
147    fn unsupported_collections_is_conservative_when_uncertain() {
148        let catalogs = vec![
149            catalog("a", Some(&["sentinel-2-l2a"])),
150            catalog("b", None), // uncertain — might support anything
151        ];
152
153        // typo collection — at least one catalog (b) is uncertain, so we
154        // can't be sure it's unsupported. Don't flag.
155        assert!(unsupported_collections(&catalogs, &["typo-collection".to_string()]).is_empty());
156    }
157
158    #[test]
159    fn unsupported_collections_flags_when_all_catalogs_known() {
160        let catalogs = vec![
161            catalog("a", Some(&["sentinel-2-l2a"])),
162            catalog("b", Some(&["landsat-c2-l2"])),
163        ];
164
165        let unsupported = unsupported_collections(
166            &catalogs,
167            &[
168                "sentinel-2-l2a".to_string(),
169                "made-up-collection".to_string(),
170            ],
171        );
172
173        assert_eq!(unsupported, vec!["made-up-collection".to_string()]);
174    }
175
176    #[test]
177    fn unsupported_collections_empty_for_empty_request() {
178        let catalogs = vec![catalog("a", Some(&["x"]))];
179        assert!(unsupported_collections(&catalogs, &[]).is_empty());
180    }
181
182    #[test]
183    #[allow(dead_code)]
184    fn aggregate_collections_no_panic_with_empty_input() {
185        let _: HashSet<String> = HashSet::new(); // touch HashSet import
186        assert!(aggregate_collections(&[]).is_empty());
187    }
188}