Skip to main content

influxdb3_plugin_schemas/
index_query.rs

1//! Index query primitives: search and info over parsed registry indexes.
2
3use crate::{
4    ArtifactHash, Dependencies, Description, IndexEntry, PluginName, PublishedAt, TriggerType,
5};
6
7/// Search query parameters for browsing an index.
8#[derive(Debug, Clone, Default)]
9pub struct IndexSearchQuery {
10    pub query: Option<String>,
11    pub trigger_type: Option<TriggerType>,
12    pub database_version: Option<semver::Version>,
13    pub include_yanked: bool,
14    pub include_incompatible: bool,
15}
16
17/// Search result containing one hit per plugin.
18#[derive(Debug, Clone, PartialEq, serde::Serialize)]
19pub struct IndexSearchResult {
20    pub hits: Vec<IndexSearchHit>,
21}
22
23/// One search hit summarizing the latest matching version of a plugin.
24#[derive(Debug, Clone, PartialEq, serde::Serialize)]
25pub struct IndexSearchHit {
26    pub name: PluginName,
27    pub version: semver::Version,
28    pub published_at: PublishedAt,
29    pub description: Description,
30    pub triggers: Vec<TriggerType>,
31    pub visibility: IndexVersionVisibility,
32}
33
34/// Visibility state for a version in query results.
35#[derive(Debug, Clone, PartialEq, serde::Serialize)]
36pub enum IndexVersionVisibility {
37    Visible,
38    Hidden { reasons: Vec<IndexVisibilityReason> },
39}
40
41/// Reason a version is hidden from default query results.
42#[derive(Debug, Clone, PartialEq, serde::Serialize)]
43pub enum IndexVisibilityReason {
44    Yanked,
45    IncompatibleDatabaseVersion {
46        required: semver::VersionReq,
47        actual: semver::Version,
48    },
49}
50
51/// Info query parameters for inspecting a specific plugin.
52#[derive(Debug, Clone)]
53pub struct IndexInfoQuery {
54    pub name: PluginName,
55    pub version: Option<semver::Version>,
56    pub database_version: Option<semver::Version>,
57    pub include_yanked: bool,
58    pub include_incompatible: bool,
59}
60
61/// Info result distinguishing found, absent, and filtered-out states.
62#[derive(Debug, Clone, PartialEq, serde::Serialize)]
63pub enum IndexInfoResult {
64    Found(Box<IndexInfo>),
65    NotFound {
66        name: PluginName,
67        version: Option<semver::Version>,
68    },
69    FilteredOut {
70        name: PluginName,
71        version: Option<semver::Version>,
72        reasons: Vec<IndexVisibilityReason>,
73    },
74}
75
76/// Full metadata for a single plugin version.
77#[derive(Debug, Clone, PartialEq, serde::Serialize)]
78pub struct IndexInfo {
79    pub name: PluginName,
80    pub version: semver::Version,
81    pub published_at: PublishedAt,
82    pub description: Description,
83    pub triggers: Vec<TriggerType>,
84    pub homepage: Option<url::Url>,
85    pub repository: Option<url::Url>,
86    pub documentation: Option<url::Url>,
87    pub dependencies: Dependencies,
88    pub hash: ArtifactHash,
89    pub visibility: IndexVersionVisibility,
90}
91
92pub(crate) fn visibility_for(
93    entry: &IndexEntry,
94    database_version: Option<&semver::Version>,
95) -> IndexVersionVisibility {
96    let mut reasons = Vec::new();
97    if entry.yanked {
98        reasons.push(IndexVisibilityReason::Yanked);
99    }
100    if let Some(db_ver) =
101        database_version.filter(|v| !entry.dependencies.database_version.matches(v))
102    {
103        reasons.push(IndexVisibilityReason::IncompatibleDatabaseVersion {
104            required: entry.dependencies.database_version.clone(),
105            actual: db_ver.clone(),
106        });
107    }
108    if reasons.is_empty() {
109        IndexVersionVisibility::Visible
110    } else {
111        IndexVersionVisibility::Hidden { reasons }
112    }
113}
114
115fn info_from_entry(entry: &IndexEntry, visibility: IndexVersionVisibility) -> IndexInfo {
116    IndexInfo {
117        name: entry.name.clone(),
118        version: entry.version.clone(),
119        published_at: entry.published_at.clone(),
120        description: entry.description.clone(),
121        triggers: entry.triggers.clone(),
122        homepage: entry.homepage.clone(),
123        repository: entry.repository.clone(),
124        documentation: entry.documentation.clone(),
125        dependencies: entry.dependencies.clone(),
126        hash: entry.hash.clone(),
127        visibility,
128    }
129}
130
131impl crate::Index {
132    pub fn search(&self, query: &IndexSearchQuery) -> IndexSearchResult {
133        use std::collections::BTreeMap;
134
135        let query_text = query
136            .query
137            .as_deref()
138            .map(|s| s.trim())
139            .filter(|s| !s.is_empty());
140        let query_lower = query_text.map(|s| s.to_ascii_lowercase());
141        let query_canonical = query_text.map(crate::identity::canonical_name);
142
143        // BTreeMap keyed by canonical name ensures output is sorted by canonical name ascending
144        let mut groups: BTreeMap<String, Vec<(&IndexEntry, IndexVersionVisibility)>> =
145            BTreeMap::new();
146
147        for entry in &self.plugins {
148            let vis = visibility_for(entry, query.database_version.as_ref());
149
150            let excluded = matches!(&vis, IndexVersionVisibility::Hidden { reasons }
151                if reasons.iter().any(|r| match r {
152                    IndexVisibilityReason::Yanked => !query.include_yanked,
153                    IndexVisibilityReason::IncompatibleDatabaseVersion { .. } => {
154                        !query.include_incompatible
155                    }
156                })
157            );
158            if excluded {
159                continue;
160            }
161
162            if let Some(ref q_lower) = query_lower {
163                let name_lower = entry.name.as_str().to_ascii_lowercase();
164                let canonical = entry.name.canonical();
165                let desc_lower = entry.description.as_str().to_ascii_lowercase();
166                let q_canon = query_canonical.as_deref().unwrap_or("");
167
168                let matches = name_lower.contains(q_lower.as_str())
169                    || canonical.contains(q_canon)
170                    || desc_lower.contains(q_lower.as_str());
171                if !matches {
172                    continue;
173                }
174            }
175
176            if matches!(&query.trigger_type, Some(t) if !entry.triggers.contains(t)) {
177                continue;
178            }
179
180            groups
181                .entry(entry.name.canonical())
182                .or_default()
183                .push((entry, vis));
184        }
185
186        let mut hits = Vec::with_capacity(groups.len());
187        for (_canonical, mut candidates) in groups {
188            // SemVer precedence descending; full version comparison as tiebreaker
189            candidates.sort_by(|a, b| {
190                b.0.version
191                    .cmp_precedence(&a.0.version)
192                    .then_with(|| b.0.version.cmp(&a.0.version))
193            });
194            let (entry, vis) = &candidates[0];
195            hits.push(IndexSearchHit {
196                name: entry.name.clone(),
197                version: entry.version.clone(),
198                published_at: entry.published_at.clone(),
199                description: entry.description.clone(),
200                triggers: entry.triggers.clone(),
201                visibility: vis.clone(),
202            });
203        }
204
205        IndexSearchResult { hits }
206    }
207
208    pub fn info(&self, query: &IndexInfoQuery) -> IndexInfoResult {
209        let query_canonical = query.name.canonical();
210
211        // Exact-version inspection: always returns Found if the entry exists
212        if let Some(ref requested_version) = query.version {
213            let found = self
214                .plugins
215                .iter()
216                .find(|e| e.name.canonical() == query_canonical && e.version == *requested_version);
217            return match found {
218                Some(entry) => {
219                    let vis = visibility_for(entry, query.database_version.as_ref());
220                    IndexInfoResult::Found(Box::new(info_from_entry(entry, vis)))
221                }
222                None => IndexInfoResult::NotFound {
223                    name: query.name.clone(),
224                    version: Some(requested_version.clone()),
225                },
226            };
227        }
228
229        // No version specified: collect all entries for this plugin
230        let candidates: Vec<(&IndexEntry, IndexVersionVisibility)> = self
231            .plugins
232            .iter()
233            .filter(|e| e.name.canonical() == query_canonical)
234            .map(|e| {
235                let vis = visibility_for(e, query.database_version.as_ref());
236                (e, vis)
237            })
238            .collect();
239
240        if candidates.is_empty() {
241            return IndexInfoResult::NotFound {
242                name: query.name.clone(),
243                version: None,
244            };
245        }
246
247        // Partition into selectable (visible or opted-in) vs excluded
248        let (mut selectable, excluded): (Vec<_>, Vec<_>) =
249            candidates.into_iter().partition(|(_, vis)| match vis {
250                IndexVersionVisibility::Visible => true,
251                IndexVersionVisibility::Hidden { reasons } => reasons.iter().all(|r| match r {
252                    IndexVisibilityReason::Yanked => query.include_yanked,
253                    IndexVisibilityReason::IncompatibleDatabaseVersion { .. } => {
254                        query.include_incompatible
255                    }
256                }),
257            });
258
259        if selectable.is_empty() {
260            // All versions hidden — collect distinct reason kinds
261            let mut has_yanked = false;
262            let mut incompat_reason: Option<IndexVisibilityReason> = None;
263            for (_, vis) in &excluded {
264                if let IndexVersionVisibility::Hidden { reasons } = vis {
265                    for r in reasons {
266                        match r {
267                            IndexVisibilityReason::Yanked => has_yanked = true,
268                            IndexVisibilityReason::IncompatibleDatabaseVersion { .. } => {
269                                if incompat_reason.is_none() {
270                                    incompat_reason = Some(r.clone());
271                                }
272                            }
273                        }
274                    }
275                }
276            }
277            let mut reasons = Vec::new();
278            if has_yanked {
279                reasons.push(IndexVisibilityReason::Yanked);
280            }
281            if let Some(ir) = incompat_reason {
282                reasons.push(ir);
283            }
284            return IndexInfoResult::FilteredOut {
285                name: query.name.clone(),
286                version: None,
287                reasons,
288            };
289        }
290
291        // Select latest version from selectable candidates
292        selectable.sort_by(|a, b| {
293            b.0.version
294                .cmp_precedence(&a.0.version)
295                .then_with(|| b.0.version.cmp(&a.0.version))
296        });
297        let (entry, vis) = &selectable[0];
298        IndexInfoResult::Found(Box::new(info_from_entry(entry, vis.clone())))
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use crate::{
306        ArtifactHash, ArtifactsUrl, Dependencies, Description, Index, IndexEntry,
307        IndexSchemaVersion, PublishedAt, PythonRequirement, TriggerType,
308    };
309    use assert_matches::assert_matches;
310    use pretty_assertions::assert_eq;
311
312    fn make_entry(name: &str, version: &str) -> IndexEntry {
313        make_entry_with_published_at(name, version, "2026-04-29T18:45:12Z")
314    }
315
316    fn make_entry_with_published_at(name: &str, version: &str, published_at: &str) -> IndexEntry {
317        IndexEntry {
318            name: name.parse().unwrap(),
319            version: version.parse().unwrap(),
320            published_at: PublishedAt::try_new(published_at).unwrap(),
321            description: Description::try_new("desc").unwrap(),
322            triggers: vec![TriggerType::ProcessWrites],
323            homepage: None,
324            repository: None,
325            documentation: None,
326            dependencies: Dependencies {
327                database_version: ">=3.0.0".parse().unwrap(),
328                python: vec![],
329                plugins: vec![],
330            },
331            hash: ArtifactHash::try_new(
332                "sha256:0000000000000000000000000000000000000000000000000000000000000000",
333            )
334            .unwrap(),
335            yanked: false,
336        }
337    }
338
339    fn make_index(entries: Vec<IndexEntry>) -> Index {
340        Index {
341            index_schema_version: IndexSchemaVersion::CURRENT,
342            artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
343            plugins: entries,
344        }
345    }
346
347    // --- Visibility helper tests ---
348
349    #[test]
350    fn visibility_visible_when_not_yanked_and_compatible() {
351        let entry = make_entry("foo", "1.0.0");
352        let vis = visibility_for(&entry, Some(&"3.2.0".parse().unwrap()));
353        assert_eq!(vis, IndexVersionVisibility::Visible);
354    }
355
356    #[test]
357    fn visibility_hidden_when_yanked() {
358        let mut entry = make_entry("foo", "1.0.0");
359        entry.yanked = true;
360        let vis = visibility_for(&entry, Some(&"3.2.0".parse().unwrap()));
361        assert_matches!(&vis, IndexVersionVisibility::Hidden { reasons } => {
362            assert_eq!(reasons.len(), 1);
363            assert_matches!(&reasons[0], IndexVisibilityReason::Yanked);
364        });
365    }
366
367    #[test]
368    fn visibility_hidden_when_incompatible() {
369        let mut entry = make_entry("foo", "1.0.0");
370        entry.dependencies.database_version = ">=4.0.0".parse().unwrap();
371        let vis = visibility_for(&entry, Some(&"3.2.0".parse().unwrap()));
372        assert_matches!(&vis, IndexVersionVisibility::Hidden { reasons } => {
373            assert_eq!(reasons.len(), 1);
374            assert_matches!(&reasons[0], IndexVisibilityReason::IncompatibleDatabaseVersion {
375                required, actual
376            } => {
377                assert_eq!(required.to_string(), ">=4.0.0");
378                assert_eq!(*actual, "3.2.0".parse::<semver::Version>().unwrap());
379            });
380        });
381    }
382
383    #[test]
384    fn visibility_hidden_yanked_and_incompatible() {
385        let mut entry = make_entry("foo", "1.0.0");
386        entry.yanked = true;
387        entry.dependencies.database_version = ">=4.0.0".parse().unwrap();
388        let vis = visibility_for(&entry, Some(&"3.2.0".parse().unwrap()));
389        assert_matches!(&vis, IndexVersionVisibility::Hidden { reasons } => {
390            assert_eq!(reasons.len(), 2);
391            assert_matches!(&reasons[0], IndexVisibilityReason::Yanked);
392            assert_matches!(&reasons[1], IndexVisibilityReason::IncompatibleDatabaseVersion { .. });
393        });
394    }
395
396    #[test]
397    fn visibility_no_compat_check_without_db_version() {
398        let mut entry = make_entry("foo", "1.0.0");
399        entry.dependencies.database_version = ">=99.0.0".parse().unwrap();
400        let vis = visibility_for(&entry, None);
401        assert_eq!(vis, IndexVersionVisibility::Visible);
402    }
403
404    // --- Search tests ---
405
406    #[test]
407    fn search_empty_query_matches_all() {
408        let index = make_index(vec![
409            make_entry("alpha", "1.0.0"),
410            make_entry("beta", "2.0.0"),
411        ]);
412        let result = index.search(&IndexSearchQuery::default());
413        assert_eq!(result.hits.len(), 2);
414    }
415
416    #[test]
417    fn search_whitespace_query_matches_all() {
418        let index = make_index(vec![
419            make_entry("alpha", "1.0.0"),
420            make_entry("beta", "2.0.0"),
421        ]);
422        let result = index.search(&IndexSearchQuery {
423            query: Some("   ".into()),
424            ..Default::default()
425        });
426        assert_eq!(result.hits.len(), 2);
427    }
428
429    #[test]
430    fn search_name_substring_match() {
431        let index = make_index(vec![
432            make_entry("downsampler", "1.0.0"),
433            make_entry("alerter", "1.0.0"),
434        ]);
435        let result = index.search(&IndexSearchQuery {
436            query: Some("sample".into()),
437            ..Default::default()
438        });
439        assert_eq!(result.hits.len(), 1);
440        assert_eq!(result.hits[0].name.as_str(), "downsampler");
441    }
442
443    #[test]
444    fn search_description_substring_match() {
445        let mut entry = make_entry("notifier", "1.0.0");
446        entry.description = Description::try_new("Fires on every WAL commit").unwrap();
447        let index = make_index(vec![entry, make_entry("other", "1.0.0")]);
448        let result = index.search(&IndexSearchQuery {
449            query: Some("wal".into()),
450            ..Default::default()
451        });
452        assert_eq!(result.hits.len(), 1);
453        assert_eq!(result.hits[0].name.as_str(), "notifier");
454    }
455
456    #[test]
457    fn search_case_insensitive() {
458        let index = make_index(vec![make_entry("downsampler", "1.0.0")]);
459        let result = index.search(&IndexSearchQuery {
460            query: Some("DOWNSAMPLE".into()),
461            ..Default::default()
462        });
463        assert_eq!(result.hits.len(), 1);
464        assert_eq!(result.hits[0].name.as_str(), "downsampler");
465    }
466
467    #[test]
468    fn search_canonical_name_matching() {
469        // Hyphens in name, underscores in query
470        let index = make_index(vec![make_entry("my-plugin", "1.0.0")]);
471        let result = index.search(&IndexSearchQuery {
472            query: Some("my_plugin".into()),
473            ..Default::default()
474        });
475        assert_eq!(result.hits.len(), 1);
476        assert_eq!(result.hits[0].name.as_str(), "my-plugin");
477
478        // Underscores in name, hyphens in query
479        let index = make_index(vec![make_entry("my_plugin", "1.0.0")]);
480        let result = index.search(&IndexSearchQuery {
481            query: Some("my-plugin".into()),
482            ..Default::default()
483        });
484        assert_eq!(result.hits.len(), 1);
485        assert_eq!(result.hits[0].name.as_str(), "my_plugin");
486    }
487
488    #[test]
489    fn search_no_dependency_text_search() {
490        let mut entry = make_entry("downsampler", "1.0.0");
491        entry.dependencies.python = vec![PythonRequirement::try_new("requests>=2.31").unwrap()];
492        let index = make_index(vec![entry]);
493        let result = index.search(&IndexSearchQuery {
494            query: Some("requests".into()),
495            ..Default::default()
496        });
497        assert_eq!(result.hits.len(), 0);
498    }
499
500    #[test]
501    fn search_no_url_hash_trigger_text_search() {
502        let mut entry = make_entry("downsampler", "1.0.0");
503        entry.documentation = Some("https://docs.example.com/searchable".parse().unwrap());
504        let index = make_index(vec![entry]);
505        let result = index.search(&IndexSearchQuery {
506            query: Some("searchable".into()),
507            ..Default::default()
508        });
509        assert_eq!(result.hits.len(), 0);
510    }
511
512    // --- Search filtering tests ---
513
514    #[test]
515    fn search_trigger_type_filter_includes() {
516        let index = make_index(vec![make_entry("writer", "1.0.0")]);
517        let result = index.search(&IndexSearchQuery {
518            trigger_type: Some(TriggerType::ProcessWrites),
519            ..Default::default()
520        });
521        assert_eq!(result.hits.len(), 1);
522    }
523
524    #[test]
525    fn search_trigger_type_filter_excludes() {
526        let mut entry = make_entry("requester", "1.0.0");
527        entry.triggers = vec![TriggerType::ProcessRequest];
528        let index = make_index(vec![entry]);
529        let result = index.search(&IndexSearchQuery {
530            trigger_type: Some(TriggerType::ProcessWrites),
531            ..Default::default()
532        });
533        assert_eq!(result.hits.len(), 0);
534    }
535
536    #[test]
537    fn search_yanked_hidden_by_default() {
538        let mut entry = make_entry("obsolete", "1.0.0");
539        entry.yanked = true;
540        let index = make_index(vec![entry]);
541        let result = index.search(&IndexSearchQuery::default());
542        assert_eq!(result.hits.len(), 0);
543    }
544
545    #[test]
546    fn search_yanked_included_when_requested() {
547        let mut entry = make_entry("obsolete", "1.0.0");
548        entry.yanked = true;
549        let index = make_index(vec![entry]);
550        let result = index.search(&IndexSearchQuery {
551            include_yanked: true,
552            ..Default::default()
553        });
554        assert_eq!(result.hits.len(), 1);
555        assert_matches!(
556            &result.hits[0].visibility,
557            IndexVersionVisibility::Hidden { reasons }
558                if reasons.len() == 1
559                    && matches!(&reasons[0], IndexVisibilityReason::Yanked)
560        );
561    }
562
563    #[test]
564    fn search_incompatible_hidden_with_db_version() {
565        let mut entry = make_entry("future", "1.0.0");
566        entry.dependencies.database_version = ">=4.0.0".parse().unwrap();
567        let index = make_index(vec![entry]);
568        let result = index.search(&IndexSearchQuery {
569            database_version: Some("3.2.0".parse().unwrap()),
570            ..Default::default()
571        });
572        assert_eq!(result.hits.len(), 0);
573    }
574
575    #[test]
576    fn search_incompatible_included_when_requested() {
577        let mut entry = make_entry("future", "1.0.0");
578        entry.dependencies.database_version = ">=4.0.0".parse().unwrap();
579        let index = make_index(vec![entry]);
580        let result = index.search(&IndexSearchQuery {
581            database_version: Some("3.2.0".parse().unwrap()),
582            include_incompatible: true,
583            ..Default::default()
584        });
585        assert_eq!(result.hits.len(), 1);
586        assert_matches!(
587            &result.hits[0].visibility,
588            IndexVersionVisibility::Hidden { reasons }
589                if reasons.len() == 1
590                    && matches!(&reasons[0], IndexVisibilityReason::IncompatibleDatabaseVersion { .. })
591        );
592    }
593
594    #[test]
595    fn search_no_compat_filter_without_db_version() {
596        let mut entry = make_entry("future", "1.0.0");
597        entry.dependencies.database_version = ">=99.0.0".parse().unwrap();
598        let index = make_index(vec![entry]);
599        let result = index.search(&IndexSearchQuery::default());
600        assert_eq!(result.hits.len(), 1);
601        assert_eq!(result.hits[0].visibility, IndexVersionVisibility::Visible);
602    }
603
604    #[test]
605    fn search_yanked_and_incompatible_reasons_accumulate() {
606        let mut entry = make_entry("doomed", "1.0.0");
607        entry.yanked = true;
608        entry.dependencies.database_version = ">=4.0.0".parse().unwrap();
609        let index = make_index(vec![entry]);
610        let result = index.search(&IndexSearchQuery {
611            database_version: Some("3.2.0".parse().unwrap()),
612            include_yanked: true,
613            include_incompatible: true,
614            ..Default::default()
615        });
616        assert_eq!(result.hits.len(), 1);
617        assert_matches!(
618            &result.hits[0].visibility,
619            IndexVersionVisibility::Hidden { reasons } => {
620                assert_eq!(reasons.len(), 2);
621                assert_matches!(&reasons[0], IndexVisibilityReason::Yanked);
622                assert_matches!(&reasons[1], IndexVisibilityReason::IncompatibleDatabaseVersion { .. });
623            }
624        );
625    }
626
627    // --- Search version selection + ordering tests ---
628
629    #[test]
630    fn search_one_hit_per_plugin() {
631        let index = make_index(vec![
632            make_entry("alpha", "1.0.0"),
633            make_entry("alpha", "2.0.0"),
634            make_entry("alpha", "3.0.0"),
635        ]);
636        let result = index.search(&IndexSearchQuery::default());
637        assert_eq!(result.hits.len(), 1);
638    }
639
640    #[test]
641    fn search_latest_visible_version_selected() {
642        let index = make_index(vec![
643            make_entry("alpha", "1.0.0"),
644            make_entry("alpha", "1.2.0"),
645            make_entry("alpha", "2.0.0"),
646        ]);
647        let result = index.search(&IndexSearchQuery::default());
648        assert_eq!(result.hits.len(), 1);
649        assert_eq!(
650            result.hits[0].version,
651            "2.0.0".parse::<semver::Version>().unwrap()
652        );
653    }
654
655    #[test]
656    fn search_latest_yanked_skipped() {
657        let mut v2 = make_entry("alpha", "2.0.0");
658        v2.yanked = true;
659        let index = make_index(vec![make_entry("alpha", "1.2.0"), v2]);
660        let result = index.search(&IndexSearchQuery::default());
661        assert_eq!(result.hits.len(), 1);
662        assert_eq!(
663            result.hits[0].version,
664            "1.2.0".parse::<semver::Version>().unwrap()
665        );
666    }
667
668    #[test]
669    fn search_latest_incompatible_skipped() {
670        let mut v2 = make_entry("alpha", "2.0.0");
671        v2.dependencies.database_version = ">=4.0.0".parse().unwrap();
672        let index = make_index(vec![make_entry("alpha", "1.2.0"), v2]);
673        let result = index.search(&IndexSearchQuery {
674            database_version: Some("3.2.0".parse().unwrap()),
675            ..Default::default()
676        });
677        assert_eq!(result.hits.len(), 1);
678        assert_eq!(
679            result.hits[0].version,
680            "1.2.0".parse::<semver::Version>().unwrap()
681        );
682    }
683
684    #[test]
685    fn search_hidden_selected_when_included() {
686        let mut v2 = make_entry("alpha", "2.0.0");
687        v2.yanked = true;
688        let index = make_index(vec![make_entry("alpha", "1.2.0"), v2]);
689        let result = index.search(&IndexSearchQuery {
690            include_yanked: true,
691            ..Default::default()
692        });
693        assert_eq!(result.hits.len(), 1);
694        assert_eq!(
695            result.hits[0].version,
696            "2.0.0".parse::<semver::Version>().unwrap()
697        );
698        assert_matches!(
699            &result.hits[0].visibility,
700            IndexVersionVisibility::Hidden { reasons }
701                if reasons.len() == 1
702                    && matches!(&reasons[0], IndexVisibilityReason::Yanked)
703        );
704    }
705
706    #[test]
707    fn search_summary_from_selected_version() {
708        let mut v1 = make_entry("alpha", "1.0.0");
709        v1.description = Description::try_new("old description").unwrap();
710        v1.triggers = vec![TriggerType::ProcessRequest];
711        let mut v2 = make_entry("alpha", "2.0.0");
712        v2.description = Description::try_new("new description").unwrap();
713        v2.triggers = vec![
714            TriggerType::ProcessWrites,
715            TriggerType::ProcessScheduledCall,
716        ];
717        let index = make_index(vec![v1, v2]);
718        let result = index.search(&IndexSearchQuery::default());
719        assert_eq!(result.hits.len(), 1);
720        assert_eq!(result.hits[0].description.as_str(), "new description");
721        assert_eq!(result.hits[0].triggers.len(), 2);
722    }
723
724    #[test]
725    fn search_hit_exposes_published_at_from_selected_version() {
726        let index = make_index(vec![
727            make_entry_with_published_at("alpha", "1.0.0", "2026-04-29T18:45:12Z"),
728            make_entry_with_published_at("alpha", "2.0.0", "2027-01-02T03:04:05Z"),
729        ]);
730        let result = index.search(&IndexSearchQuery::default());
731        assert_eq!(result.hits.len(), 1);
732        assert_eq!(result.hits[0].version, semver::Version::new(2, 0, 0));
733        assert_eq!(result.hits[0].published_at.as_str(), "2027-01-02T03:04:05Z");
734    }
735
736    #[test]
737    fn search_hits_sorted_by_canonical_name() {
738        let index = make_index(vec![
739            make_entry("zebra", "1.0.0"),
740            make_entry("alpha", "1.0.0"),
741            make_entry("middle", "1.0.0"),
742        ]);
743        let result = index.search(&IndexSearchQuery::default());
744        let names: Vec<&str> = result.hits.iter().map(|h| h.name.as_str()).collect();
745        assert_eq!(names, vec!["alpha", "middle", "zebra"]);
746    }
747
748    #[test]
749    fn search_semver_precedence_for_selection() {
750        let index = make_index(vec![
751            make_entry("alpha", "1.0.0-alpha"),
752            make_entry("alpha", "1.0.0"),
753        ]);
754        let result = index.search(&IndexSearchQuery::default());
755        assert_eq!(result.hits.len(), 1);
756        assert_eq!(
757            result.hits[0].version,
758            "1.0.0".parse::<semver::Version>().unwrap()
759        );
760    }
761
762    #[test]
763    fn search_build_metadata_deterministic() {
764        let index_a = make_index(vec![
765            make_entry("alpha", "1.0.0+build.1"),
766            make_entry("alpha", "1.0.0+build.2"),
767        ]);
768        let index_b = make_index(vec![
769            make_entry("alpha", "1.0.0+build.2"),
770            make_entry("alpha", "1.0.0+build.1"),
771        ]);
772        let result_a = index_a.search(&IndexSearchQuery::default());
773        let result_b = index_b.search(&IndexSearchQuery::default());
774        assert_eq!(result_a.hits[0].version, result_b.hits[0].version);
775    }
776
777    // --- Info tests ---
778
779    #[test]
780    fn info_selects_latest_visible() {
781        let index = make_index(vec![
782            make_entry("alpha", "1.0.0"),
783            make_entry("alpha", "1.2.0"),
784            make_entry("alpha", "2.0.0"),
785        ]);
786        let result = index.info(&IndexInfoQuery {
787            name: "alpha".parse().unwrap(),
788            version: None,
789            database_version: None,
790            include_yanked: false,
791            include_incompatible: false,
792        });
793        assert_matches!(&result, IndexInfoResult::Found(info) => {
794            assert_eq!(info.version, "2.0.0".parse::<semver::Version>().unwrap());
795        });
796    }
797
798    #[test]
799    fn info_returns_single_version() {
800        let index = make_index(vec![
801            make_entry("alpha", "1.0.0"),
802            make_entry("alpha", "2.0.0"),
803        ]);
804        let result = index.info(&IndexInfoQuery {
805            name: "alpha".parse().unwrap(),
806            version: None,
807            database_version: None,
808            include_yanked: false,
809            include_incompatible: false,
810        });
811        assert_matches!(&result, IndexInfoResult::Found(_));
812    }
813
814    #[test]
815    fn info_skips_yanked_by_default() {
816        let mut v2 = make_entry("alpha", "2.0.0");
817        v2.yanked = true;
818        let index = make_index(vec![make_entry("alpha", "1.0.0"), v2]);
819        let result = index.info(&IndexInfoQuery {
820            name: "alpha".parse().unwrap(),
821            version: None,
822            database_version: None,
823            include_yanked: false,
824            include_incompatible: false,
825        });
826        assert_matches!(&result, IndexInfoResult::Found(info) => {
827            assert_eq!(info.version, "1.0.0".parse::<semver::Version>().unwrap());
828        });
829    }
830
831    #[test]
832    fn info_skips_incompatible_by_default() {
833        let mut v2 = make_entry("alpha", "2.0.0");
834        v2.dependencies.database_version = ">=4.0.0".parse().unwrap();
835        let index = make_index(vec![make_entry("alpha", "1.0.0"), v2]);
836        let result = index.info(&IndexInfoQuery {
837            name: "alpha".parse().unwrap(),
838            version: None,
839            database_version: Some("3.2.0".parse().unwrap()),
840            include_yanked: false,
841            include_incompatible: false,
842        });
843        assert_matches!(&result, IndexInfoResult::Found(info) => {
844            assert_eq!(info.version, "1.0.0".parse::<semver::Version>().unwrap());
845        });
846    }
847
848    #[test]
849    fn info_includes_yanked_when_requested() {
850        let mut v2 = make_entry("alpha", "2.0.0");
851        v2.yanked = true;
852        let index = make_index(vec![make_entry("alpha", "1.0.0"), v2]);
853        let result = index.info(&IndexInfoQuery {
854            name: "alpha".parse().unwrap(),
855            version: None,
856            database_version: None,
857            include_yanked: true,
858            include_incompatible: false,
859        });
860        assert_matches!(&result, IndexInfoResult::Found(info) => {
861            assert_eq!(info.version, "2.0.0".parse::<semver::Version>().unwrap());
862            assert_matches!(&info.visibility, IndexVersionVisibility::Hidden { reasons }
863                if reasons.len() == 1
864                    && matches!(&reasons[0], IndexVisibilityReason::Yanked)
865            );
866        });
867    }
868
869    #[test]
870    fn info_includes_incompatible_when_requested() {
871        let mut v2 = make_entry("alpha", "2.0.0");
872        v2.dependencies.database_version = ">=4.0.0".parse().unwrap();
873        let index = make_index(vec![make_entry("alpha", "1.0.0"), v2]);
874        let result = index.info(&IndexInfoQuery {
875            name: "alpha".parse().unwrap(),
876            version: None,
877            database_version: Some("3.2.0".parse().unwrap()),
878            include_yanked: false,
879            include_incompatible: true,
880        });
881        assert_matches!(&result, IndexInfoResult::Found(info) => {
882            assert_eq!(info.version, "2.0.0".parse::<semver::Version>().unwrap());
883            assert_matches!(&info.visibility, IndexVersionVisibility::Hidden { reasons }
884                if reasons.len() == 1
885                    && matches!(&reasons[0], IndexVisibilityReason::IncompatibleDatabaseVersion { .. })
886            );
887        });
888    }
889
890    #[test]
891    fn info_no_compat_filter_without_db_version() {
892        let mut entry = make_entry("alpha", "1.0.0");
893        entry.dependencies.database_version = ">=99.0.0".parse().unwrap();
894        let index = make_index(vec![entry]);
895        let result = index.info(&IndexInfoQuery {
896            name: "alpha".parse().unwrap(),
897            version: None,
898            database_version: None,
899            include_yanked: false,
900            include_incompatible: false,
901        });
902        assert_matches!(&result, IndexInfoResult::Found(info) => {
903            assert_eq!(info.visibility, IndexVersionVisibility::Visible);
904        });
905    }
906
907    #[test]
908    fn info_missing_name() {
909        let index = make_index(vec![make_entry("alpha", "1.0.0")]);
910        let result = index.info(&IndexInfoQuery {
911            name: "nonexistent".parse().unwrap(),
912            version: None,
913            database_version: None,
914            include_yanked: false,
915            include_incompatible: false,
916        });
917        assert_matches!(&result, IndexInfoResult::NotFound { name, version } => {
918            assert_eq!(name.as_str(), "nonexistent");
919            assert!(version.is_none());
920        });
921    }
922
923    #[test]
924    fn info_all_yanked() {
925        let mut v1 = make_entry("alpha", "1.0.0");
926        v1.yanked = true;
927        let mut v2 = make_entry("alpha", "2.0.0");
928        v2.yanked = true;
929        let index = make_index(vec![v1, v2]);
930        let result = index.info(&IndexInfoQuery {
931            name: "alpha".parse().unwrap(),
932            version: None,
933            database_version: None,
934            include_yanked: false,
935            include_incompatible: false,
936        });
937        assert_matches!(&result, IndexInfoResult::FilteredOut { name, version, reasons } => {
938            assert_eq!(name.as_str(), "alpha");
939            assert!(version.is_none());
940            assert_eq!(reasons.len(), 1);
941            assert_matches!(&reasons[0], IndexVisibilityReason::Yanked);
942        });
943    }
944
945    #[test]
946    fn info_all_incompatible() {
947        let mut v1 = make_entry("alpha", "1.0.0");
948        v1.dependencies.database_version = ">=4.0.0".parse().unwrap();
949        let index = make_index(vec![v1]);
950        let result = index.info(&IndexInfoQuery {
951            name: "alpha".parse().unwrap(),
952            version: None,
953            database_version: Some("3.2.0".parse().unwrap()),
954            include_yanked: false,
955            include_incompatible: false,
956        });
957        assert_matches!(&result, IndexInfoResult::FilteredOut { name, version, reasons } => {
958            assert_eq!(name.as_str(), "alpha");
959            assert!(version.is_none());
960            assert_eq!(reasons.len(), 1);
961            assert_matches!(&reasons[0], IndexVisibilityReason::IncompatibleDatabaseVersion { .. });
962        });
963    }
964
965    #[test]
966    fn info_all_hidden_mixed_reasons() {
967        let mut v1 = make_entry("alpha", "1.0.0");
968        v1.yanked = true;
969        let mut v2 = make_entry("alpha", "2.0.0");
970        v2.dependencies.database_version = ">=4.0.0".parse().unwrap();
971        let index = make_index(vec![v1, v2]);
972        let result = index.info(&IndexInfoQuery {
973            name: "alpha".parse().unwrap(),
974            version: None,
975            database_version: Some("3.2.0".parse().unwrap()),
976            include_yanked: false,
977            include_incompatible: false,
978        });
979        assert_matches!(&result, IndexInfoResult::FilteredOut { reasons, .. } => {
980            assert_eq!(reasons.len(), 2);
981            assert!(reasons.iter().any(|r| matches!(r, IndexVisibilityReason::Yanked)));
982            assert!(reasons.iter().any(|r| matches!(r, IndexVisibilityReason::IncompatibleDatabaseVersion { .. })));
983        });
984    }
985
986    // --- Exact version info tests ---
987
988    #[test]
989    fn info_exact_version_visible() {
990        let index = make_index(vec![
991            make_entry("alpha", "1.0.0"),
992            make_entry("alpha", "2.0.0"),
993        ]);
994        let result = index.info(&IndexInfoQuery {
995            name: "alpha".parse().unwrap(),
996            version: Some("1.0.0".parse().unwrap()),
997            database_version: None,
998            include_yanked: false,
999            include_incompatible: false,
1000        });
1001        assert_matches!(&result, IndexInfoResult::Found(info) => {
1002            assert_eq!(info.version, "1.0.0".parse::<semver::Version>().unwrap());
1003            assert_eq!(info.visibility, IndexVersionVisibility::Visible);
1004        });
1005    }
1006
1007    #[test]
1008    fn info_exact_version_yanked() {
1009        let mut entry = make_entry("alpha", "1.0.0");
1010        entry.yanked = true;
1011        let index = make_index(vec![entry]);
1012        let result = index.info(&IndexInfoQuery {
1013            name: "alpha".parse().unwrap(),
1014            version: Some("1.0.0".parse().unwrap()),
1015            database_version: None,
1016            include_yanked: false,
1017            include_incompatible: false,
1018        });
1019        assert_matches!(&result, IndexInfoResult::Found(info) => {
1020            assert_matches!(&info.visibility, IndexVersionVisibility::Hidden { reasons }
1021                if reasons.len() == 1
1022                    && matches!(&reasons[0], IndexVisibilityReason::Yanked)
1023            );
1024        });
1025    }
1026
1027    #[test]
1028    fn info_exact_version_incompatible() {
1029        let mut entry = make_entry("alpha", "1.0.0");
1030        entry.dependencies.database_version = ">=4.0.0".parse().unwrap();
1031        let index = make_index(vec![entry]);
1032        let result = index.info(&IndexInfoQuery {
1033            name: "alpha".parse().unwrap(),
1034            version: Some("1.0.0".parse().unwrap()),
1035            database_version: Some("3.2.0".parse().unwrap()),
1036            include_yanked: false,
1037            include_incompatible: false,
1038        });
1039        assert_matches!(&result, IndexInfoResult::Found(info) => {
1040            assert_matches!(&info.visibility, IndexVersionVisibility::Hidden { reasons }
1041                if reasons.len() == 1
1042                    && matches!(&reasons[0], IndexVisibilityReason::IncompatibleDatabaseVersion {
1043                        required, actual
1044                    } if required.to_string() == ">=4.0.0"
1045                        && *actual == "3.2.0".parse::<semver::Version>().unwrap()
1046                    )
1047            );
1048        });
1049    }
1050
1051    #[test]
1052    fn info_exact_version_yanked_and_incompatible() {
1053        let mut entry = make_entry("alpha", "1.0.0");
1054        entry.yanked = true;
1055        entry.dependencies.database_version = ">=4.0.0".parse().unwrap();
1056        let index = make_index(vec![entry]);
1057        let result = index.info(&IndexInfoQuery {
1058            name: "alpha".parse().unwrap(),
1059            version: Some("1.0.0".parse().unwrap()),
1060            database_version: Some("3.2.0".parse().unwrap()),
1061            include_yanked: false,
1062            include_incompatible: false,
1063        });
1064        assert_matches!(&result, IndexInfoResult::Found(info) => {
1065            assert_matches!(&info.visibility, IndexVersionVisibility::Hidden { reasons } => {
1066                assert_eq!(reasons.len(), 2);
1067                assert_matches!(&reasons[0], IndexVisibilityReason::Yanked);
1068                assert_matches!(&reasons[1], IndexVisibilityReason::IncompatibleDatabaseVersion { .. });
1069            });
1070        });
1071    }
1072
1073    #[test]
1074    fn info_exact_version_missing() {
1075        let index = make_index(vec![make_entry("alpha", "1.0.0")]);
1076        let result = index.info(&IndexInfoQuery {
1077            name: "alpha".parse().unwrap(),
1078            version: Some("9.9.9".parse().unwrap()),
1079            database_version: None,
1080            include_yanked: false,
1081            include_incompatible: false,
1082        });
1083        assert_matches!(&result, IndexInfoResult::NotFound { name, version } => {
1084            assert_eq!(name.as_str(), "alpha");
1085            assert_eq!(*version, Some("9.9.9".parse::<semver::Version>().unwrap()));
1086        });
1087    }
1088
1089    #[test]
1090    fn info_exact_version_missing_plugin() {
1091        let index = make_index(vec![make_entry("alpha", "1.0.0")]);
1092        let result = index.info(&IndexInfoQuery {
1093            name: "nonexistent".parse().unwrap(),
1094            version: Some("1.0.0".parse().unwrap()),
1095            database_version: None,
1096            include_yanked: false,
1097            include_incompatible: false,
1098        });
1099        assert_matches!(&result, IndexInfoResult::NotFound { name, version } => {
1100            assert_eq!(name.as_str(), "nonexistent");
1101            assert_eq!(*version, Some("1.0.0".parse::<semver::Version>().unwrap()));
1102        });
1103    }
1104
1105    // --- Result content tests ---
1106
1107    #[test]
1108    fn info_full_metadata() {
1109        let mut entry = make_entry("downsampler", "1.2.0");
1110        entry.description = Description::try_new("Downsamples WAL data").unwrap();
1111        entry.triggers = vec![
1112            TriggerType::ProcessWrites,
1113            TriggerType::ProcessScheduledCall,
1114        ];
1115        entry.homepage = Some("https://example.com".parse().unwrap());
1116        entry.repository = Some("https://github.com/example/ds".parse().unwrap());
1117        entry.documentation = Some("https://docs.example.com/ds".parse().unwrap());
1118        entry.dependencies = Dependencies {
1119            database_version: ">=3.2.0,<4.0.0".parse().unwrap(),
1120            python: vec![PythonRequirement::try_new("requests>=2.31").unwrap()],
1121            plugins: vec![],
1122        };
1123        entry.hash = ArtifactHash::try_new(
1124            "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
1125        )
1126        .unwrap();
1127        let index = make_index(vec![entry]);
1128        let result = index.info(&IndexInfoQuery {
1129            name: "downsampler".parse().unwrap(),
1130            version: None,
1131            database_version: Some("3.5.0".parse().unwrap()),
1132            include_yanked: false,
1133            include_incompatible: false,
1134        });
1135        assert_matches!(&result, IndexInfoResult::Found(info) => {
1136            assert_eq!(info.name.as_str(), "downsampler");
1137            assert_eq!(info.version, "1.2.0".parse::<semver::Version>().unwrap());
1138            assert_eq!(info.description.as_str(), "Downsamples WAL data");
1139            assert_eq!(info.triggers.len(), 2);
1140            assert!(info.homepage.is_some());
1141            assert!(info.repository.is_some());
1142            assert!(info.documentation.is_some());
1143            assert_eq!(info.dependencies.database_version.to_string(), ">=3.2.0, <4.0.0");
1144            assert_eq!(info.dependencies.python.len(), 1);
1145            assert!(info.hash.as_str().starts_with("sha256:abcdef"));
1146            assert_eq!(info.visibility, IndexVersionVisibility::Visible);
1147        });
1148    }
1149
1150    #[test]
1151    fn info_exposes_published_at() {
1152        let index = make_index(vec![make_entry_with_published_at(
1153            "alpha",
1154            "1.0.0",
1155            "2027-01-02T03:04:05Z",
1156        )]);
1157        let result = index.info(&IndexInfoQuery {
1158            name: "alpha".parse().unwrap(),
1159            version: Some("1.0.0".parse().unwrap()),
1160            database_version: None,
1161            include_yanked: false,
1162            include_incompatible: false,
1163        });
1164        assert_matches!(&result, IndexInfoResult::Found(info) => {
1165            assert_eq!(info.published_at.as_str(), "2027-01-02T03:04:05Z");
1166        });
1167    }
1168
1169    #[test]
1170    fn info_incompatible_reason_includes_versions() {
1171        let mut entry = make_entry("alpha", "1.0.0");
1172        entry.dependencies.database_version = ">=4.0.0".parse().unwrap();
1173        let index = make_index(vec![entry]);
1174        let result = index.info(&IndexInfoQuery {
1175            name: "alpha".parse().unwrap(),
1176            version: Some("1.0.0".parse().unwrap()),
1177            database_version: Some("3.2.0".parse().unwrap()),
1178            include_yanked: false,
1179            include_incompatible: false,
1180        });
1181        assert_matches!(&result, IndexInfoResult::Found(info) => {
1182            assert_matches!(&info.visibility, IndexVersionVisibility::Hidden { reasons } => {
1183                assert_matches!(&reasons[0], IndexVisibilityReason::IncompatibleDatabaseVersion {
1184                    required, actual
1185                } => {
1186                    assert_eq!(required.to_string(), ">=4.0.0");
1187                    assert_eq!(*actual, "3.2.0".parse::<semver::Version>().unwrap());
1188                });
1189            });
1190        });
1191    }
1192
1193    // --- Serialization tests ---
1194
1195    #[test]
1196    fn search_result_serializes() {
1197        let index = make_index(vec![make_entry("alpha", "1.0.0")]);
1198        let result = index.search(&IndexSearchQuery::default());
1199        let json = serde_json::to_value(&result).unwrap();
1200        assert!(json["hits"].is_array());
1201        assert_eq!(json["hits"][0]["name"], "alpha");
1202        assert_eq!(json["hits"][0]["version"], "1.0.0");
1203        assert_eq!(json["hits"][0]["published_at"], "2026-04-29T18:45:12Z");
1204        assert!(json["hits"][0]["description"].is_string());
1205        assert!(json["hits"][0]["triggers"].is_array());
1206        assert_eq!(json["hits"][0]["visibility"], "Visible");
1207    }
1208
1209    #[test]
1210    fn info_found_serializes() {
1211        let index = make_index(vec![make_entry("alpha", "1.0.0")]);
1212        let result = index.info(&IndexInfoQuery {
1213            name: "alpha".parse().unwrap(),
1214            version: None,
1215            database_version: None,
1216            include_yanked: false,
1217            include_incompatible: false,
1218        });
1219        let json = serde_json::to_value(&result).unwrap();
1220        let found = &json["Found"];
1221        assert_eq!(found["name"], "alpha");
1222        assert_eq!(found["version"], "1.0.0");
1223        assert_eq!(found["published_at"], "2026-04-29T18:45:12Z");
1224        assert!(found["description"].is_string());
1225        assert!(found["triggers"].is_array());
1226        assert!(found["dependencies"].is_object());
1227        assert!(found["hash"].is_string());
1228        assert_eq!(found["visibility"], "Visible");
1229    }
1230
1231    #[test]
1232    fn info_not_found_serializes() {
1233        let index = make_index(vec![]);
1234        let result = index.info(&IndexInfoQuery {
1235            name: "missing".parse().unwrap(),
1236            version: Some("1.0.0".parse().unwrap()),
1237            database_version: None,
1238            include_yanked: false,
1239            include_incompatible: false,
1240        });
1241        let json = serde_json::to_value(&result).unwrap();
1242        let not_found = &json["NotFound"];
1243        assert_eq!(not_found["name"], "missing");
1244        assert_eq!(not_found["version"], "1.0.0");
1245    }
1246
1247    #[test]
1248    fn info_filtered_out_serializes() {
1249        let mut entry = make_entry("alpha", "1.0.0");
1250        entry.yanked = true;
1251        let index = make_index(vec![entry]);
1252        let result = index.info(&IndexInfoQuery {
1253            name: "alpha".parse().unwrap(),
1254            version: None,
1255            database_version: None,
1256            include_yanked: false,
1257            include_incompatible: false,
1258        });
1259        let json = serde_json::to_value(&result).unwrap();
1260        let filtered = &json["FilteredOut"];
1261        assert_eq!(filtered["name"], "alpha");
1262        assert!(filtered["version"].is_null());
1263        assert!(filtered["reasons"].is_array());
1264    }
1265
1266    #[test]
1267    fn visibility_reasons_serialize() {
1268        let yanked = IndexVisibilityReason::Yanked;
1269        let incompat = IndexVisibilityReason::IncompatibleDatabaseVersion {
1270            required: ">=4.0.0".parse().unwrap(),
1271            actual: "3.2.0".parse().unwrap(),
1272        };
1273        let yanked_json = serde_json::to_value(&yanked).unwrap();
1274        let incompat_json = serde_json::to_value(&incompat).unwrap();
1275        assert_eq!(yanked_json, "Yanked");
1276        assert!(incompat_json["IncompatibleDatabaseVersion"].is_object());
1277        assert_eq!(
1278            incompat_json["IncompatibleDatabaseVersion"]["required"],
1279            ">=4.0.0"
1280        );
1281        assert_eq!(
1282            incompat_json["IncompatibleDatabaseVersion"]["actual"],
1283            "3.2.0"
1284        );
1285    }
1286
1287    // --- Edge case tests ---
1288
1289    #[test]
1290    fn search_empty_index() {
1291        let index = make_index(vec![]);
1292        let result = index.search(&IndexSearchQuery::default());
1293        assert_eq!(result.hits.len(), 0);
1294    }
1295
1296    #[test]
1297    fn info_empty_index() {
1298        let index = make_index(vec![]);
1299        let result = index.info(&IndexInfoQuery {
1300            name: "alpha".parse().unwrap(),
1301            version: None,
1302            database_version: None,
1303            include_yanked: false,
1304            include_incompatible: false,
1305        });
1306        assert_matches!(&result, IndexInfoResult::NotFound { .. });
1307    }
1308
1309    #[test]
1310    fn search_only_hidden_not_shown() {
1311        let mut entry = make_entry("alpha", "1.0.0");
1312        entry.yanked = true;
1313        let index = make_index(vec![entry]);
1314        let result = index.search(&IndexSearchQuery::default());
1315        assert_eq!(result.hits.len(), 0);
1316    }
1317
1318    #[test]
1319    fn search_mixed_visible_hidden_uses_visible() {
1320        let mut v2 = make_entry("alpha", "2.0.0");
1321        v2.yanked = true;
1322        let index = make_index(vec![make_entry("alpha", "1.0.0"), v2]);
1323        let result = index.search(&IndexSearchQuery::default());
1324        assert_eq!(result.hits.len(), 1);
1325        assert_eq!(
1326            result.hits[0].version,
1327            "1.0.0".parse::<semver::Version>().unwrap()
1328        );
1329        assert_eq!(result.hits[0].visibility, IndexVersionVisibility::Visible);
1330    }
1331
1332    #[test]
1333    fn search_mixed_with_include_flags() {
1334        let mut v2 = make_entry("alpha", "2.0.0");
1335        v2.yanked = true;
1336        let index = make_index(vec![make_entry("alpha", "1.0.0"), v2]);
1337        let result = index.search(&IndexSearchQuery {
1338            include_yanked: true,
1339            ..Default::default()
1340        });
1341        assert_eq!(result.hits.len(), 1);
1342        assert_eq!(
1343            result.hits[0].version,
1344            "2.0.0".parse::<semver::Version>().unwrap()
1345        );
1346    }
1347
1348    #[test]
1349    fn search_text_match_on_hidden_visible_older_matches() {
1350        let mut v2 = make_entry("alpha", "2.0.0");
1351        v2.yanked = true;
1352        v2.description = Description::try_new("common in both versions").unwrap();
1353        let mut v1 = make_entry("alpha", "1.0.0");
1354        v1.description = Description::try_new("common desc").unwrap();
1355        let index = make_index(vec![v1, v2]);
1356        // Both versions match "common", but v2 is hidden (yanked).
1357        // Search excludes v2 before text matching; v1 matches and is selected.
1358        let result = index.search(&IndexSearchQuery {
1359            query: Some("common".into()),
1360            ..Default::default()
1361        });
1362        assert_eq!(result.hits.len(), 1);
1363        assert_eq!(
1364            result.hits[0].version,
1365            "1.0.0".parse::<semver::Version>().unwrap()
1366        );
1367    }
1368
1369    #[test]
1370    fn search_trigger_filter_before_grouping() {
1371        let mut v2 = make_entry("alpha", "2.0.0");
1372        v2.triggers = vec![TriggerType::ProcessRequest];
1373        let mut v1 = make_entry("alpha", "1.0.0");
1374        v1.triggers = vec![TriggerType::ProcessWrites];
1375        let index = make_index(vec![v1, v2]);
1376        let result = index.search(&IndexSearchQuery {
1377            trigger_type: Some(TriggerType::ProcessWrites),
1378            ..Default::default()
1379        });
1380        assert_eq!(result.hits.len(), 1);
1381        assert_eq!(
1382            result.hits[0].version,
1383            "1.0.0".parse::<semver::Version>().unwrap()
1384        );
1385        assert_eq!(result.hits[0].triggers, vec![TriggerType::ProcessWrites]);
1386    }
1387}