1use 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 #[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 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 pub id: String,
192 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 #[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(¤t) {
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
665const 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, );
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 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 #[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 #[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 #[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 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 assert_eq!(hits[0].entry.name, "checkpoint");
1094 }
1095
1096 #[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 }
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 assert_eq!(score, 480);
1125 }
1126
1127 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 assert_eq!(merged[0].description.as_deref(), Some("new"));
1356 }
1357
1358 #[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 #[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 #[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 #[test]
1429 fn non_empty_whitespace(ws in "[ \\t\\n]{0,10}") {
1430 assert!(non_empty(&ws).is_none());
1431 }
1432
1433 #[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 #[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 #[test]
1452 fn normalize_license_none(_dummy in 0..1u8) {
1453 assert!(normalize_license(None).is_none());
1454 }
1455
1456 #[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 #[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 #[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 #[test]
1483 fn score_case_insensitive(name in "[a-z]{1,10}") {
1484 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 #[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 assert!(score >= 300);
1499 }
1500
1501 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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}