Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ interface Package {
name: string
description: string
homepage: string
documentation: string // docs URL (docs.rs, readthedocs, rubydoc, etc.)
repository: string
licenses: string // SPDX-normalized
keywords: string[]
Expand Down
1 change: 1 addition & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface Package {
name: string
description: string
homepage: string
documentation: string
repository: string
licenses: string
keywords: string[]
Expand Down
43 changes: 42 additions & 1 deletion src/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Package, Version, Dependency, Maintainer } from './core/types.ts'
import type { Package, Version, Dependency, Maintainer, URLBuilder } from './core/types.ts'
import type { Client } from './core/client.ts'
import { createFromPURL } from './core/purl.ts'

Expand Down Expand Up @@ -56,3 +56,44 @@ export async function bulkFetchPackages(
await Promise.all(workers)
return results
}

/**
* Select the best matching version from a list.
*
* Resolution order:
* 1. Exact match for `requested` (non-yanked/deprecated/retracted)
* 2. Exact match for `latest` (non-yanked/deprecated/retracted)
* 3. First available version with no negative status
*
* Returns `null` when no usable version exists.
*/
export function selectVersion(versions: Version[], options?: {
requested?: string
latest?: string
}): Version | null {
const { requested, latest } = options ?? {}

if (requested) {
const exact = versions.find(v => v.number === requested && v.status === '')
if (exact) return exact
}

if (latest) {
const latestV = versions.find(v => v.number === latest && v.status === '')
if (latestV) return latestV
}

return versions.find(v => v.status === '') ?? null
}

/**
* Resolve the best documentation URL for a package.
*
* Fallback chain:
* 1. `package.documentation` (explicit docs URL from registry)
* 2. `package.homepage` (project homepage)
* 3. `urls.documentation()` (ecosystem default, e.g. docs.rs, rubydoc.info)
*/
export function resolveDocsUrl(pkg: Package, urls: URLBuilder, version?: string): string {
return pkg.documentation || pkg.homepage || urls.documentation(pkg.name, version)
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export {
fetchDependenciesFromPURL,
fetchMaintainersFromPURL,
bulkFetchPackages,
selectVersion,
resolveDocsUrl,
} from './helpers.ts'

// Types
Expand Down
17 changes: 15 additions & 2 deletions src/registries/cargo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,21 @@ class CargoRegistry implements Registry {
name: crateData.name,
description: crateData.description || '',
homepage: crateData.homepage || '',
documentation: crateData.documentation || '',
repository: normalizeRepositoryURL(crateData.repository || ''),
licenses: normalizeLicense(data.versions[0]?.license || ''),
keywords: crateData.keywords,
namespace: '',
latestVersion,
metadata: {},
metadata: {
downloads: crateData.downloads,
recentDownloads: crateData.recent_downloads,
categories: crateData.categories,
newestVersion: crateData.newest_version,
defaultVersion: crateData.max_version,
updatedAt: crateData.updated_at,
createdAt: crateData.created_at,
},
}
}
catch (error) {
Expand Down Expand Up @@ -163,7 +172,11 @@ class CargoRegistry implements Registry {
licenses: normalizeLicense(versionData.license),
integrity: `sha256-${versionData.checksum}`,
status,
metadata: {},
metadata: {
crateSize: versionData.crate_size,
features: versionData.features,
downloads: versionData.downloads,
},
})
}

Expand Down
1 change: 1 addition & 0 deletions src/registries/npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ class NpmRegistry implements Registry {
name: data.name,
description: data.description || '',
homepage: data.homepage || '',
documentation: '',
repository: normalizeRepositoryURL(data.repository || ''),
licenses,
keywords: data.keywords || [],
Expand Down
1 change: 1 addition & 0 deletions src/registries/packagist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ class PackagistRegistry implements Registry {
name: packageData.name,
description: packageData.description || '',
homepage: '',
documentation: '',
repository: normalizeRepositoryURL(packageData.repository || latestVersionData.source?.url || ''),
licenses,
keywords: packageData.keywords || [],
Expand Down
1 change: 1 addition & 0 deletions src/registries/pypi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ class PyPIRegistry implements Registry {
name: info.name,
description: info.summary || info.description || '',
homepage: info.project_urls?.['Homepage'] || '',
documentation: info.project_urls?.['Documentation'] || '',
repository,
licenses,
keywords,
Expand Down
1 change: 1 addition & 0 deletions src/registries/rubygems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ class RubyGemsRegistry implements Registry {
name: data.name,
description: data.description || '',
homepage: data.homepage_uri || '',
documentation: (data.metadata?.documentation_uri as string) || '',
repository,
licenses,
keywords: [],
Expand Down
128 changes: 128 additions & 0 deletions test/unit/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, it, expect } from 'vitest'
import type { Package, Version, URLBuilder } from '../../src/core/types.ts'
import { selectVersion, resolveDocsUrl } from '../../src/helpers.ts'

function version(num: string, status: '' | 'yanked' | 'deprecated' | 'retracted' = ''): Version {
return {
number: num,
publishedAt: new Date('2024-01-01T00:00:00Z'),
licenses: '',
integrity: '',
status,
metadata: {},
}
}

function pkg(overrides: Partial<Package> = {}): Package {
return {
name: 'test',
description: '',
homepage: '',
documentation: '',
repository: '',
licenses: '',
keywords: [],
namespace: '',
latestVersion: '1.0.0',
metadata: {},
...overrides,
}
}

const stubUrls: URLBuilder = {
registry: () => '',
download: () => '',
documentation: (name: string, ver?: string) => `https://docs.example.com/${name}/${ver ?? 'latest'}`,
purl: () => '',
}

describe('selectVersion', () => {
it('returns exact requested version when available', () => {
const versions = [version('1.0.0'), version('2.0.0'), version('3.0.0')]
const result = selectVersion(versions, { requested: '2.0.0' })
expect(result?.number).toBe('2.0.0')
})

it('skips yanked requested version and falls back to latest', () => {
const versions = [version('1.0.0'), version('2.0.0', 'yanked'), version('3.0.0')]
const result = selectVersion(versions, { requested: '2.0.0', latest: '3.0.0' })
expect(result?.number).toBe('3.0.0')
})

it('skips deprecated requested version', () => {
const versions = [version('1.0.0'), version('2.0.0', 'deprecated')]
const result = selectVersion(versions, { requested: '2.0.0', latest: '1.0.0' })
expect(result?.number).toBe('1.0.0')
})

it('skips retracted requested version', () => {
const versions = [version('1.0.0'), version('2.0.0', 'retracted')]
const result = selectVersion(versions, { requested: '2.0.0', latest: '1.0.0' })
expect(result?.number).toBe('1.0.0')
})

it('falls back to first clean version when both requested and latest are unusable', () => {
const versions = [
version('1.0.0', 'yanked'),
version('2.0.0', 'yanked'),
version('3.0.0'),
]
const result = selectVersion(versions, { requested: '1.0.0', latest: '2.0.0' })
expect(result?.number).toBe('3.0.0')
})

it('returns null when all versions are yanked', () => {
const versions = [version('1.0.0', 'yanked'), version('2.0.0', 'yanked')]
const result = selectVersion(versions, { requested: '1.0.0' })
expect(result).toBeNull()
})

it('returns null for empty version list', () => {
const result = selectVersion([], { requested: '1.0.0' })
expect(result).toBeNull()
})

it('works without options — returns first clean version', () => {
const versions = [version('1.0.0', 'yanked'), version('2.0.0')]
const result = selectVersion(versions)
expect(result?.number).toBe('2.0.0')
})

it('returns null when requested version does not exist', () => {
const versions = [version('1.0.0'), version('2.0.0')]
const result = selectVersion(versions, { requested: '9.9.9', latest: '2.0.0' })
expect(result?.number).toBe('2.0.0')
})
})

describe('resolveDocsUrl', () => {
it('prefers documentation field when set', () => {
const p = pkg({ documentation: 'https://serde.rs/docs' })
expect(resolveDocsUrl(p, stubUrls)).toBe('https://serde.rs/docs')
})

it('falls back to homepage when documentation is empty', () => {
const p = pkg({ homepage: 'https://serde.rs' })
expect(resolveDocsUrl(p, stubUrls)).toBe('https://serde.rs')
})

it('falls back to URLBuilder when both documentation and homepage are empty', () => {
const p = pkg()
expect(resolveDocsUrl(p, stubUrls, '1.0.0')).toBe('https://docs.example.com/test/1.0.0')
})

it('passes version to URLBuilder fallback', () => {
const p = pkg()
expect(resolveDocsUrl(p, stubUrls, '2.5.0')).toBe('https://docs.example.com/test/2.5.0')
})

it('uses latest when no version specified in URLBuilder fallback', () => {
const p = pkg()
expect(resolveDocsUrl(p, stubUrls)).toBe('https://docs.example.com/test/latest')
})

it('does not fall through when documentation is set even if homepage exists', () => {
const p = pkg({ documentation: 'https://docs.rs/serde', homepage: 'https://serde.rs' })
expect(resolveDocsUrl(p, stubUrls)).toBe('https://docs.rs/serde')
})
})
10 changes: 10 additions & 0 deletions test/unit/registries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,12 @@ describe('Registry Modules', () => {
expect(pkg.latestVersion).toBe('1.0.197')
expect(pkg.keywords).toContain('serialization')
expect(pkg.namespace).toBe('')
expect(pkg.documentation).toBe('https://docs.rs/serde')
expect(pkg.metadata.downloads).toBe(500000000)
expect(pkg.metadata.recentDownloads).toBe(5000000)
expect(pkg.metadata.newestVersion).toBe('1.0.197')
expect(pkg.metadata.updatedAt).toBe('2024-01-15T10:00:00Z')
expect(pkg.metadata.createdAt).toBe('2014-12-20T02:35:07Z')
})

it('should throw NotFoundError for missing cargo crate', async () => {
Expand Down Expand Up @@ -276,6 +282,9 @@ describe('Registry Modules', () => {
expect(versions[0].integrity).toContain('sha256')
expect(versions[1].number).toBe('1.0.197')
expect(versions[1].status).toBe('')
expect(versions[0].metadata.crateSize).toBe(100000)
expect(versions[0].metadata.downloads).toBe(40000000)
expect(versions[1].metadata.crateSize).toBe(100000)
})
})

Expand Down Expand Up @@ -342,6 +351,7 @@ describe('Registry Modules', () => {
expect(pkg.repository).toContain('github.com/psf/requests')
expect(pkg.latestVersion).toBe('2.31.0')
expect(pkg.namespace).toBe('')
expect(pkg.documentation).toBe('https://requests.readthedocs.io')
})

it('should throw NotFoundError for missing pypi package', async () => {
Expand Down