Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
52 changes: 51 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,53 @@ 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. Newest available version with no negative status (by publishedAt)
*
* 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
}

const usable = versions.filter(v => v.status === '')
if (usable.length === 0) return null

usable.sort((a, b) => {
const at = a.publishedAt?.getTime() ?? 0
const bt = b.publishedAt?.getTime() ?? 0
return bt - at
})

return usable[0] ?? 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
2 changes: 2 additions & 0 deletions src/registries/rubygems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface RubyGemsGemResponse {
version?: string
description: string
homepage_uri?: string
documentation_uri?: string
source_code_uri?: string
licenses?: string[]
metadata?: Record<string, unknown>
Expand Down Expand Up @@ -83,6 +84,7 @@ class RubyGemsRegistry implements Registry {
name: data.name,
description: data.description || '',
homepage: data.homepage_uri || '',
documentation: data.documentation_uri || '',
repository,
licenses,
keywords: [],
Expand Down
137 changes: 137 additions & 0 deletions test/unit/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
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' = '', date = '2024-01-01T00:00:00Z'): Version {
return {
number: num,
publishedAt: new Date(date),
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 newest clean version', () => {
const versions = [version('1.0.0', 'yanked'), version('2.0.0', '', '2024-06-01'), version('3.0.0', '', '2024-01-01')]
const result = selectVersion(versions)
expect(result?.number).toBe('2.0.0')
})

it('falls back to latest 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')
})

it('returns newest clean version when fallback is needed', () => {
const versions = [
version('1.0.0', '', '2024-01-01'),
version('2.0.0', '', '2024-09-01'),
version('3.0.0', '', '2024-06-01'),
]
const result = selectVersion(versions, { requested: '9.9.9' })
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