Skip to main content

pi/
extension_index.rs

1//! Extension discovery index (offline-first).
2//!
3//! This module provides a local, searchable index of available extensions. The index is:
4//! - **Offline-first**: Pi ships a bundled seed index embedded at compile time.
5//! - **Fail-open**: cache load/refresh failures should never break discovery.
6//! - **Host-agnostic**: the index is primarily a data structure; CLI commands live elsewhere.
7
8use crate::config::Config;
9use crate::error::{Error, Result};
10use crate::http::client::Client;
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use std::collections::{BTreeMap, BTreeSet};
14use std::io::Write as _;
15use std::path::{Path, PathBuf};
16use std::time::Duration;
17use tempfile::NamedTempFile;
18
19pub const EXTENSION_INDEX_SCHEMA: &str = "pi.ext.index.v1";
20pub const EXTENSION_INDEX_VERSION: u32 = 1;
21pub const DEFAULT_INDEX_MAX_AGE: Duration = Duration::from_secs(60 * 60 * 24);
22const DEFAULT_NPM_QUERY: &str = "keywords:pi-extension";
23const DEFAULT_GITHUB_QUERY: &str = "topic:pi-extension";
24const DEFAULT_REMOTE_LIMIT: usize = 100;
25const REMOTE_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(rename_all = "camelCase")]
29pub struct ExtensionIndex {
30    pub schema: String,
31    pub version: u32,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub generated_at: Option<String>,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub last_refreshed_at: Option<String>,
36    #[serde(default)]
37    pub entries: Vec<ExtensionIndexEntry>,
38}
39
40impl ExtensionIndex {
41    #[must_use]
42    pub fn new_empty() -> Self {
43        Self {
44            schema: EXTENSION_INDEX_SCHEMA.to_string(),
45            version: EXTENSION_INDEX_VERSION,
46            generated_at: Some(Utc::now().to_rfc3339()),
47            last_refreshed_at: None,
48            entries: Vec::new(),
49        }
50    }
51
52    pub fn validate(&self) -> Result<()> {
53        if self.schema != EXTENSION_INDEX_SCHEMA {
54            return Err(Error::validation(format!(
55                "Unsupported extension index schema: {}",
56                self.schema
57            )));
58        }
59        if self.version != EXTENSION_INDEX_VERSION {
60            return Err(Error::validation(format!(
61                "Unsupported extension index version: {}",
62                self.version
63            )));
64        }
65        Ok(())
66    }
67
68    #[must_use]
69    pub fn is_stale(&self, now: DateTime<Utc>, max_age: Duration) -> bool {
70        let Some(ts) = &self.last_refreshed_at else {
71            return true;
72        };
73        let Ok(parsed) = DateTime::parse_from_rfc3339(ts) else {
74            return true;
75        };
76        let parsed = parsed.with_timezone(&Utc);
77        now.signed_duration_since(parsed)
78            .to_std()
79            .map_or(true, |age| age > max_age)
80    }
81
82    /// Resolve a unique `installSource` for an id/name, if present.
83    ///
84    /// This is used to support ergonomic forms like `pi install checkpoint-pi` without requiring
85    /// users to spell out `npm:` / `git:` prefixes. If resolution is ambiguous, returns `None`.
86    #[must_use]
87    pub fn resolve_install_source(&self, query: &str) -> Option<String> {
88        let q = query.trim();
89        if q.is_empty() {
90            return None;
91        }
92        let q_lc = q.to_ascii_lowercase();
93
94        let mut sources: BTreeSet<String> = BTreeSet::new();
95        for entry in &self.entries {
96            let Some(install) = &entry.install_source else {
97                continue;
98            };
99
100            if entry.name.eq_ignore_ascii_case(q) || entry.id.eq_ignore_ascii_case(q) {
101                sources.insert(install.clone());
102                continue;
103            }
104
105            // Convenience: `npm/<name>` or `<name>` for npm entries.
106            if let Some(ExtensionIndexSource::Npm { package, .. }) = &entry.source {
107                if package.to_ascii_lowercase() == q_lc {
108                    sources.insert(install.clone());
109                    continue;
110                }
111            }
112
113            if let Some(rest) = entry.id.strip_prefix("npm/") {
114                if rest.eq_ignore_ascii_case(q) {
115                    sources.insert(install.clone());
116                }
117            }
118        }
119
120        if sources.len() == 1 {
121            sources.into_iter().next()
122        } else {
123            None
124        }
125    }
126
127    #[must_use]
128    pub fn search(&self, query: &str, limit: usize) -> Vec<ExtensionSearchHit> {
129        let q = query.trim();
130        if q.is_empty() || limit == 0 {
131            return Vec::new();
132        }
133
134        let tokens = q
135            .split_whitespace()
136            .map(|t| t.trim().to_ascii_lowercase())
137            .filter(|t| !t.is_empty())
138            .collect::<Vec<_>>();
139        if tokens.is_empty() {
140            return Vec::new();
141        }
142
143        let mut hits = self
144            .entries
145            .iter()
146            .filter_map(|entry| {
147                let score = score_entry(entry, &tokens);
148                if score <= 0 {
149                    None
150                } else {
151                    Some(ExtensionSearchHit {
152                        entry: entry.clone(),
153                        score,
154                    })
155                }
156            })
157            .collect::<Vec<_>>();
158
159        hits.sort_by(|a, b| {
160            b.score
161                .cmp(&a.score)
162                .then_with(|| {
163                    b.entry
164                        .install_source
165                        .is_some()
166                        .cmp(&a.entry.install_source.is_some())
167                })
168                .then_with(|| {
169                    a.entry
170                        .name
171                        .to_ascii_lowercase()
172                        .cmp(&b.entry.name.to_ascii_lowercase())
173                })
174                .then_with(|| {
175                    a.entry
176                        .id
177                        .to_ascii_lowercase()
178                        .cmp(&b.entry.id.to_ascii_lowercase())
179                })
180        });
181
182        hits.truncate(limit);
183        hits
184    }
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
188#[serde(rename_all = "camelCase")]
189pub struct ExtensionIndexEntry {
190    /// Globally unique id within the index (stable key).
191    pub id: String,
192    /// Primary display name (often npm package name or repo name).
193    pub name: String,
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub description: Option<String>,
196    #[serde(default)]
197    pub tags: Vec<String>,
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub license: Option<String>,
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub source: Option<ExtensionIndexSource>,
202    /// Optional source string compatible with Pi's package manager (e.g. `npm:pkg@ver`).
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub install_source: Option<String>,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
208#[serde(tag = "type", rename_all = "lowercase")]
209pub enum ExtensionIndexSource {
210    Npm {
211        package: String,
212        #[serde(default, skip_serializing_if = "Option::is_none")]
213        version: Option<String>,
214        #[serde(default, skip_serializing_if = "Option::is_none")]
215        url: Option<String>,
216    },
217    Git {
218        repo: String,
219        #[serde(default, skip_serializing_if = "Option::is_none")]
220        path: Option<String>,
221        #[serde(default, skip_serializing_if = "Option::is_none")]
222        r#ref: Option<String>,
223    },
224    Url {
225        url: String,
226    },
227}
228
229#[derive(Debug, Clone)]
230pub struct ExtensionSearchHit {
231    pub entry: ExtensionIndexEntry,
232    pub score: i64,
233}
234
235#[derive(Debug, Clone, Default)]
236pub struct ExtensionIndexRefreshStats {
237    pub npm_entries: usize,
238    pub github_entries: usize,
239    pub merged_entries: usize,
240    pub refreshed: bool,
241}
242
243fn score_entry(entry: &ExtensionIndexEntry, tokens: &[String]) -> i64 {
244    let name = entry.name.to_ascii_lowercase();
245    let id = entry.id.to_ascii_lowercase();
246    let description = entry
247        .description
248        .as_ref()
249        .map(|s| s.to_ascii_lowercase())
250        .unwrap_or_default();
251    let tags = entry
252        .tags
253        .iter()
254        .map(|t| t.to_ascii_lowercase())
255        .collect::<Vec<_>>();
256
257    let mut score: i64 = 0;
258    for token in tokens {
259        if name.contains(token) {
260            score += 300;
261        }
262        if id.contains(token) {
263            score += 120;
264        }
265        if description.contains(token) {
266            score += 60;
267        }
268        if tags.iter().any(|t| t.contains(token)) {
269            score += 180;
270        }
271    }
272
273    score
274}
275
276#[derive(Debug, Clone)]
277pub struct ExtensionIndexStore {
278    path: PathBuf,
279}
280
281impl ExtensionIndexStore {
282    #[must_use]
283    pub const fn new(path: PathBuf) -> Self {
284        Self { path }
285    }
286
287    #[must_use]
288    pub fn default_path() -> PathBuf {
289        Config::extension_index_path()
290    }
291
292    #[must_use]
293    pub fn default_store() -> Self {
294        Self::new(Self::default_path())
295    }
296
297    #[must_use]
298    pub fn path(&self) -> &Path {
299        &self.path
300    }
301
302    pub fn load(&self) -> Result<Option<ExtensionIndex>> {
303        if !self.path.exists() {
304            return Ok(None);
305        }
306        let content = std::fs::read_to_string(&self.path)?;
307        let index: ExtensionIndex = serde_json::from_str(&content)?;
308        index.validate()?;
309        Ok(Some(index))
310    }
311
312    pub fn load_or_seed(&self) -> Result<ExtensionIndex> {
313        match self.load() {
314            Ok(Some(index)) => Ok(index),
315            Ok(None) => seed_index(),
316            Err(err) => {
317                tracing::warn!(
318                    "failed to load extension index cache (falling back to seed): {err}"
319                );
320                seed_index()
321            }
322        }
323    }
324
325    pub fn save(&self, index: &ExtensionIndex) -> Result<()> {
326        index.validate()?;
327        if let Some(parent) = self.path.parent() {
328            std::fs::create_dir_all(parent)?;
329            let mut tmp = NamedTempFile::new_in(parent)?;
330            let encoded = serde_json::to_string_pretty(index)?;
331            tmp.write_all(encoded.as_bytes())?;
332            tmp.flush()?;
333            tmp.persist(&self.path)
334                .map(|_| ())
335                .map_err(|e| Error::from(Box::new(e.error)))
336        } else {
337            Err(Error::config(format!(
338                "Invalid extension index path: {}",
339                self.path.display()
340            )))
341        }
342    }
343
344    pub fn resolve_install_source(&self, query: &str) -> Result<Option<String>> {
345        let index = self.load_or_seed()?;
346        Ok(index.resolve_install_source(query))
347    }
348
349    pub async fn load_or_refresh_best_effort(
350        &self,
351        client: &Client,
352        max_age: Duration,
353    ) -> Result<ExtensionIndex> {
354        let current = self.load_or_seed()?;
355        if current.is_stale(Utc::now(), max_age) {
356            let (refreshed, _) = self.refresh_best_effort(client).await?;
357            return Ok(refreshed);
358        }
359        Ok(current)
360    }
361
362    pub async fn refresh_best_effort(
363        &self,
364        client: &Client,
365    ) -> Result<(ExtensionIndex, ExtensionIndexRefreshStats)> {
366        let mut current = self.load_or_seed()?;
367
368        let npm_entries = match fetch_npm_entries(client, DEFAULT_REMOTE_LIMIT).await {
369            Ok(entries) => entries,
370            Err(err) => {
371                tracing::warn!("npm extension index refresh failed: {err}");
372                Vec::new()
373            }
374        };
375        let github_entries = match fetch_github_entries(client, DEFAULT_REMOTE_LIMIT).await {
376            Ok(entries) => entries,
377            Err(err) => {
378                tracing::warn!("github extension index refresh failed: {err}");
379                Vec::new()
380            }
381        };
382
383        let npm_count = npm_entries.len();
384        let github_count = github_entries.len();
385        if npm_count == 0 && github_count == 0 {
386            return Ok((
387                current,
388                ExtensionIndexRefreshStats {
389                    npm_entries: 0,
390                    github_entries: 0,
391                    merged_entries: 0,
392                    refreshed: false,
393                },
394            ));
395        }
396
397        current.entries = merge_entries(current.entries, npm_entries, github_entries);
398        current.last_refreshed_at = Some(Utc::now().to_rfc3339());
399        if let Err(err) = self.save(&current) {
400            tracing::warn!("failed to persist refreshed extension index cache: {err}");
401        }
402
403        Ok((
404            current.clone(),
405            ExtensionIndexRefreshStats {
406                npm_entries: npm_count,
407                github_entries: github_count,
408                merged_entries: current.entries.len(),
409                refreshed: true,
410            },
411        ))
412    }
413}
414
415fn merge_entries(
416    existing: Vec<ExtensionIndexEntry>,
417    npm_entries: Vec<ExtensionIndexEntry>,
418    github_entries: Vec<ExtensionIndexEntry>,
419) -> Vec<ExtensionIndexEntry> {
420    let mut by_id = BTreeMap::<String, ExtensionIndexEntry>::new();
421    for entry in existing {
422        by_id.insert(entry.id.to_ascii_lowercase(), entry);
423    }
424
425    for incoming in npm_entries.into_iter().chain(github_entries) {
426        let key = incoming.id.to_ascii_lowercase();
427        if let Some(entry) = by_id.get_mut(&key) {
428            merge_entry(entry, incoming);
429        } else {
430            by_id.insert(key, incoming);
431        }
432    }
433
434    let mut entries = by_id.into_values().collect::<Vec<_>>();
435    entries.sort_by_key(|entry| entry.id.to_ascii_lowercase());
436    entries
437}
438
439fn merge_entry(existing: &mut ExtensionIndexEntry, incoming: ExtensionIndexEntry) {
440    if !incoming.name.trim().is_empty() {
441        existing.name = incoming.name;
442    }
443    if incoming.description.is_some() {
444        existing.description = incoming.description;
445    }
446    if incoming.license.is_some() {
447        existing.license = incoming.license;
448    }
449    if incoming.source.is_some() {
450        existing.source = incoming.source;
451    }
452    if incoming.install_source.is_some() {
453        existing.install_source = incoming.install_source;
454    }
455    existing.tags = merge_tags(existing.tags.iter().cloned(), incoming.tags);
456}
457
458fn merge_tags(
459    left: impl IntoIterator<Item = String>,
460    right: impl IntoIterator<Item = String>,
461) -> Vec<String> {
462    let mut tags = BTreeSet::new();
463    for tag in left.into_iter().chain(right) {
464        let trimmed = tag.trim();
465        if !trimmed.is_empty() {
466            tags.insert(trimmed.to_string());
467        }
468    }
469    tags.into_iter().collect()
470}
471
472async fn fetch_npm_entries(client: &Client, limit: usize) -> Result<Vec<ExtensionIndexEntry>> {
473    let query =
474        url::form_urlencoded::byte_serialize(DEFAULT_NPM_QUERY.as_bytes()).collect::<String>();
475    let size = limit.clamp(1, DEFAULT_REMOTE_LIMIT);
476    let url = format!("https://registry.npmjs.org/-/v1/search?text={query}&size={size}");
477    let response = client
478        .get(&url)
479        .timeout(REMOTE_REQUEST_TIMEOUT)
480        .send()
481        .await?;
482    let status = response.status();
483    let body = response.text().await?;
484    if status != 200 {
485        return Err(Error::api(format!(
486            "npm extension search failed with status {status}"
487        )));
488    }
489
490    parse_npm_search_entries(&body)
491}
492
493async fn fetch_github_entries(client: &Client, limit: usize) -> Result<Vec<ExtensionIndexEntry>> {
494    let query =
495        url::form_urlencoded::byte_serialize(DEFAULT_GITHUB_QUERY.as_bytes()).collect::<String>();
496    let per_page = limit.clamp(1, DEFAULT_REMOTE_LIMIT);
497    let url = format!(
498        "https://api.github.com/search/repositories?q={query}&sort=updated&order=desc&per_page={per_page}"
499    );
500    let response = client
501        .get(&url)
502        .timeout(REMOTE_REQUEST_TIMEOUT)
503        .header("Accept", "application/vnd.github+json")
504        .send()
505        .await?;
506    let status = response.status();
507    let body = response.text().await?;
508    if status != 200 {
509        return Err(Error::api(format!(
510            "GitHub extension search failed with status {status}"
511        )));
512    }
513
514    parse_github_search_entries(&body)
515}
516
517fn parse_npm_search_entries(body: &str) -> Result<Vec<ExtensionIndexEntry>> {
518    #[derive(Debug, Deserialize)]
519    struct NpmSearchResponse {
520        #[serde(default)]
521        objects: Vec<NpmSearchObject>,
522    }
523
524    #[derive(Debug, Deserialize)]
525    struct NpmSearchObject {
526        package: NpmPackage,
527    }
528
529    #[derive(Debug, Deserialize)]
530    #[serde(rename_all = "camelCase")]
531    struct NpmPackage {
532        name: String,
533        #[serde(default)]
534        version: Option<String>,
535        #[serde(default)]
536        description: Option<String>,
537        #[serde(default)]
538        keywords: Vec<String>,
539        #[serde(default)]
540        license: Option<String>,
541        #[serde(default)]
542        links: NpmLinks,
543    }
544
545    #[derive(Debug, Default, Deserialize)]
546    struct NpmLinks {
547        #[serde(default)]
548        npm: Option<String>,
549    }
550
551    let parsed: NpmSearchResponse = serde_json::from_str(body)
552        .map_err(|err| Error::api(format!("npm search response parse error: {err}")))?;
553
554    let mut entries = Vec::with_capacity(parsed.objects.len());
555    for object in parsed.objects {
556        let package = object.package;
557        let version = package.version.as_deref().and_then(non_empty);
558        let install_spec = version.as_ref().map_or_else(
559            || package.name.clone(),
560            |ver| format!("{}@{ver}", package.name),
561        );
562        let license = normalize_license(package.license.as_deref());
563        let description = package.description.as_deref().and_then(non_empty);
564        let tags = merge_tags(
565            vec!["npm".to_string(), "extension".to_string()],
566            package
567                .keywords
568                .into_iter()
569                .map(|keyword| keyword.to_ascii_lowercase()),
570        );
571
572        entries.push(ExtensionIndexEntry {
573            id: format!("npm/{}", package.name),
574            name: package.name.clone(),
575            description,
576            tags,
577            license,
578            source: Some(ExtensionIndexSource::Npm {
579                package: package.name.clone(),
580                version,
581                url: package.links.npm.clone(),
582            }),
583            install_source: Some(format!("npm:{install_spec}")),
584        });
585    }
586
587    Ok(entries)
588}
589
590fn parse_github_search_entries(body: &str) -> Result<Vec<ExtensionIndexEntry>> {
591    #[derive(Debug, Deserialize)]
592    struct GitHubSearchResponse {
593        #[serde(default)]
594        items: Vec<GitHubRepo>,
595    }
596
597    #[derive(Debug, Deserialize)]
598    struct GitHubRepo {
599        full_name: String,
600        name: String,
601        #[serde(default)]
602        description: Option<String>,
603        #[serde(default)]
604        topics: Vec<String>,
605        #[serde(default)]
606        license: Option<GitHubLicense>,
607    }
608
609    #[derive(Debug, Deserialize)]
610    struct GitHubLicense {
611        #[serde(default)]
612        spdx_id: Option<String>,
613    }
614
615    let parsed: GitHubSearchResponse = serde_json::from_str(body)
616        .map_err(|err| Error::api(format!("GitHub search response parse error: {err}")))?;
617
618    let mut entries = Vec::with_capacity(parsed.items.len());
619    for item in parsed.items {
620        let spdx_id = item.license.and_then(|value| value.spdx_id);
621        let license = spdx_id
622            .as_deref()
623            .and_then(non_empty)
624            .filter(|value| !value.eq_ignore_ascii_case("NOASSERTION"));
625        let tags = merge_tags(
626            vec!["git".to_string(), "extension".to_string()],
627            item.topics
628                .into_iter()
629                .map(|topic| topic.to_ascii_lowercase()),
630        );
631
632        entries.push(ExtensionIndexEntry {
633            id: format!("git/{}", item.full_name),
634            name: item.name,
635            description: item.description.as_deref().and_then(non_empty),
636            tags,
637            license,
638            source: Some(ExtensionIndexSource::Git {
639                repo: item.full_name.clone(),
640                path: None,
641                r#ref: None,
642            }),
643            install_source: Some(format!("git:{}", item.full_name)),
644        });
645    }
646
647    Ok(entries)
648}
649
650fn normalize_license(value: Option<&str>) -> Option<String> {
651    value
652        .and_then(non_empty)
653        .filter(|license| !license.eq_ignore_ascii_case("unknown"))
654}
655
656fn non_empty(value: &str) -> Option<String> {
657    let trimmed = value.trim();
658    if trimmed.is_empty() {
659        None
660    } else {
661        Some(trimmed.to_string())
662    }
663}
664
665// ============================================================================
666// Seed Index (Bundled)
667// ============================================================================
668
669const SEED_ARTIFACT_PROVENANCE_JSON: &str =
670    include_str!("../docs/extension-artifact-provenance.json");
671
672#[derive(Debug, Deserialize)]
673struct ArtifactProvenance {
674    #[serde(rename = "$schema")]
675    _schema: Option<String>,
676    #[serde(default)]
677    generated: Option<String>,
678    #[serde(default)]
679    items: Vec<ArtifactProvenanceItem>,
680}
681
682#[derive(Debug, Deserialize)]
683struct ArtifactProvenanceItem {
684    id: String,
685    name: String,
686    #[serde(default)]
687    license: Option<String>,
688    source: ArtifactProvenanceSource,
689}
690
691#[derive(Debug, Deserialize)]
692#[serde(tag = "type", rename_all = "lowercase")]
693enum ArtifactProvenanceSource {
694    Git {
695        repo: String,
696        #[serde(default)]
697        path: Option<String>,
698    },
699    Npm {
700        package: String,
701        #[serde(default)]
702        version: Option<String>,
703        #[serde(default)]
704        url: Option<String>,
705    },
706    Url {
707        url: String,
708    },
709}
710
711pub fn seed_index() -> Result<ExtensionIndex> {
712    let provenance: ArtifactProvenance = serde_json::from_str(SEED_ARTIFACT_PROVENANCE_JSON)?;
713    let generated_at = provenance.generated;
714
715    let mut entries = Vec::with_capacity(provenance.items.len());
716    for item in provenance.items {
717        let license = item
718            .license
719            .clone()
720            .filter(|value| !value.trim().is_empty() && !value.eq_ignore_ascii_case("unknown"));
721
722        let (source, install_source, tags) = match &item.source {
723            ArtifactProvenanceSource::Npm { version, url, .. } => {
724                let spec = version.as_ref().map_or_else(
725                    || item.name.clone(),
726                    |v| format!("{}@{}", item.name, v.trim()),
727                );
728                (
729                    Some(ExtensionIndexSource::Npm {
730                        package: item.name.clone(),
731                        version: version.clone(),
732                        url: url.clone(),
733                    }),
734                    Some(format!("npm:{spec}")),
735                    vec!["npm".to_string(), "extension".to_string()],
736                )
737            }
738            ArtifactProvenanceSource::Git { repo, path } => {
739                let install_source = path.as_ref().map_or_else(
740                    || Some(format!("git:{repo}")),
741                    |_| None, // deep path entries typically require a package filter
742                );
743                (
744                    Some(ExtensionIndexSource::Git {
745                        repo: repo.clone(),
746                        path: path.clone(),
747                        r#ref: None,
748                    }),
749                    install_source,
750                    vec!["git".to_string(), "extension".to_string()],
751                )
752            }
753            ArtifactProvenanceSource::Url { url } => (
754                Some(ExtensionIndexSource::Url { url: url.clone() }),
755                None,
756                vec!["url".to_string(), "extension".to_string()],
757            ),
758        };
759
760        entries.push(ExtensionIndexEntry {
761            id: item.id,
762            name: item.name,
763            description: None,
764            tags,
765            license,
766            source,
767            install_source,
768        });
769    }
770
771    entries.sort_by_key(|entry| entry.id.to_ascii_lowercase());
772
773    Ok(ExtensionIndex {
774        schema: EXTENSION_INDEX_SCHEMA.to_string(),
775        version: EXTENSION_INDEX_VERSION,
776        generated_at,
777        last_refreshed_at: None,
778        entries,
779    })
780}
781
782#[cfg(test)]
783mod tests {
784    use super::{
785        EXTENSION_INDEX_SCHEMA, EXTENSION_INDEX_VERSION, ExtensionIndex, ExtensionIndexEntry,
786        ExtensionIndexSource, ExtensionIndexStore, merge_entries, merge_tags, non_empty,
787        normalize_license, parse_github_search_entries, parse_npm_search_entries, score_entry,
788        seed_index,
789    };
790    use chrono::{Duration as ChronoDuration, Utc};
791    use std::time::Duration;
792
793    #[test]
794    fn seed_index_parses_and_has_entries() {
795        let index = seed_index().expect("seed index");
796        assert!(index.entries.len() > 10);
797    }
798
799    #[test]
800    fn resolve_install_source_requires_unique_match() {
801        let index = ExtensionIndex {
802            schema: super::EXTENSION_INDEX_SCHEMA.to_string(),
803            version: super::EXTENSION_INDEX_VERSION,
804            generated_at: None,
805            last_refreshed_at: None,
806            entries: vec![
807                ExtensionIndexEntry {
808                    id: "npm/foo".to_string(),
809                    name: "foo".to_string(),
810                    description: None,
811                    tags: Vec::new(),
812                    license: None,
813                    source: None,
814                    install_source: Some("npm:foo@1.0.0".to_string()),
815                },
816                ExtensionIndexEntry {
817                    id: "npm/foo-alt".to_string(),
818                    name: "foo".to_string(),
819                    description: None,
820                    tags: Vec::new(),
821                    license: None,
822                    source: None,
823                    install_source: Some("npm:foo@2.0.0".to_string()),
824                },
825            ],
826        };
827
828        assert_eq!(index.resolve_install_source("foo"), None);
829        assert_eq!(
830            index.resolve_install_source("npm/foo"),
831            Some("npm:foo@1.0.0".to_string())
832        );
833    }
834
835    #[test]
836    fn store_resolve_install_source_falls_back_to_seed() {
837        let store = ExtensionIndexStore::new(std::path::PathBuf::from("this-file-does-not-exist"));
838        let resolved = store.resolve_install_source("checkpoint-pi");
839        // The exact seed contents can change; the important part is "no error".
840        assert!(resolved.is_ok());
841    }
842
843    #[test]
844    fn parse_npm_search_entries_maps_install_sources() {
845        let body = r#"{
846          "objects": [
847            {
848              "package": {
849                "name": "checkpoint-pi",
850                "version": "1.2.3",
851                "description": "checkpoint helper",
852                "keywords": ["pi-extension", "checkpoint"],
853                "license": "MIT",
854                "links": { "npm": "https://www.npmjs.com/package/checkpoint-pi" }
855              }
856            }
857          ]
858        }"#;
859
860        let entries = parse_npm_search_entries(body).expect("parse npm search");
861        assert_eq!(entries.len(), 1);
862        let entry = &entries[0];
863        assert_eq!(entry.id, "npm/checkpoint-pi");
864        assert_eq!(
865            entry.install_source.as_deref(),
866            Some("npm:checkpoint-pi@1.2.3")
867        );
868        assert!(entry.tags.iter().any(|tag| tag == "checkpoint"));
869    }
870
871    #[test]
872    fn parse_github_search_entries_maps_git_install_sources() {
873        let body = r#"{
874          "items": [
875            {
876              "full_name": "org/pi-cool-ext",
877              "name": "pi-cool-ext",
878              "description": "cool extension",
879              "topics": ["pi-extension", "automation"],
880              "license": { "spdx_id": "Apache-2.0" }
881            }
882          ]
883        }"#;
884
885        let entries = parse_github_search_entries(body).expect("parse github search");
886        assert_eq!(entries.len(), 1);
887        let entry = &entries[0];
888        assert_eq!(entry.id, "git/org/pi-cool-ext");
889        assert_eq!(entry.install_source.as_deref(), Some("git:org/pi-cool-ext"));
890        assert!(entry.tags.iter().any(|tag| tag == "automation"));
891        assert!(matches!(
892            entry.source,
893            Some(ExtensionIndexSource::Git { .. })
894        ));
895    }
896
897    #[test]
898    fn merge_entries_preserves_existing_fields_when_incoming_missing() {
899        let existing = vec![ExtensionIndexEntry {
900            id: "npm/checkpoint-pi".to_string(),
901            name: "checkpoint-pi".to_string(),
902            description: Some("existing description".to_string()),
903            tags: vec!["npm".to_string()],
904            license: Some("MIT".to_string()),
905            source: Some(ExtensionIndexSource::Npm {
906                package: "checkpoint-pi".to_string(),
907                version: Some("1.0.0".to_string()),
908                url: None,
909            }),
910            install_source: Some("npm:checkpoint-pi@1.0.0".to_string()),
911        }];
912        let incoming = vec![ExtensionIndexEntry {
913            id: "npm/checkpoint-pi".to_string(),
914            name: "checkpoint-pi".to_string(),
915            description: None,
916            tags: vec!["extension".to_string()],
917            license: None,
918            source: None,
919            install_source: None,
920        }];
921
922        let merged = merge_entries(existing, incoming, Vec::new());
923        assert_eq!(merged.len(), 1);
924        let entry = &merged[0];
925        assert_eq!(entry.description.as_deref(), Some("existing description"));
926        assert_eq!(
927            entry.install_source.as_deref(),
928            Some("npm:checkpoint-pi@1.0.0")
929        );
930        assert!(entry.tags.iter().any(|tag| tag == "npm"));
931        assert!(entry.tags.iter().any(|tag| tag == "extension"));
932    }
933
934    // ── new_empty ──────────────────────────────────────────────────────
935
936    #[test]
937    fn new_empty_has_correct_schema_and_version() {
938        let index = ExtensionIndex::new_empty();
939        assert_eq!(index.schema, EXTENSION_INDEX_SCHEMA);
940        assert_eq!(index.version, EXTENSION_INDEX_VERSION);
941        assert!(index.generated_at.is_some());
942        assert!(index.last_refreshed_at.is_none());
943        assert!(index.entries.is_empty());
944    }
945
946    // ── validate ───────────────────────────────────────────────────────
947
948    #[test]
949    fn validate_accepts_correct_schema_and_version() {
950        let index = ExtensionIndex::new_empty();
951        assert!(index.validate().is_ok());
952    }
953
954    #[test]
955    fn validate_rejects_wrong_schema() {
956        let mut index = ExtensionIndex::new_empty();
957        index.schema = "wrong.schema".to_string();
958        let err = index.validate().unwrap_err();
959        assert!(
960            err.to_string()
961                .contains("Unsupported extension index schema")
962        );
963    }
964
965    #[test]
966    fn validate_rejects_wrong_version() {
967        let mut index = ExtensionIndex::new_empty();
968        index.version = 999;
969        let err = index.validate().unwrap_err();
970        assert!(
971            err.to_string()
972                .contains("Unsupported extension index version")
973        );
974    }
975
976    // ── is_stale ───────────────────────────────────────────────────────
977
978    #[test]
979    fn is_stale_true_when_no_timestamp() {
980        let index = ExtensionIndex::new_empty();
981        assert!(index.is_stale(Utc::now(), Duration::from_secs(3600)));
982    }
983
984    #[test]
985    fn is_stale_true_when_invalid_timestamp() {
986        let mut index = ExtensionIndex::new_empty();
987        index.last_refreshed_at = Some("not-a-date".to_string());
988        assert!(index.is_stale(Utc::now(), Duration::from_secs(3600)));
989    }
990
991    #[test]
992    fn is_stale_false_when_fresh() {
993        let mut index = ExtensionIndex::new_empty();
994        index.last_refreshed_at = Some(Utc::now().to_rfc3339());
995        assert!(!index.is_stale(Utc::now(), Duration::from_secs(3600)));
996    }
997
998    #[test]
999    fn is_stale_true_when_expired() {
1000        let mut index = ExtensionIndex::new_empty();
1001        let old = Utc::now() - ChronoDuration::hours(2);
1002        index.last_refreshed_at = Some(old.to_rfc3339());
1003        assert!(index.is_stale(Utc::now(), Duration::from_secs(3600)));
1004    }
1005
1006    // ── search ─────────────────────────────────────────────────────────
1007
1008    fn test_entry(id: &str, name: &str, desc: Option<&str>, tags: &[&str]) -> ExtensionIndexEntry {
1009        ExtensionIndexEntry {
1010            id: id.to_string(),
1011            name: name.to_string(),
1012            description: desc.map(std::string::ToString::to_string),
1013            tags: tags.iter().map(std::string::ToString::to_string).collect(),
1014            license: None,
1015            source: None,
1016            install_source: Some(format!("npm:{name}")),
1017        }
1018    }
1019
1020    fn test_index(entries: Vec<ExtensionIndexEntry>) -> ExtensionIndex {
1021        ExtensionIndex {
1022            schema: EXTENSION_INDEX_SCHEMA.to_string(),
1023            version: EXTENSION_INDEX_VERSION,
1024            generated_at: None,
1025            last_refreshed_at: None,
1026            entries,
1027        }
1028    }
1029
1030    #[test]
1031    fn search_empty_query_returns_nothing() {
1032        let index = test_index(vec![test_entry("npm/foo", "foo", None, &[])]);
1033        assert!(index.search("", 10).is_empty());
1034        assert!(index.search("   ", 10).is_empty());
1035    }
1036
1037    #[test]
1038    fn search_zero_limit_returns_nothing() {
1039        let index = test_index(vec![test_entry("npm/foo", "foo", None, &[])]);
1040        assert!(index.search("foo", 0).is_empty());
1041    }
1042
1043    #[test]
1044    fn search_matches_by_name() {
1045        let index = test_index(vec![
1046            test_entry("npm/alpha", "alpha", None, &[]),
1047            test_entry("npm/beta", "beta", None, &[]),
1048        ]);
1049        let hits = index.search("alpha", 10);
1050        assert_eq!(hits.len(), 1);
1051        assert_eq!(hits[0].entry.name, "alpha");
1052    }
1053
1054    #[test]
1055    fn search_matches_by_description() {
1056        let index = test_index(vec![test_entry(
1057            "npm/foo",
1058            "foo",
1059            Some("checkpoint helper"),
1060            &[],
1061        )]);
1062        let hits = index.search("checkpoint", 10);
1063        assert_eq!(hits.len(), 1);
1064    }
1065
1066    #[test]
1067    fn search_matches_by_tag() {
1068        let index = test_index(vec![test_entry("npm/foo", "foo", None, &["automation"])]);
1069        let hits = index.search("automation", 10);
1070        assert_eq!(hits.len(), 1);
1071    }
1072
1073    #[test]
1074    fn search_respects_limit() {
1075        let index = test_index(vec![
1076            test_entry("npm/foo-a", "foo-a", None, &[]),
1077            test_entry("npm/foo-b", "foo-b", None, &[]),
1078            test_entry("npm/foo-c", "foo-c", None, &[]),
1079        ]);
1080        let hits = index.search("foo", 2);
1081        assert_eq!(hits.len(), 2);
1082    }
1083
1084    #[test]
1085    fn search_ranks_name_higher_than_description() {
1086        let index = test_index(vec![
1087            test_entry("npm/other", "other", Some("checkpoint tool"), &[]),
1088            test_entry("npm/checkpoint", "checkpoint", None, &[]),
1089        ]);
1090        let hits = index.search("checkpoint", 10);
1091        assert_eq!(hits.len(), 2);
1092        // Name match (300) beats description match (60)
1093        assert_eq!(hits[0].entry.name, "checkpoint");
1094    }
1095
1096    // ── score_entry ────────────────────────────────────────────────────
1097
1098    #[test]
1099    fn score_entry_name_match_highest() {
1100        let entry = test_entry("npm/foo", "foo", Some("bar"), &["baz"]);
1101        assert_eq!(score_entry(&entry, &["foo".to_string()]), 300 + 120);
1102        // name(300) + id contains "foo" too (120)
1103    }
1104
1105    #[test]
1106    fn score_entry_no_match_returns_zero() {
1107        let entry = test_entry("npm/foo", "foo", None, &[]);
1108        assert_eq!(score_entry(&entry, &["zzz".to_string()]), 0);
1109    }
1110
1111    #[test]
1112    fn score_entry_tag_match() {
1113        let entry = test_entry("npm/bar", "bar", None, &["automation"]);
1114        let score = score_entry(&entry, &["automation".to_string()]);
1115        assert_eq!(score, 180);
1116    }
1117
1118    #[test]
1119    fn score_entry_multiple_tokens_accumulate() {
1120        let entry = test_entry("npm/foo", "foo", Some("great tool"), &["utility"]);
1121        let score = score_entry(&entry, &["foo".to_string(), "great".to_string()]);
1122        // "foo": name(300) + id(120) = 420
1123        // "great": description(60) = 60
1124        assert_eq!(score, 480);
1125    }
1126
1127    // ── merge_tags ─────────────────────────────────────────────────────
1128
1129    #[test]
1130    fn merge_tags_deduplicates() {
1131        let result = merge_tags(
1132            vec!["a".to_string(), "b".to_string()],
1133            vec!["b".to_string(), "c".to_string()],
1134        );
1135        assert_eq!(result, vec!["a", "b", "c"]);
1136    }
1137
1138    #[test]
1139    fn merge_tags_trims_and_skips_empty() {
1140        let result = merge_tags(
1141            vec!["  a  ".to_string(), String::new()],
1142            vec!["  ".to_string(), "b".to_string()],
1143        );
1144        assert_eq!(result, vec!["a", "b"]);
1145    }
1146
1147    // ── normalize_license ──────────────────────────────────────────────
1148
1149    #[test]
1150    fn normalize_license_returns_none_for_none() {
1151        assert_eq!(normalize_license(None), None);
1152    }
1153
1154    #[test]
1155    fn normalize_license_returns_none_for_empty() {
1156        assert_eq!(normalize_license(Some("")), None);
1157        assert_eq!(normalize_license(Some("  ")), None);
1158    }
1159
1160    #[test]
1161    fn normalize_license_returns_none_for_unknown() {
1162        assert_eq!(normalize_license(Some("unknown")), None);
1163        assert_eq!(normalize_license(Some("UNKNOWN")), None);
1164    }
1165
1166    #[test]
1167    fn normalize_license_returns_value_for_valid() {
1168        assert_eq!(normalize_license(Some("MIT")), Some("MIT".to_string()));
1169        assert_eq!(
1170            normalize_license(Some("Apache-2.0")),
1171            Some("Apache-2.0".to_string())
1172        );
1173    }
1174
1175    // ── non_empty ──────────────────────────────────────────────────────
1176
1177    #[test]
1178    fn non_empty_returns_none_for_empty_and_whitespace() {
1179        assert_eq!(non_empty(""), None);
1180        assert_eq!(non_empty("   "), None);
1181    }
1182
1183    #[test]
1184    fn non_empty_trims_and_returns() {
1185        assert_eq!(non_empty("  hello  "), Some("hello".to_string()));
1186    }
1187
1188    // ── resolve_install_source edge cases ──────────────────────────────
1189
1190    #[test]
1191    fn resolve_install_source_empty_query_returns_none() {
1192        let index = test_index(vec![test_entry("npm/foo", "foo", None, &[])]);
1193        assert_eq!(index.resolve_install_source(""), None);
1194        assert_eq!(index.resolve_install_source("   "), None);
1195    }
1196
1197    #[test]
1198    fn resolve_install_source_case_insensitive() {
1199        let index = test_index(vec![ExtensionIndexEntry {
1200            id: "npm/Foo".to_string(),
1201            name: "Foo".to_string(),
1202            description: None,
1203            tags: Vec::new(),
1204            license: None,
1205            source: None,
1206            install_source: Some("npm:Foo".to_string()),
1207        }]);
1208        assert_eq!(
1209            index.resolve_install_source("foo"),
1210            Some("npm:Foo".to_string())
1211        );
1212    }
1213
1214    #[test]
1215    fn resolve_install_source_npm_package_name() {
1216        let index = test_index(vec![ExtensionIndexEntry {
1217            id: "npm/my-ext".to_string(),
1218            name: "my-ext".to_string(),
1219            description: None,
1220            tags: Vec::new(),
1221            license: None,
1222            source: Some(ExtensionIndexSource::Npm {
1223                package: "my-ext".to_string(),
1224                version: Some("1.0.0".to_string()),
1225                url: None,
1226            }),
1227            install_source: Some("npm:my-ext@1.0.0".to_string()),
1228        }]);
1229        assert_eq!(
1230            index.resolve_install_source("my-ext"),
1231            Some("npm:my-ext@1.0.0".to_string())
1232        );
1233    }
1234
1235    #[test]
1236    fn resolve_install_source_no_install_source_returns_none() {
1237        let index = test_index(vec![ExtensionIndexEntry {
1238            id: "npm/foo".to_string(),
1239            name: "foo".to_string(),
1240            description: None,
1241            tags: Vec::new(),
1242            license: None,
1243            source: None,
1244            install_source: None,
1245        }]);
1246        assert_eq!(index.resolve_install_source("foo"), None);
1247    }
1248
1249    // ── ExtensionIndexStore save/load roundtrip ────────────────────────
1250
1251    #[test]
1252    fn store_save_load_roundtrip() {
1253        let temp_dir = tempfile::tempdir().expect("tempdir");
1254        let path = temp_dir.path().join("index.json");
1255        let store = ExtensionIndexStore::new(path);
1256
1257        let mut index = ExtensionIndex::new_empty();
1258        index
1259            .entries
1260            .push(test_entry("npm/rt", "rt", Some("roundtrip"), &["test"]));
1261        store.save(&index).expect("save");
1262
1263        let loaded = store.load().expect("load").expect("some");
1264        assert_eq!(loaded.entries.len(), 1);
1265        assert_eq!(loaded.entries[0].name, "rt");
1266        assert_eq!(loaded.entries[0].description.as_deref(), Some("roundtrip"));
1267    }
1268
1269    #[test]
1270    fn store_load_nonexistent_returns_none() {
1271        let store = ExtensionIndexStore::new(std::path::PathBuf::from("/nonexistent/path.json"));
1272        assert!(store.load().expect("load").is_none());
1273    }
1274
1275    #[test]
1276    fn store_load_or_seed_falls_back_on_missing() {
1277        let store = ExtensionIndexStore::new(std::path::PathBuf::from("/nonexistent/path.json"));
1278        let index = store.load_or_seed().expect("load_or_seed");
1279        assert!(!index.entries.is_empty());
1280    }
1281
1282    // ── parse edge cases ───────────────────────────────────────────────
1283
1284    #[test]
1285    fn parse_npm_no_version_omits_at_in_install_source() {
1286        let body = r#"{
1287          "objects": [{
1288            "package": {
1289              "name": "bare-ext",
1290              "keywords": [],
1291              "links": {}
1292            }
1293          }]
1294        }"#;
1295        let entries = parse_npm_search_entries(body).expect("parse");
1296        assert_eq!(entries[0].install_source.as_deref(), Some("npm:bare-ext"));
1297    }
1298
1299    #[test]
1300    fn parse_npm_empty_objects_returns_empty() {
1301        let body = r#"{ "objects": [] }"#;
1302        let entries = parse_npm_search_entries(body).expect("parse");
1303        assert!(entries.is_empty());
1304    }
1305
1306    #[test]
1307    fn parse_github_noassertion_license_filtered_out() {
1308        let body = r#"{
1309          "items": [{
1310            "full_name": "org/ext",
1311            "name": "ext",
1312            "topics": [],
1313            "license": { "spdx_id": "NOASSERTION" }
1314          }]
1315        }"#;
1316        let entries = parse_github_search_entries(body).expect("parse");
1317        assert!(entries[0].license.is_none());
1318    }
1319
1320    #[test]
1321    fn parse_github_null_license_ok() {
1322        let body = r#"{
1323          "items": [{
1324            "full_name": "org/ext2",
1325            "name": "ext2",
1326            "topics": []
1327          }]
1328        }"#;
1329        let entries = parse_github_search_entries(body).expect("parse");
1330        assert!(entries[0].license.is_none());
1331    }
1332
1333    // ── merge_entries adds new entries ──────────────────────────────────
1334
1335    #[test]
1336    fn merge_entries_adds_new_and_deduplicates() {
1337        let existing = vec![test_entry("npm/a", "a", None, &[])];
1338        let npm = vec![test_entry("npm/b", "b", None, &[])];
1339        let git = vec![test_entry("git/c", "c", None, &[])];
1340        let merged = merge_entries(existing, npm, git);
1341        assert_eq!(merged.len(), 3);
1342        // Sorted by id
1343        assert_eq!(merged[0].id, "git/c");
1344        assert_eq!(merged[1].id, "npm/a");
1345        assert_eq!(merged[2].id, "npm/b");
1346    }
1347
1348    #[test]
1349    fn merge_entries_case_insensitive_dedup() {
1350        let existing = vec![test_entry("npm/Foo", "Foo", Some("old"), &[])];
1351        let npm = vec![test_entry("npm/foo", "foo", Some("new"), &[])];
1352        let merged = merge_entries(existing, npm, Vec::new());
1353        assert_eq!(merged.len(), 1);
1354        // Incoming overwrites description
1355        assert_eq!(merged[0].description.as_deref(), Some("new"));
1356    }
1357
1358    // ── serde roundtrip ────────────────────────────────────────────────
1359
1360    #[test]
1361    fn extension_index_serde_roundtrip() {
1362        let index = test_index(vec![test_entry("npm/x", "x", Some("desc"), &["tag1"])]);
1363        let json = serde_json::to_string(&index).expect("serialize");
1364        let deserialized: ExtensionIndex = serde_json::from_str(&json).expect("deserialize");
1365        assert_eq!(deserialized.entries.len(), 1);
1366        assert_eq!(deserialized.entries[0].name, "x");
1367    }
1368
1369    #[test]
1370    fn extension_index_entry_source_variants_serialize() {
1371        let npm = ExtensionIndexSource::Npm {
1372            package: "p".to_string(),
1373            version: Some("1.0".to_string()),
1374            url: None,
1375        };
1376        let git = ExtensionIndexSource::Git {
1377            repo: "org/r".to_string(),
1378            path: None,
1379            r#ref: None,
1380        };
1381        let url = ExtensionIndexSource::Url {
1382            url: "https://example.com".to_string(),
1383        };
1384
1385        for source in [npm, git, url] {
1386            let json = serde_json::to_string(&source).expect("serialize");
1387            let _: ExtensionIndexSource = serde_json::from_str(&json).expect("deserialize");
1388        }
1389    }
1390
1391    // ── ExtensionIndexRefreshStats default ─────────────────────────────
1392
1393    #[test]
1394    fn refresh_stats_default_all_zero() {
1395        let stats = super::ExtensionIndexRefreshStats::default();
1396        assert_eq!(stats.npm_entries, 0);
1397        assert_eq!(stats.github_entries, 0);
1398        assert_eq!(stats.merged_entries, 0);
1399        assert!(!stats.refreshed);
1400    }
1401
1402    // ── store path accessor ────────────────────────────────────────────
1403
1404    #[test]
1405    fn store_path_returns_configured_path() {
1406        let store = ExtensionIndexStore::new(std::path::PathBuf::from("/custom/path.json"));
1407        assert_eq!(store.path().to_str().unwrap(), "/custom/path.json");
1408    }
1409
1410    mod proptest_extension_index {
1411        use super::*;
1412        use proptest::prelude::*;
1413
1414        fn make_entry(id: &str, name: &str) -> ExtensionIndexEntry {
1415            ExtensionIndexEntry {
1416                id: id.to_string(),
1417                name: name.to_string(),
1418                description: None,
1419                tags: Vec::new(),
1420                license: None,
1421                source: None,
1422                install_source: None,
1423            }
1424        }
1425
1426        proptest! {
1427            /// `non_empty` returns None for whitespace-only strings.
1428            #[test]
1429            fn non_empty_whitespace(ws in "[ \\t\\n]{0,10}") {
1430                assert!(non_empty(&ws).is_none());
1431            }
1432
1433            /// `non_empty` returns trimmed value for non-empty strings.
1434            #[test]
1435            fn non_empty_trims(s in "[a-z]{1,10}", ws in "[ \\t]{0,3}") {
1436                let padded = format!("{ws}{s}{ws}");
1437                let result = non_empty(&padded).unwrap();
1438                assert_eq!(result, s);
1439            }
1440
1441            /// `normalize_license` filters "unknown" (case-insensitive).
1442            #[test]
1443            fn normalize_license_filters_unknown(
1444                case_idx in 0..3usize
1445            ) {
1446                let variants = ["unknown", "UNKNOWN", "Unknown"];
1447                assert!(normalize_license(Some(variants[case_idx])).is_none());
1448            }
1449
1450            /// `normalize_license(None)` returns None.
1451            #[test]
1452            fn normalize_license_none(_dummy in 0..1u8) {
1453                assert!(normalize_license(None).is_none());
1454            }
1455
1456            /// `normalize_license` passes through valid licenses.
1457            #[test]
1458            fn normalize_license_passthrough(s in "[A-Z]{3,10}") {
1459                if !s.eq_ignore_ascii_case("unknown") {
1460                    assert!(normalize_license(Some(&s)).is_some());
1461                }
1462            }
1463
1464            /// `score_entry` is zero for empty token list.
1465            #[test]
1466            fn score_empty_tokens(name in "[a-z]{1,10}") {
1467                let entry = make_entry("id", &name);
1468                assert_eq!(score_entry(&entry, &[]), 0);
1469            }
1470
1471            /// `score_entry` is non-negative.
1472            #[test]
1473            fn score_non_negative(
1474                name in "[a-z]{1,10}",
1475                token in "[a-z]{1,5}"
1476            ) {
1477                let entry = make_entry("id", &name);
1478                assert!(score_entry(&entry, &[token]) >= 0);
1479            }
1480
1481            /// `score_entry` is case-insensitive.
1482            #[test]
1483            fn score_case_insensitive(name in "[a-z]{1,10}") {
1484                // score_entry expects pre-lowered tokens (search() lowercases them).
1485                // The case-insensitivity is on the *entry* fields, not tokens.
1486                let lower_entry = make_entry("id", &name);
1487                let upper_entry = make_entry("id", &name.to_uppercase());
1488                let token = vec![name];
1489                assert_eq!(score_entry(&lower_entry, &token), score_entry(&upper_entry, &token));
1490            }
1491
1492            /// Name match gives 300 points per token.
1493            #[test]
1494            fn score_name_match(name in "[a-z]{3,8}") {
1495                let entry = make_entry("different-id", &name);
1496                let score = score_entry(&entry, &[name]);
1497                // At minimum 300 for name match (might also match id/description/tags)
1498                assert!(score >= 300);
1499            }
1500
1501            /// `merge_tags` deduplicates.
1502            #[test]
1503            fn merge_tags_dedup(tag in "[a-z]{1,10}") {
1504                let result = merge_tags(
1505                    vec![tag.clone(), tag.clone()],
1506                    vec![tag.clone()],
1507                );
1508                assert_eq!(result.len(), 1);
1509                assert_eq!(result[0], tag);
1510            }
1511
1512            /// `merge_tags` filters empty/whitespace.
1513            #[test]
1514            fn merge_tags_filters_empty(tag in "[a-z]{1,10}") {
1515                let result = merge_tags(
1516                    vec![tag, String::new(), "  ".to_string()],
1517                    vec![],
1518                );
1519                assert_eq!(result.len(), 1);
1520            }
1521
1522            /// `merge_tags` result is sorted (BTreeSet).
1523            #[test]
1524            fn merge_tags_sorted(
1525                a in "[a-z]{1,5}",
1526                b in "[a-z]{1,5}",
1527                c in "[a-z]{1,5}"
1528            ) {
1529                let result = merge_tags(vec![c, a], vec![b]);
1530                for w in result.windows(2) {
1531                    assert!(w[0] <= w[1]);
1532                }
1533            }
1534
1535            /// `merge_tags` preserves all unique tags from both sides.
1536            #[test]
1537            fn merge_tags_preserves(
1538                left in prop::collection::vec("[a-z]{1,5}", 0..5),
1539                right in prop::collection::vec("[a-z]{1,5}", 0..5)
1540            ) {
1541                let result = merge_tags(left.clone(), right.clone());
1542                // Every non-empty tag from either side should be in result
1543                for tag in left.iter().chain(right.iter()) {
1544                    let trimmed = tag.trim();
1545                    if !trimmed.is_empty() {
1546                        assert!(
1547                            result.contains(&trimmed.to_string()),
1548                            "missing tag: {trimmed}"
1549                        );
1550                    }
1551                }
1552            }
1553
1554            /// `merge_entries` keeps casefolded ids unique and sorted.
1555            #[test]
1556            fn merge_entries_unique_sorted_casefold_ids(
1557                existing in prop::collection::vec(("[A-Za-z]{1,8}", "[a-z]{1,8}"), 0..10),
1558                npm in prop::collection::vec(("[A-Za-z]{1,8}", "[a-z]{1,8}"), 0..10),
1559                git in prop::collection::vec(("[A-Za-z]{1,8}", "[a-z]{1,8}"), 0..10)
1560            ) {
1561                let to_entries = |rows: Vec<(String, String)>, prefix: &str| {
1562                    rows.into_iter()
1563                        .map(|(id, name)| make_entry(&format!("{prefix}/{id}"), &name))
1564                        .collect::<Vec<_>>()
1565                };
1566                let merged = merge_entries(
1567                    to_entries(existing, "npm"),
1568                    to_entries(npm, "npm"),
1569                    to_entries(git, "git"),
1570                );
1571
1572                let lower_ids = merged
1573                    .iter()
1574                    .map(|entry| entry.id.to_ascii_lowercase())
1575                    .collect::<Vec<_>>();
1576                let mut sorted = lower_ids.clone();
1577                sorted.sort();
1578                assert_eq!(lower_ids, sorted);
1579
1580                let unique = lower_ids.iter().cloned().collect::<std::collections::BTreeSet<_>>();
1581                assert_eq!(unique.len(), lower_ids.len());
1582            }
1583
1584            /// `search` output is bounded by limit and sorted by non-increasing score.
1585            #[test]
1586            fn search_bounded_and_score_sorted(
1587                rows in prop::collection::vec(("[a-z]{1,8}", "[a-z]{1,8}", prop::option::of("[a-z ]{1,20}")), 0..16),
1588                query in "[a-z]{1,6}",
1589                limit in 0usize..16usize
1590            ) {
1591                let entries = rows
1592                    .into_iter()
1593                    .map(|(id, name, description)| ExtensionIndexEntry {
1594                        id: format!("npm/{id}"),
1595                        name,
1596                        description: description.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()),
1597                        tags: vec!["tag".to_string()],
1598                        license: None,
1599                        source: None,
1600                        install_source: Some(format!("npm:{id}")),
1601                    })
1602                    .collect::<Vec<_>>();
1603                let index = ExtensionIndex {
1604                    schema: EXTENSION_INDEX_SCHEMA.to_string(),
1605                    version: EXTENSION_INDEX_VERSION,
1606                    generated_at: None,
1607                    last_refreshed_at: None,
1608                    entries,
1609                };
1610
1611                let hits = index.search(&query, limit);
1612                assert!(hits.len() <= limit);
1613                assert!(hits.windows(2).all(|pair| pair[0].score >= pair[1].score));
1614                assert!(hits.iter().all(|hit| hit.score > 0));
1615            }
1616
1617            /// Name ambiguity must fail-open to `None`; exact id remains resolvable.
1618            #[test]
1619            fn resolve_install_source_ambiguous_name_none_exact_id_some(
1620                name in "[a-z]{1,10}",
1621                left in "[a-z]{1,8}",
1622                right in "[a-z]{1,8}"
1623            ) {
1624                prop_assume!(!left.eq_ignore_ascii_case(&right));
1625
1626                let left_id = format!("npm/{left}");
1627                let right_id = format!("npm/{right}");
1628                let left_install = format!("npm:{left}@1.0.0");
1629                let right_install = format!("npm:{right}@2.0.0");
1630
1631                let index = ExtensionIndex {
1632                    schema: EXTENSION_INDEX_SCHEMA.to_string(),
1633                    version: EXTENSION_INDEX_VERSION,
1634                    generated_at: None,
1635                    last_refreshed_at: None,
1636                    entries: vec![
1637                        ExtensionIndexEntry {
1638                            id: left_id.clone(),
1639                            name: name.clone(),
1640                            description: None,
1641                            tags: Vec::new(),
1642                            license: None,
1643                            source: Some(ExtensionIndexSource::Npm {
1644                                package: left,
1645                                version: Some("1.0.0".to_string()),
1646                                url: None,
1647                            }),
1648                            install_source: Some(left_install.clone()),
1649                        },
1650                        ExtensionIndexEntry {
1651                            id: right_id.clone(),
1652                            name: name.clone(),
1653                            description: None,
1654                            tags: Vec::new(),
1655                            license: None,
1656                            source: Some(ExtensionIndexSource::Npm {
1657                                package: right,
1658                                version: Some("2.0.0".to_string()),
1659                                url: None,
1660                            }),
1661                            install_source: Some(right_install.clone()),
1662                        },
1663                    ],
1664                };
1665
1666                assert_eq!(index.resolve_install_source(&name), None);
1667                assert_eq!(index.resolve_install_source(&left_id), Some(left_install));
1668                assert_eq!(index.resolve_install_source(&right_id), Some(right_install));
1669            }
1670
1671            /// `ExtensionIndexSource` serde roundtrip for Npm variant.
1672            #[test]
1673            fn source_npm_serde(pkg in "[a-z]{1,10}", ver in "[0-9]\\.[0-9]\\.[0-9]") {
1674                let source = ExtensionIndexSource::Npm {
1675                    package: pkg,
1676                    version: Some(ver),
1677                    url: None,
1678                };
1679                let json = serde_json::to_string(&source).unwrap();
1680                let _: ExtensionIndexSource = serde_json::from_str(&json).unwrap();
1681            }
1682
1683            /// `ExtensionIndexSource` serde roundtrip for Git variant.
1684            #[test]
1685            fn source_git_serde(repo in "[a-z]{1,10}/[a-z]{1,10}") {
1686                let source = ExtensionIndexSource::Git {
1687                    repo,
1688                    path: None,
1689                    r#ref: None,
1690                };
1691                let json = serde_json::to_string(&source).unwrap();
1692                let _: ExtensionIndexSource = serde_json::from_str(&json).unwrap();
1693            }
1694
1695            /// `ExtensionIndexEntry` serde roundtrip.
1696            #[test]
1697            fn entry_serde_roundtrip(
1698                id in "[a-z]{1,10}",
1699                name in "[a-z]{1,10}"
1700            ) {
1701                let entry = make_entry(&id, &name);
1702                let json = serde_json::to_string(&entry).unwrap();
1703                let back: ExtensionIndexEntry = serde_json::from_str(&json).unwrap();
1704                assert_eq!(back.id, id);
1705                assert_eq!(back.name, name);
1706            }
1707        }
1708    }
1709}