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            },
330            hash: ArtifactHash::try_new(
331                "sha256:0000000000000000000000000000000000000000000000000000000000000000",
332            )
333            .unwrap(),
334            yanked: false,
335        }
336    }
337
338    fn make_index(entries: Vec<IndexEntry>) -> Index {
339        Index {
340            index_schema_version: IndexSchemaVersion::CURRENT,
341            artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
342            plugins: entries,
343        }
344    }
345
346    // --- Visibility helper tests ---
347
348    #[test]
349    fn visibility_visible_when_not_yanked_and_compatible() {
350        let entry = make_entry("foo", "1.0.0");
351        let vis = visibility_for(&entry, Some(&"3.2.0".parse().unwrap()));
352        assert_eq!(vis, IndexVersionVisibility::Visible);
353    }
354
355    #[test]
356    fn visibility_hidden_when_yanked() {
357        let mut entry = make_entry("foo", "1.0.0");
358        entry.yanked = true;
359        let vis = visibility_for(&entry, Some(&"3.2.0".parse().unwrap()));
360        assert_matches!(&vis, IndexVersionVisibility::Hidden { reasons } => {
361            assert_eq!(reasons.len(), 1);
362            assert_matches!(&reasons[0], IndexVisibilityReason::Yanked);
363        });
364    }
365
366    #[test]
367    fn visibility_hidden_when_incompatible() {
368        let mut entry = make_entry("foo", "1.0.0");
369        entry.dependencies.database_version = ">=4.0.0".parse().unwrap();
370        let vis = visibility_for(&entry, Some(&"3.2.0".parse().unwrap()));
371        assert_matches!(&vis, IndexVersionVisibility::Hidden { reasons } => {
372            assert_eq!(reasons.len(), 1);
373            assert_matches!(&reasons[0], IndexVisibilityReason::IncompatibleDatabaseVersion {
374                required, actual
375            } => {
376                assert_eq!(required.to_string(), ">=4.0.0");
377                assert_eq!(*actual, "3.2.0".parse::<semver::Version>().unwrap());
378            });
379        });
380    }
381
382    #[test]
383    fn visibility_hidden_yanked_and_incompatible() {
384        let mut entry = make_entry("foo", "1.0.0");
385        entry.yanked = true;
386        entry.dependencies.database_version = ">=4.0.0".parse().unwrap();
387        let vis = visibility_for(&entry, Some(&"3.2.0".parse().unwrap()));
388        assert_matches!(&vis, IndexVersionVisibility::Hidden { reasons } => {
389            assert_eq!(reasons.len(), 2);
390            assert_matches!(&reasons[0], IndexVisibilityReason::Yanked);
391            assert_matches!(&reasons[1], IndexVisibilityReason::IncompatibleDatabaseVersion { .. });
392        });
393    }
394
395    #[test]
396    fn visibility_no_compat_check_without_db_version() {
397        let mut entry = make_entry("foo", "1.0.0");
398        entry.dependencies.database_version = ">=99.0.0".parse().unwrap();
399        let vis = visibility_for(&entry, None);
400        assert_eq!(vis, IndexVersionVisibility::Visible);
401    }
402
403    // --- Search tests ---
404
405    #[test]
406    fn search_empty_query_matches_all() {
407        let index = make_index(vec![
408            make_entry("alpha", "1.0.0"),
409            make_entry("beta", "2.0.0"),
410        ]);
411        let result = index.search(&IndexSearchQuery::default());
412        assert_eq!(result.hits.len(), 2);
413    }
414
415    #[test]
416    fn search_whitespace_query_matches_all() {
417        let index = make_index(vec![
418            make_entry("alpha", "1.0.0"),
419            make_entry("beta", "2.0.0"),
420        ]);
421        let result = index.search(&IndexSearchQuery {
422            query: Some("   ".into()),
423            ..Default::default()
424        });
425        assert_eq!(result.hits.len(), 2);
426    }
427
428    #[test]
429    fn search_name_substring_match() {
430        let index = make_index(vec![
431            make_entry("downsampler", "1.0.0"),
432            make_entry("alerter", "1.0.0"),
433        ]);
434        let result = index.search(&IndexSearchQuery {
435            query: Some("sample".into()),
436            ..Default::default()
437        });
438        assert_eq!(result.hits.len(), 1);
439        assert_eq!(result.hits[0].name.as_str(), "downsampler");
440    }
441
442    #[test]
443    fn search_description_substring_match() {
444        let mut entry = make_entry("notifier", "1.0.0");
445        entry.description = Description::try_new("Fires on every WAL commit").unwrap();
446        let index = make_index(vec![entry, make_entry("other", "1.0.0")]);
447        let result = index.search(&IndexSearchQuery {
448            query: Some("wal".into()),
449            ..Default::default()
450        });
451        assert_eq!(result.hits.len(), 1);
452        assert_eq!(result.hits[0].name.as_str(), "notifier");
453    }
454
455    #[test]
456    fn search_case_insensitive() {
457        let index = make_index(vec![make_entry("downsampler", "1.0.0")]);
458        let result = index.search(&IndexSearchQuery {
459            query: Some("DOWNSAMPLE".into()),
460            ..Default::default()
461        });
462        assert_eq!(result.hits.len(), 1);
463        assert_eq!(result.hits[0].name.as_str(), "downsampler");
464    }
465
466    #[test]
467    fn search_canonical_name_matching() {
468        // Hyphens in name, underscores in query
469        let index = make_index(vec![make_entry("my-plugin", "1.0.0")]);
470        let result = index.search(&IndexSearchQuery {
471            query: Some("my_plugin".into()),
472            ..Default::default()
473        });
474        assert_eq!(result.hits.len(), 1);
475        assert_eq!(result.hits[0].name.as_str(), "my-plugin");
476
477        // Underscores in name, hyphens in query
478        let index = make_index(vec![make_entry("my_plugin", "1.0.0")]);
479        let result = index.search(&IndexSearchQuery {
480            query: Some("my-plugin".into()),
481            ..Default::default()
482        });
483        assert_eq!(result.hits.len(), 1);
484        assert_eq!(result.hits[0].name.as_str(), "my_plugin");
485    }
486
487    #[test]
488    fn search_no_dependency_text_search() {
489        let mut entry = make_entry("downsampler", "1.0.0");
490        entry.dependencies.python = vec![PythonRequirement::try_new("requests>=2.31").unwrap()];
491        let index = make_index(vec![entry]);
492        let result = index.search(&IndexSearchQuery {
493            query: Some("requests".into()),
494            ..Default::default()
495        });
496        assert_eq!(result.hits.len(), 0);
497    }
498
499    #[test]
500    fn search_no_url_hash_trigger_text_search() {
501        let mut entry = make_entry("downsampler", "1.0.0");
502        entry.documentation = Some("https://docs.example.com/searchable".parse().unwrap());
503        let index = make_index(vec![entry]);
504        let result = index.search(&IndexSearchQuery {
505            query: Some("searchable".into()),
506            ..Default::default()
507        });
508        assert_eq!(result.hits.len(), 0);
509    }
510
511    // --- Search filtering tests ---
512
513    #[test]
514    fn search_trigger_type_filter_includes() {
515        let index = make_index(vec![make_entry("writer", "1.0.0")]);
516        let result = index.search(&IndexSearchQuery {
517            trigger_type: Some(TriggerType::ProcessWrites),
518            ..Default::default()
519        });
520        assert_eq!(result.hits.len(), 1);
521    }
522
523    #[test]
524    fn search_trigger_type_filter_excludes() {
525        let mut entry = make_entry("requester", "1.0.0");
526        entry.triggers = vec![TriggerType::ProcessRequest];
527        let index = make_index(vec![entry]);
528        let result = index.search(&IndexSearchQuery {
529            trigger_type: Some(TriggerType::ProcessWrites),
530            ..Default::default()
531        });
532        assert_eq!(result.hits.len(), 0);
533    }
534
535    #[test]
536    fn search_yanked_hidden_by_default() {
537        let mut entry = make_entry("obsolete", "1.0.0");
538        entry.yanked = true;
539        let index = make_index(vec![entry]);
540        let result = index.search(&IndexSearchQuery::default());
541        assert_eq!(result.hits.len(), 0);
542    }
543
544    #[test]
545    fn search_yanked_included_when_requested() {
546        let mut entry = make_entry("obsolete", "1.0.0");
547        entry.yanked = true;
548        let index = make_index(vec![entry]);
549        let result = index.search(&IndexSearchQuery {
550            include_yanked: true,
551            ..Default::default()
552        });
553        assert_eq!(result.hits.len(), 1);
554        assert_matches!(
555            &result.hits[0].visibility,
556            IndexVersionVisibility::Hidden { reasons }
557                if reasons.len() == 1
558                    && matches!(&reasons[0], IndexVisibilityReason::Yanked)
559        );
560    }
561
562    #[test]
563    fn search_incompatible_hidden_with_db_version() {
564        let mut entry = make_entry("future", "1.0.0");
565        entry.dependencies.database_version = ">=4.0.0".parse().unwrap();
566        let index = make_index(vec![entry]);
567        let result = index.search(&IndexSearchQuery {
568            database_version: Some("3.2.0".parse().unwrap()),
569            ..Default::default()
570        });
571        assert_eq!(result.hits.len(), 0);
572    }
573
574    #[test]
575    fn search_incompatible_included_when_requested() {
576        let mut entry = make_entry("future", "1.0.0");
577        entry.dependencies.database_version = ">=4.0.0".parse().unwrap();
578        let index = make_index(vec![entry]);
579        let result = index.search(&IndexSearchQuery {
580            database_version: Some("3.2.0".parse().unwrap()),
581            include_incompatible: true,
582            ..Default::default()
583        });
584        assert_eq!(result.hits.len(), 1);
585        assert_matches!(
586            &result.hits[0].visibility,
587            IndexVersionVisibility::Hidden { reasons }
588                if reasons.len() == 1
589                    && matches!(&reasons[0], IndexVisibilityReason::IncompatibleDatabaseVersion { .. })
590        );
591    }
592
593    #[test]
594    fn search_no_compat_filter_without_db_version() {
595        let mut entry = make_entry("future", "1.0.0");
596        entry.dependencies.database_version = ">=99.0.0".parse().unwrap();
597        let index = make_index(vec![entry]);
598        let result = index.search(&IndexSearchQuery::default());
599        assert_eq!(result.hits.len(), 1);
600        assert_eq!(result.hits[0].visibility, IndexVersionVisibility::Visible);
601    }
602
603    #[test]
604    fn search_yanked_and_incompatible_reasons_accumulate() {
605        let mut entry = make_entry("doomed", "1.0.0");
606        entry.yanked = true;
607        entry.dependencies.database_version = ">=4.0.0".parse().unwrap();
608        let index = make_index(vec![entry]);
609        let result = index.search(&IndexSearchQuery {
610            database_version: Some("3.2.0".parse().unwrap()),
611            include_yanked: true,
612            include_incompatible: true,
613            ..Default::default()
614        });
615        assert_eq!(result.hits.len(), 1);
616        assert_matches!(
617            &result.hits[0].visibility,
618            IndexVersionVisibility::Hidden { reasons } => {
619                assert_eq!(reasons.len(), 2);
620                assert_matches!(&reasons[0], IndexVisibilityReason::Yanked);
621                assert_matches!(&reasons[1], IndexVisibilityReason::IncompatibleDatabaseVersion { .. });
622            }
623        );
624    }
625
626    // --- Search version selection + ordering tests ---
627
628    #[test]
629    fn search_one_hit_per_plugin() {
630        let index = make_index(vec![
631            make_entry("alpha", "1.0.0"),
632            make_entry("alpha", "2.0.0"),
633            make_entry("alpha", "3.0.0"),
634        ]);
635        let result = index.search(&IndexSearchQuery::default());
636        assert_eq!(result.hits.len(), 1);
637    }
638
639    #[test]
640    fn search_latest_visible_version_selected() {
641        let index = make_index(vec![
642            make_entry("alpha", "1.0.0"),
643            make_entry("alpha", "1.2.0"),
644            make_entry("alpha", "2.0.0"),
645        ]);
646        let result = index.search(&IndexSearchQuery::default());
647        assert_eq!(result.hits.len(), 1);
648        assert_eq!(
649            result.hits[0].version,
650            "2.0.0".parse::<semver::Version>().unwrap()
651        );
652    }
653
654    #[test]
655    fn search_latest_yanked_skipped() {
656        let mut v2 = make_entry("alpha", "2.0.0");
657        v2.yanked = true;
658        let index = make_index(vec![make_entry("alpha", "1.2.0"), v2]);
659        let result = index.search(&IndexSearchQuery::default());
660        assert_eq!(result.hits.len(), 1);
661        assert_eq!(
662            result.hits[0].version,
663            "1.2.0".parse::<semver::Version>().unwrap()
664        );
665    }
666
667    #[test]
668    fn search_latest_incompatible_skipped() {
669        let mut v2 = make_entry("alpha", "2.0.0");
670        v2.dependencies.database_version = ">=4.0.0".parse().unwrap();
671        let index = make_index(vec![make_entry("alpha", "1.2.0"), v2]);
672        let result = index.search(&IndexSearchQuery {
673            database_version: Some("3.2.0".parse().unwrap()),
674            ..Default::default()
675        });
676        assert_eq!(result.hits.len(), 1);
677        assert_eq!(
678            result.hits[0].version,
679            "1.2.0".parse::<semver::Version>().unwrap()
680        );
681    }
682
683    #[test]
684    fn search_hidden_selected_when_included() {
685        let mut v2 = make_entry("alpha", "2.0.0");
686        v2.yanked = true;
687        let index = make_index(vec![make_entry("alpha", "1.2.0"), v2]);
688        let result = index.search(&IndexSearchQuery {
689            include_yanked: true,
690            ..Default::default()
691        });
692        assert_eq!(result.hits.len(), 1);
693        assert_eq!(
694            result.hits[0].version,
695            "2.0.0".parse::<semver::Version>().unwrap()
696        );
697        assert_matches!(
698            &result.hits[0].visibility,
699            IndexVersionVisibility::Hidden { reasons }
700                if reasons.len() == 1
701                    && matches!(&reasons[0], IndexVisibilityReason::Yanked)
702        );
703    }
704
705    #[test]
706    fn search_summary_from_selected_version() {
707        let mut v1 = make_entry("alpha", "1.0.0");
708        v1.description = Description::try_new("old description").unwrap();
709        v1.triggers = vec![TriggerType::ProcessRequest];
710        let mut v2 = make_entry("alpha", "2.0.0");
711        v2.description = Description::try_new("new description").unwrap();
712        v2.triggers = vec![
713            TriggerType::ProcessWrites,
714            TriggerType::ProcessScheduledCall,
715        ];
716        let index = make_index(vec![v1, v2]);
717        let result = index.search(&IndexSearchQuery::default());
718        assert_eq!(result.hits.len(), 1);
719        assert_eq!(result.hits[0].description.as_str(), "new description");
720        assert_eq!(result.hits[0].triggers.len(), 2);
721    }
722
723    #[test]
724    fn search_hit_exposes_published_at_from_selected_version() {
725        let index = make_index(vec![
726            make_entry_with_published_at("alpha", "1.0.0", "2026-04-29T18:45:12Z"),
727            make_entry_with_published_at("alpha", "2.0.0", "2027-01-02T03:04:05Z"),
728        ]);
729        let result = index.search(&IndexSearchQuery::default());
730        assert_eq!(result.hits.len(), 1);
731        assert_eq!(result.hits[0].version, semver::Version::new(2, 0, 0));
732        assert_eq!(result.hits[0].published_at.as_str(), "2027-01-02T03:04:05Z");
733    }
734
735    #[test]
736    fn search_hits_sorted_by_canonical_name() {
737        let index = make_index(vec![
738            make_entry("zebra", "1.0.0"),
739            make_entry("alpha", "1.0.0"),
740            make_entry("middle", "1.0.0"),
741        ]);
742        let result = index.search(&IndexSearchQuery::default());
743        let names: Vec<&str> = result.hits.iter().map(|h| h.name.as_str()).collect();
744        assert_eq!(names, vec!["alpha", "middle", "zebra"]);
745    }
746
747    #[test]
748    fn search_semver_precedence_for_selection() {
749        let index = make_index(vec![
750            make_entry("alpha", "1.0.0-alpha"),
751            make_entry("alpha", "1.0.0"),
752        ]);
753        let result = index.search(&IndexSearchQuery::default());
754        assert_eq!(result.hits.len(), 1);
755        assert_eq!(
756            result.hits[0].version,
757            "1.0.0".parse::<semver::Version>().unwrap()
758        );
759    }
760
761    #[test]
762    fn search_build_metadata_deterministic() {
763        let index_a = make_index(vec![
764            make_entry("alpha", "1.0.0+build.1"),
765            make_entry("alpha", "1.0.0+build.2"),
766        ]);
767        let index_b = make_index(vec![
768            make_entry("alpha", "1.0.0+build.2"),
769            make_entry("alpha", "1.0.0+build.1"),
770        ]);
771        let result_a = index_a.search(&IndexSearchQuery::default());
772        let result_b = index_b.search(&IndexSearchQuery::default());
773        assert_eq!(result_a.hits[0].version, result_b.hits[0].version);
774    }
775
776    // --- Info tests ---
777
778    #[test]
779    fn info_selects_latest_visible() {
780        let index = make_index(vec![
781            make_entry("alpha", "1.0.0"),
782            make_entry("alpha", "1.2.0"),
783            make_entry("alpha", "2.0.0"),
784        ]);
785        let result = index.info(&IndexInfoQuery {
786            name: "alpha".parse().unwrap(),
787            version: None,
788            database_version: None,
789            include_yanked: false,
790            include_incompatible: false,
791        });
792        assert_matches!(&result, IndexInfoResult::Found(info) => {
793            assert_eq!(info.version, "2.0.0".parse::<semver::Version>().unwrap());
794        });
795    }
796
797    #[test]
798    fn info_returns_single_version() {
799        let index = make_index(vec![
800            make_entry("alpha", "1.0.0"),
801            make_entry("alpha", "2.0.0"),
802        ]);
803        let result = index.info(&IndexInfoQuery {
804            name: "alpha".parse().unwrap(),
805            version: None,
806            database_version: None,
807            include_yanked: false,
808            include_incompatible: false,
809        });
810        assert_matches!(&result, IndexInfoResult::Found(_));
811    }
812
813    #[test]
814    fn info_skips_yanked_by_default() {
815        let mut v2 = make_entry("alpha", "2.0.0");
816        v2.yanked = true;
817        let index = make_index(vec![make_entry("alpha", "1.0.0"), v2]);
818        let result = index.info(&IndexInfoQuery {
819            name: "alpha".parse().unwrap(),
820            version: None,
821            database_version: None,
822            include_yanked: false,
823            include_incompatible: false,
824        });
825        assert_matches!(&result, IndexInfoResult::Found(info) => {
826            assert_eq!(info.version, "1.0.0".parse::<semver::Version>().unwrap());
827        });
828    }
829
830    #[test]
831    fn info_skips_incompatible_by_default() {
832        let mut v2 = make_entry("alpha", "2.0.0");
833        v2.dependencies.database_version = ">=4.0.0".parse().unwrap();
834        let index = make_index(vec![make_entry("alpha", "1.0.0"), v2]);
835        let result = index.info(&IndexInfoQuery {
836            name: "alpha".parse().unwrap(),
837            version: None,
838            database_version: Some("3.2.0".parse().unwrap()),
839            include_yanked: false,
840            include_incompatible: false,
841        });
842        assert_matches!(&result, IndexInfoResult::Found(info) => {
843            assert_eq!(info.version, "1.0.0".parse::<semver::Version>().unwrap());
844        });
845    }
846
847    #[test]
848    fn info_includes_yanked_when_requested() {
849        let mut v2 = make_entry("alpha", "2.0.0");
850        v2.yanked = true;
851        let index = make_index(vec![make_entry("alpha", "1.0.0"), v2]);
852        let result = index.info(&IndexInfoQuery {
853            name: "alpha".parse().unwrap(),
854            version: None,
855            database_version: None,
856            include_yanked: true,
857            include_incompatible: false,
858        });
859        assert_matches!(&result, IndexInfoResult::Found(info) => {
860            assert_eq!(info.version, "2.0.0".parse::<semver::Version>().unwrap());
861            assert_matches!(&info.visibility, IndexVersionVisibility::Hidden { reasons }
862                if reasons.len() == 1
863                    && matches!(&reasons[0], IndexVisibilityReason::Yanked)
864            );
865        });
866    }
867
868    #[test]
869    fn info_includes_incompatible_when_requested() {
870        let mut v2 = make_entry("alpha", "2.0.0");
871        v2.dependencies.database_version = ">=4.0.0".parse().unwrap();
872        let index = make_index(vec![make_entry("alpha", "1.0.0"), v2]);
873        let result = index.info(&IndexInfoQuery {
874            name: "alpha".parse().unwrap(),
875            version: None,
876            database_version: Some("3.2.0".parse().unwrap()),
877            include_yanked: false,
878            include_incompatible: true,
879        });
880        assert_matches!(&result, IndexInfoResult::Found(info) => {
881            assert_eq!(info.version, "2.0.0".parse::<semver::Version>().unwrap());
882            assert_matches!(&info.visibility, IndexVersionVisibility::Hidden { reasons }
883                if reasons.len() == 1
884                    && matches!(&reasons[0], IndexVisibilityReason::IncompatibleDatabaseVersion { .. })
885            );
886        });
887    }
888
889    #[test]
890    fn info_no_compat_filter_without_db_version() {
891        let mut entry = make_entry("alpha", "1.0.0");
892        entry.dependencies.database_version = ">=99.0.0".parse().unwrap();
893        let index = make_index(vec![entry]);
894        let result = index.info(&IndexInfoQuery {
895            name: "alpha".parse().unwrap(),
896            version: None,
897            database_version: None,
898            include_yanked: false,
899            include_incompatible: false,
900        });
901        assert_matches!(&result, IndexInfoResult::Found(info) => {
902            assert_eq!(info.visibility, IndexVersionVisibility::Visible);
903        });
904    }
905
906    #[test]
907    fn info_missing_name() {
908        let index = make_index(vec![make_entry("alpha", "1.0.0")]);
909        let result = index.info(&IndexInfoQuery {
910            name: "nonexistent".parse().unwrap(),
911            version: None,
912            database_version: None,
913            include_yanked: false,
914            include_incompatible: false,
915        });
916        assert_matches!(&result, IndexInfoResult::NotFound { name, version } => {
917            assert_eq!(name.as_str(), "nonexistent");
918            assert!(version.is_none());
919        });
920    }
921
922    #[test]
923    fn info_all_yanked() {
924        let mut v1 = make_entry("alpha", "1.0.0");
925        v1.yanked = true;
926        let mut v2 = make_entry("alpha", "2.0.0");
927        v2.yanked = true;
928        let index = make_index(vec![v1, v2]);
929        let result = index.info(&IndexInfoQuery {
930            name: "alpha".parse().unwrap(),
931            version: None,
932            database_version: None,
933            include_yanked: false,
934            include_incompatible: false,
935        });
936        assert_matches!(&result, IndexInfoResult::FilteredOut { name, version, reasons } => {
937            assert_eq!(name.as_str(), "alpha");
938            assert!(version.is_none());
939            assert_eq!(reasons.len(), 1);
940            assert_matches!(&reasons[0], IndexVisibilityReason::Yanked);
941        });
942    }
943
944    #[test]
945    fn info_all_incompatible() {
946        let mut v1 = make_entry("alpha", "1.0.0");
947        v1.dependencies.database_version = ">=4.0.0".parse().unwrap();
948        let index = make_index(vec![v1]);
949        let result = index.info(&IndexInfoQuery {
950            name: "alpha".parse().unwrap(),
951            version: None,
952            database_version: Some("3.2.0".parse().unwrap()),
953            include_yanked: false,
954            include_incompatible: false,
955        });
956        assert_matches!(&result, IndexInfoResult::FilteredOut { name, version, reasons } => {
957            assert_eq!(name.as_str(), "alpha");
958            assert!(version.is_none());
959            assert_eq!(reasons.len(), 1);
960            assert_matches!(&reasons[0], IndexVisibilityReason::IncompatibleDatabaseVersion { .. });
961        });
962    }
963
964    #[test]
965    fn info_all_hidden_mixed_reasons() {
966        let mut v1 = make_entry("alpha", "1.0.0");
967        v1.yanked = true;
968        let mut v2 = make_entry("alpha", "2.0.0");
969        v2.dependencies.database_version = ">=4.0.0".parse().unwrap();
970        let index = make_index(vec![v1, v2]);
971        let result = index.info(&IndexInfoQuery {
972            name: "alpha".parse().unwrap(),
973            version: None,
974            database_version: Some("3.2.0".parse().unwrap()),
975            include_yanked: false,
976            include_incompatible: false,
977        });
978        assert_matches!(&result, IndexInfoResult::FilteredOut { reasons, .. } => {
979            assert_eq!(reasons.len(), 2);
980            assert!(reasons.iter().any(|r| matches!(r, IndexVisibilityReason::Yanked)));
981            assert!(reasons.iter().any(|r| matches!(r, IndexVisibilityReason::IncompatibleDatabaseVersion { .. })));
982        });
983    }
984
985    // --- Exact version info tests ---
986
987    #[test]
988    fn info_exact_version_visible() {
989        let index = make_index(vec![
990            make_entry("alpha", "1.0.0"),
991            make_entry("alpha", "2.0.0"),
992        ]);
993        let result = index.info(&IndexInfoQuery {
994            name: "alpha".parse().unwrap(),
995            version: Some("1.0.0".parse().unwrap()),
996            database_version: None,
997            include_yanked: false,
998            include_incompatible: false,
999        });
1000        assert_matches!(&result, IndexInfoResult::Found(info) => {
1001            assert_eq!(info.version, "1.0.0".parse::<semver::Version>().unwrap());
1002            assert_eq!(info.visibility, IndexVersionVisibility::Visible);
1003        });
1004    }
1005
1006    #[test]
1007    fn info_exact_version_yanked() {
1008        let mut entry = make_entry("alpha", "1.0.0");
1009        entry.yanked = true;
1010        let index = make_index(vec![entry]);
1011        let result = index.info(&IndexInfoQuery {
1012            name: "alpha".parse().unwrap(),
1013            version: Some("1.0.0".parse().unwrap()),
1014            database_version: None,
1015            include_yanked: false,
1016            include_incompatible: false,
1017        });
1018        assert_matches!(&result, IndexInfoResult::Found(info) => {
1019            assert_matches!(&info.visibility, IndexVersionVisibility::Hidden { reasons }
1020                if reasons.len() == 1
1021                    && matches!(&reasons[0], IndexVisibilityReason::Yanked)
1022            );
1023        });
1024    }
1025
1026    #[test]
1027    fn info_exact_version_incompatible() {
1028        let mut entry = make_entry("alpha", "1.0.0");
1029        entry.dependencies.database_version = ">=4.0.0".parse().unwrap();
1030        let index = make_index(vec![entry]);
1031        let result = index.info(&IndexInfoQuery {
1032            name: "alpha".parse().unwrap(),
1033            version: Some("1.0.0".parse().unwrap()),
1034            database_version: Some("3.2.0".parse().unwrap()),
1035            include_yanked: false,
1036            include_incompatible: false,
1037        });
1038        assert_matches!(&result, IndexInfoResult::Found(info) => {
1039            assert_matches!(&info.visibility, IndexVersionVisibility::Hidden { reasons }
1040                if reasons.len() == 1
1041                    && matches!(&reasons[0], IndexVisibilityReason::IncompatibleDatabaseVersion {
1042                        required, actual
1043                    } if required.to_string() == ">=4.0.0"
1044                        && *actual == "3.2.0".parse::<semver::Version>().unwrap()
1045                    )
1046            );
1047        });
1048    }
1049
1050    #[test]
1051    fn info_exact_version_yanked_and_incompatible() {
1052        let mut entry = make_entry("alpha", "1.0.0");
1053        entry.yanked = true;
1054        entry.dependencies.database_version = ">=4.0.0".parse().unwrap();
1055        let index = make_index(vec![entry]);
1056        let result = index.info(&IndexInfoQuery {
1057            name: "alpha".parse().unwrap(),
1058            version: Some("1.0.0".parse().unwrap()),
1059            database_version: Some("3.2.0".parse().unwrap()),
1060            include_yanked: false,
1061            include_incompatible: false,
1062        });
1063        assert_matches!(&result, IndexInfoResult::Found(info) => {
1064            assert_matches!(&info.visibility, IndexVersionVisibility::Hidden { reasons } => {
1065                assert_eq!(reasons.len(), 2);
1066                assert_matches!(&reasons[0], IndexVisibilityReason::Yanked);
1067                assert_matches!(&reasons[1], IndexVisibilityReason::IncompatibleDatabaseVersion { .. });
1068            });
1069        });
1070    }
1071
1072    #[test]
1073    fn info_exact_version_missing() {
1074        let index = make_index(vec![make_entry("alpha", "1.0.0")]);
1075        let result = index.info(&IndexInfoQuery {
1076            name: "alpha".parse().unwrap(),
1077            version: Some("9.9.9".parse().unwrap()),
1078            database_version: None,
1079            include_yanked: false,
1080            include_incompatible: false,
1081        });
1082        assert_matches!(&result, IndexInfoResult::NotFound { name, version } => {
1083            assert_eq!(name.as_str(), "alpha");
1084            assert_eq!(*version, Some("9.9.9".parse::<semver::Version>().unwrap()));
1085        });
1086    }
1087
1088    #[test]
1089    fn info_exact_version_missing_plugin() {
1090        let index = make_index(vec![make_entry("alpha", "1.0.0")]);
1091        let result = index.info(&IndexInfoQuery {
1092            name: "nonexistent".parse().unwrap(),
1093            version: Some("1.0.0".parse().unwrap()),
1094            database_version: None,
1095            include_yanked: false,
1096            include_incompatible: false,
1097        });
1098        assert_matches!(&result, IndexInfoResult::NotFound { name, version } => {
1099            assert_eq!(name.as_str(), "nonexistent");
1100            assert_eq!(*version, Some("1.0.0".parse::<semver::Version>().unwrap()));
1101        });
1102    }
1103
1104    // --- Result content tests ---
1105
1106    #[test]
1107    fn info_full_metadata() {
1108        let mut entry = make_entry("downsampler", "1.2.0");
1109        entry.description = Description::try_new("Downsamples WAL data").unwrap();
1110        entry.triggers = vec![
1111            TriggerType::ProcessWrites,
1112            TriggerType::ProcessScheduledCall,
1113        ];
1114        entry.homepage = Some("https://example.com".parse().unwrap());
1115        entry.repository = Some("https://github.com/example/ds".parse().unwrap());
1116        entry.documentation = Some("https://docs.example.com/ds".parse().unwrap());
1117        entry.dependencies = Dependencies {
1118            database_version: ">=3.2.0,<4.0.0".parse().unwrap(),
1119            python: vec![PythonRequirement::try_new("requests>=2.31").unwrap()],
1120        };
1121        entry.hash = ArtifactHash::try_new(
1122            "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
1123        )
1124        .unwrap();
1125        let index = make_index(vec![entry]);
1126        let result = index.info(&IndexInfoQuery {
1127            name: "downsampler".parse().unwrap(),
1128            version: None,
1129            database_version: Some("3.5.0".parse().unwrap()),
1130            include_yanked: false,
1131            include_incompatible: false,
1132        });
1133        assert_matches!(&result, IndexInfoResult::Found(info) => {
1134            assert_eq!(info.name.as_str(), "downsampler");
1135            assert_eq!(info.version, "1.2.0".parse::<semver::Version>().unwrap());
1136            assert_eq!(info.description.as_str(), "Downsamples WAL data");
1137            assert_eq!(info.triggers.len(), 2);
1138            assert!(info.homepage.is_some());
1139            assert!(info.repository.is_some());
1140            assert!(info.documentation.is_some());
1141            assert_eq!(info.dependencies.database_version.to_string(), ">=3.2.0, <4.0.0");
1142            assert_eq!(info.dependencies.python.len(), 1);
1143            assert!(info.hash.as_str().starts_with("sha256:abcdef"));
1144            assert_eq!(info.visibility, IndexVersionVisibility::Visible);
1145        });
1146    }
1147
1148    #[test]
1149    fn info_exposes_published_at() {
1150        let index = make_index(vec![make_entry_with_published_at(
1151            "alpha",
1152            "1.0.0",
1153            "2027-01-02T03:04:05Z",
1154        )]);
1155        let result = index.info(&IndexInfoQuery {
1156            name: "alpha".parse().unwrap(),
1157            version: Some("1.0.0".parse().unwrap()),
1158            database_version: None,
1159            include_yanked: false,
1160            include_incompatible: false,
1161        });
1162        assert_matches!(&result, IndexInfoResult::Found(info) => {
1163            assert_eq!(info.published_at.as_str(), "2027-01-02T03:04:05Z");
1164        });
1165    }
1166
1167    #[test]
1168    fn info_incompatible_reason_includes_versions() {
1169        let mut entry = make_entry("alpha", "1.0.0");
1170        entry.dependencies.database_version = ">=4.0.0".parse().unwrap();
1171        let index = make_index(vec![entry]);
1172        let result = index.info(&IndexInfoQuery {
1173            name: "alpha".parse().unwrap(),
1174            version: Some("1.0.0".parse().unwrap()),
1175            database_version: Some("3.2.0".parse().unwrap()),
1176            include_yanked: false,
1177            include_incompatible: false,
1178        });
1179        assert_matches!(&result, IndexInfoResult::Found(info) => {
1180            assert_matches!(&info.visibility, IndexVersionVisibility::Hidden { reasons } => {
1181                assert_matches!(&reasons[0], IndexVisibilityReason::IncompatibleDatabaseVersion {
1182                    required, actual
1183                } => {
1184                    assert_eq!(required.to_string(), ">=4.0.0");
1185                    assert_eq!(*actual, "3.2.0".parse::<semver::Version>().unwrap());
1186                });
1187            });
1188        });
1189    }
1190
1191    // --- Serialization tests ---
1192
1193    #[test]
1194    fn search_result_serializes() {
1195        let index = make_index(vec![make_entry("alpha", "1.0.0")]);
1196        let result = index.search(&IndexSearchQuery::default());
1197        let json = serde_json::to_value(&result).unwrap();
1198        assert!(json["hits"].is_array());
1199        assert_eq!(json["hits"][0]["name"], "alpha");
1200        assert_eq!(json["hits"][0]["version"], "1.0.0");
1201        assert_eq!(json["hits"][0]["published_at"], "2026-04-29T18:45:12Z");
1202        assert!(json["hits"][0]["description"].is_string());
1203        assert!(json["hits"][0]["triggers"].is_array());
1204        assert_eq!(json["hits"][0]["visibility"], "Visible");
1205    }
1206
1207    #[test]
1208    fn info_found_serializes() {
1209        let index = make_index(vec![make_entry("alpha", "1.0.0")]);
1210        let result = index.info(&IndexInfoQuery {
1211            name: "alpha".parse().unwrap(),
1212            version: None,
1213            database_version: None,
1214            include_yanked: false,
1215            include_incompatible: false,
1216        });
1217        let json = serde_json::to_value(&result).unwrap();
1218        let found = &json["Found"];
1219        assert_eq!(found["name"], "alpha");
1220        assert_eq!(found["version"], "1.0.0");
1221        assert_eq!(found["published_at"], "2026-04-29T18:45:12Z");
1222        assert!(found["description"].is_string());
1223        assert!(found["triggers"].is_array());
1224        assert!(found["dependencies"].is_object());
1225        assert!(found["hash"].is_string());
1226        assert_eq!(found["visibility"], "Visible");
1227    }
1228
1229    #[test]
1230    fn info_not_found_serializes() {
1231        let index = make_index(vec![]);
1232        let result = index.info(&IndexInfoQuery {
1233            name: "missing".parse().unwrap(),
1234            version: Some("1.0.0".parse().unwrap()),
1235            database_version: None,
1236            include_yanked: false,
1237            include_incompatible: false,
1238        });
1239        let json = serde_json::to_value(&result).unwrap();
1240        let not_found = &json["NotFound"];
1241        assert_eq!(not_found["name"], "missing");
1242        assert_eq!(not_found["version"], "1.0.0");
1243    }
1244
1245    #[test]
1246    fn info_filtered_out_serializes() {
1247        let mut entry = make_entry("alpha", "1.0.0");
1248        entry.yanked = true;
1249        let index = make_index(vec![entry]);
1250        let result = index.info(&IndexInfoQuery {
1251            name: "alpha".parse().unwrap(),
1252            version: None,
1253            database_version: None,
1254            include_yanked: false,
1255            include_incompatible: false,
1256        });
1257        let json = serde_json::to_value(&result).unwrap();
1258        let filtered = &json["FilteredOut"];
1259        assert_eq!(filtered["name"], "alpha");
1260        assert!(filtered["version"].is_null());
1261        assert!(filtered["reasons"].is_array());
1262    }
1263
1264    #[test]
1265    fn visibility_reasons_serialize() {
1266        let yanked = IndexVisibilityReason::Yanked;
1267        let incompat = IndexVisibilityReason::IncompatibleDatabaseVersion {
1268            required: ">=4.0.0".parse().unwrap(),
1269            actual: "3.2.0".parse().unwrap(),
1270        };
1271        let yanked_json = serde_json::to_value(&yanked).unwrap();
1272        let incompat_json = serde_json::to_value(&incompat).unwrap();
1273        assert_eq!(yanked_json, "Yanked");
1274        assert!(incompat_json["IncompatibleDatabaseVersion"].is_object());
1275        assert_eq!(
1276            incompat_json["IncompatibleDatabaseVersion"]["required"],
1277            ">=4.0.0"
1278        );
1279        assert_eq!(
1280            incompat_json["IncompatibleDatabaseVersion"]["actual"],
1281            "3.2.0"
1282        );
1283    }
1284
1285    // --- Edge case tests ---
1286
1287    #[test]
1288    fn search_empty_index() {
1289        let index = make_index(vec![]);
1290        let result = index.search(&IndexSearchQuery::default());
1291        assert_eq!(result.hits.len(), 0);
1292    }
1293
1294    #[test]
1295    fn info_empty_index() {
1296        let index = make_index(vec![]);
1297        let result = index.info(&IndexInfoQuery {
1298            name: "alpha".parse().unwrap(),
1299            version: None,
1300            database_version: None,
1301            include_yanked: false,
1302            include_incompatible: false,
1303        });
1304        assert_matches!(&result, IndexInfoResult::NotFound { .. });
1305    }
1306
1307    #[test]
1308    fn search_only_hidden_not_shown() {
1309        let mut entry = make_entry("alpha", "1.0.0");
1310        entry.yanked = true;
1311        let index = make_index(vec![entry]);
1312        let result = index.search(&IndexSearchQuery::default());
1313        assert_eq!(result.hits.len(), 0);
1314    }
1315
1316    #[test]
1317    fn search_mixed_visible_hidden_uses_visible() {
1318        let mut v2 = make_entry("alpha", "2.0.0");
1319        v2.yanked = true;
1320        let index = make_index(vec![make_entry("alpha", "1.0.0"), v2]);
1321        let result = index.search(&IndexSearchQuery::default());
1322        assert_eq!(result.hits.len(), 1);
1323        assert_eq!(
1324            result.hits[0].version,
1325            "1.0.0".parse::<semver::Version>().unwrap()
1326        );
1327        assert_eq!(result.hits[0].visibility, IndexVersionVisibility::Visible);
1328    }
1329
1330    #[test]
1331    fn search_mixed_with_include_flags() {
1332        let mut v2 = make_entry("alpha", "2.0.0");
1333        v2.yanked = true;
1334        let index = make_index(vec![make_entry("alpha", "1.0.0"), v2]);
1335        let result = index.search(&IndexSearchQuery {
1336            include_yanked: true,
1337            ..Default::default()
1338        });
1339        assert_eq!(result.hits.len(), 1);
1340        assert_eq!(
1341            result.hits[0].version,
1342            "2.0.0".parse::<semver::Version>().unwrap()
1343        );
1344    }
1345
1346    #[test]
1347    fn search_text_match_on_hidden_visible_older_matches() {
1348        let mut v2 = make_entry("alpha", "2.0.0");
1349        v2.yanked = true;
1350        v2.description = Description::try_new("common in both versions").unwrap();
1351        let mut v1 = make_entry("alpha", "1.0.0");
1352        v1.description = Description::try_new("common desc").unwrap();
1353        let index = make_index(vec![v1, v2]);
1354        // Both versions match "common", but v2 is hidden (yanked).
1355        // Search excludes v2 before text matching; v1 matches and is selected.
1356        let result = index.search(&IndexSearchQuery {
1357            query: Some("common".into()),
1358            ..Default::default()
1359        });
1360        assert_eq!(result.hits.len(), 1);
1361        assert_eq!(
1362            result.hits[0].version,
1363            "1.0.0".parse::<semver::Version>().unwrap()
1364        );
1365    }
1366
1367    #[test]
1368    fn search_trigger_filter_before_grouping() {
1369        let mut v2 = make_entry("alpha", "2.0.0");
1370        v2.triggers = vec![TriggerType::ProcessRequest];
1371        let mut v1 = make_entry("alpha", "1.0.0");
1372        v1.triggers = vec![TriggerType::ProcessWrites];
1373        let index = make_index(vec![v1, v2]);
1374        let result = index.search(&IndexSearchQuery {
1375            trigger_type: Some(TriggerType::ProcessWrites),
1376            ..Default::default()
1377        });
1378        assert_eq!(result.hits.len(), 1);
1379        assert_eq!(
1380            result.hits[0].version,
1381            "1.0.0".parse::<semver::Version>().unwrap()
1382        );
1383        assert_eq!(result.hits[0].triggers, vec![TriggerType::ProcessWrites]);
1384    }
1385}