diff --git a/README.md b/README.md index 40d4e49..3c07000 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Automatically detect the Target Framework Moniker (TFM) for Business Central and - **VS Marketplace** (the AL Language extension) - **NuGet DevTools** (Microsoft's AL development tools package) - **Local compiler path** (a directory containing the AL compiler DLLs) +- **Download and extract** ALCops analyzer DLLs for a detected TFM - JSON output on stdout, logs on stderr (pipe-friendly) - Zero configuration required - Usable as a CLI or as a Node.js library @@ -41,9 +42,19 @@ Commands: detect-tfm marketplace [channel] Detect TFM from VS Marketplace (default: current) detect-tfm nuget-devtools [version] Detect TFM from NuGet DevTools (default: latest) detect-tfm compiler-path Detect TFM from a local compiler directory - -Options: - --help Show this help message + download --output Download and extract ALCops analyzers + +Download options: + --output Required. Directory to extract analyzer DLLs into + --detect-using TFM detection input (URL, path, channel, or version) + --tfm Explicit TFM (skips auto-detection) + --version ALCops package version (default: latest) + --detect-from Force detection source (bc-artifact, marketplace, + nuget-devtools, compiler-path) + +Global options: + --verbose Enable debug-level logging + --help Show this help message ``` ### Examples @@ -72,6 +83,65 @@ Detect from a local compiler directory: alcops detect-tfm compiler-path ./path/to/compiler ``` +### Download Command + +The `download` command combines TFM detection with analyzer extraction in a single step. + +Download analyzers with auto-detected TFM from the latest NuGet DevTools: + +```bash +alcops download --detect-using latest --output ./analyzers +``` + +Download analyzers with auto-detected TFM from a BC artifact URL: + +```bash +alcops download --detect-using "https://bcartifacts.azureedge.net/sandbox/26.0.12345.0/us" --output ./analyzers +``` + +Download analyzers with auto-detected TFM from a local compiler directory: + +```bash +alcops download --detect-using ./path/to/compiler --output ./analyzers +``` + +Download analyzers with an explicit TFM (skips detection): + +```bash +alcops download --tfm net8.0 --output ./analyzers +``` + +Download a specific ALCops version: + +```bash +alcops download --detect-using latest --output ./analyzers --version 1.0.0 +``` + +Force a detection source with `--detect-from` (overrides smart routing): + +```bash +alcops download --detect-using 18.0.2293710 --detect-from marketplace --output ./analyzers +``` + +Enable verbose logging for debugging: + +```bash +alcops download --detect-using latest --output ./analyzers --verbose +``` + +#### Download Output + +```json +{ + "version": "1.0.0", + "tfm": "net8.0", + "outputDir": "/absolute/path/to/analyzers", + "files": [ + "/absolute/path/to/analyzers/ALCops.Analyzers.dll" + ] +} +``` + ### Output All commands write JSON to stdout: @@ -104,6 +174,13 @@ echo "Building with TFM: $TFM" run: echo "Target framework is ${{ steps.tfm.outputs.tfm }}" ``` +Or use the `download` command for a one-step solution: + +```yaml +- name: Download ALCops Analyzers + run: npx @alcops/core download --detect-using latest --output ./analyzers --verbose +``` + ## Programmatic API The package exports all detection functions for use as a library: diff --git a/src/cli.ts b/src/cli.ts index 1639dbd..977f076 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,8 +3,8 @@ import { detectFromBCArtifact } from './detectors/bc-artifact'; import { detectFromMarketplace } from './detectors/marketplace'; import { detectFromNuGetDevTools } from './detectors/nuget-devtools'; import { detectFromCompilerPath } from './detectors/compiler-path'; - -const logger = createConsoleLogger(); +import { executeDownload } from './download/download-command'; +import type { DetectSource } from './resolve-detect-source'; function usage(): void { process.stderr.write(` @@ -15,9 +15,19 @@ Commands: detect-tfm marketplace [channel] Detect TFM from VS Marketplace (default: current) detect-tfm nuget-devtools [version] Detect TFM from NuGet DevTools (default: latest) detect-tfm compiler-path Detect TFM from a local compiler directory + download --output Download and extract ALCops analyzers + +Download options: + --output Required. Directory to extract analyzer DLLs into + --detect-using TFM detection input (URL, path, channel, or version) + --tfm Explicit TFM (skips auto-detection) + --version ALCops package version (default: latest) + --detect-from Force detection source (bc-artifact, marketplace, + nuget-devtools, compiler-path) -Options: - --help Show this help message +Global options: + --verbose Enable debug-level logging + --help Show this help message Output: JSON on stdout, logs on stderr. `); @@ -25,16 +35,19 @@ Output: JSON on stdout, logs on stderr. async function main(): Promise { const args = process.argv.slice(2); + const verbose = args.includes('--verbose'); + const filteredArgs = args.filter((a) => a !== '--verbose'); + const logger = createConsoleLogger(verbose); - if (args.length === 0 || args.includes('--help')) { + if (filteredArgs.length === 0 || filteredArgs.includes('--help')) { usage(); - process.exit(args.includes('--help') ? 0 : 1); + process.exit(filteredArgs.includes('--help') ? 0 : 1); } - const command = args[0]; + const command = filteredArgs[0]; if (command === 'detect-tfm') { - const subcommand = args[1]; + const subcommand = filteredArgs[1]; if (!subcommand) { process.stderr.write('Error: detect-tfm requires a subcommand\n'); usage(); @@ -44,7 +57,7 @@ async function main(): Promise { let result; switch (subcommand) { case 'bc-artifact': { - const url = args[2]; + const url = filteredArgs[2]; if (!url) { process.stderr.write('Error: bc-artifact requires a URL argument\n'); process.exit(1); @@ -53,17 +66,17 @@ async function main(): Promise { break; } case 'marketplace': { - const channel = args[2] || 'current'; + const channel = filteredArgs[2] || 'current'; result = await detectFromMarketplace(channel, logger); break; } case 'nuget-devtools': { - const version = args[2] || 'latest'; + const version = filteredArgs[2] || 'latest'; result = await detectFromNuGetDevTools(version, logger); break; } case 'compiler-path': { - const dir = args[2]; + const dir = filteredArgs[2]; if (!dir) { process.stderr.write('Error: compiler-path requires a directory argument\n'); process.exit(1); @@ -77,6 +90,34 @@ async function main(): Promise { process.exit(1); } + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); + } else if (command === 'download') { + const downloadArgs = filteredArgs.slice(1); + const outputDir = getFlagValue(downloadArgs, '--output'); + const tfm = getFlagValue(downloadArgs, '--tfm'); + const version = getFlagValue(downloadArgs, '--version'); + const detectFrom = getFlagValue(downloadArgs, '--detect-from') as DetectSource | undefined; + const detectSource = getFlagValue(downloadArgs, '--detect-using'); + + if (!outputDir) { + process.stderr.write('Error: --output is required for the download command\n'); + usage(); + process.exit(1); + } + + if (detectFrom && !isValidDetectSource(detectFrom)) { + process.stderr.write( + `Error: Invalid --detect-from value: ${detectFrom}. ` + + `Valid values: bc-artifact, marketplace, nuget-devtools, compiler-path\n`, + ); + process.exit(1); + } + + const result = await executeDownload( + { detectSource, tfm, version, detectFrom, outputDir }, + logger, + ); + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); } else { process.stderr.write(`Error: Unknown command: ${command}\n`); @@ -85,6 +126,18 @@ async function main(): Promise { } } +function getFlagValue(args: string[], flag: string): string | undefined { + const idx = args.indexOf(flag); + if (idx === -1 || idx + 1 >= args.length) return undefined; + return args[idx + 1]; +} + +const VALID_DETECT_SOURCES = new Set(['bc-artifact', 'marketplace', 'nuget-devtools', 'compiler-path']); + +function isValidDetectSource(value: string): value is DetectSource { + return VALID_DETECT_SOURCES.has(value); +} + main().catch((err) => { process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`); process.exit(1); diff --git a/src/download/download-command.ts b/src/download/download-command.ts new file mode 100644 index 0000000..07a6dc3 --- /dev/null +++ b/src/download/download-command.ts @@ -0,0 +1,122 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { Logger, nullLogger } from '../logger'; +import { resolveDetectSource, DetectSource } from '../resolve-detect-source'; +import { detectFromBCArtifact } from '../detectors/bc-artifact'; +import { detectFromMarketplace } from '../detectors/marketplace'; +import { detectFromNuGetDevTools } from '../detectors/nuget-devtools'; +import { detectFromCompilerPath } from '../detectors/compiler-path'; +import { resolveVersion, downloadPackage } from './nuget-api'; +import { extractAnalyzers } from './nuget-extractor'; + +export interface DownloadOptions { + /** TFM detection input (URL, path, channel keyword, or version number) */ + detectSource?: string; + /** Explicit TFM, skips detection */ + tfm?: string; + /** ALCops package version to download (default: 'latest') */ + version?: string; + /** Force a specific detection source, overrides smart routing */ + detectFrom?: DetectSource; + /** Output directory for extracted analyzer DLLs */ + outputDir: string; +} + +export interface DownloadResult { + version: string; + tfm: string; + outputDir: string; + files: string[]; +} + +/** + * Full download pipeline: detect TFM → resolve ALCops version → download → extract → cleanup. + */ +export async function executeDownload( + options: DownloadOptions, + logger: Logger = nullLogger, +): Promise { + const tfm = await resolveTfm(options, logger); + const alcopsVersion = options.version ?? 'latest'; + + logger.info(`Target TFM: ${tfm}`); + logger.info(`ALCops version: ${alcopsVersion}`); + + const resolved = await resolveVersion(alcopsVersion, logger); + logger.info(`Resolved ALCops version: ${resolved.version}`); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'alcops-')); + try { + const nupkgPath = await downloadPackage( + resolved.version, + tmpDir, + logger, + resolved.packageContentUrl, + ); + + const { files, actualTfm } = await extractAnalyzers( + nupkgPath, + tfm, + options.outputDir, + logger, + ); + + logger.info(`Download complete. ${files.length} analyzer(s) extracted.`); + + return { + version: resolved.version, + tfm: actualTfm, + outputDir: path.resolve(options.outputDir), + files: files.map((f) => path.resolve(f)), + }; + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + logger.debug(`Cleaned up temp directory: ${tmpDir}`); + } +} + +async function resolveTfm(options: DownloadOptions, logger: Logger): Promise { + if (options.tfm) { + logger.info(`Using explicit TFM: ${options.tfm}`); + return options.tfm; + } + + if (!options.detectSource) { + throw new Error( + 'Either --tfm or --detect-using is required. ' + + 'Run "alcops download --help" for usage.', + ); + } + + const detectSource = options.detectFrom + ? { source: options.detectFrom, input: options.detectSource } + : await resolveDetectSource(options.detectSource, logger); + + logger.info(`Detection source: ${detectSource.source} (input: ${detectSource.input})`); + + return detectTfm(detectSource.source, detectSource.input, logger); +} + +async function detectTfm(source: DetectSource, input: string, logger: Logger): Promise { + switch (source) { + case 'bc-artifact': { + const result = await detectFromBCArtifact(input, logger); + return result.tfm; + } + case 'marketplace': { + const result = await detectFromMarketplace(input, logger); + return result.tfm; + } + case 'nuget-devtools': { + const result = await detectFromNuGetDevTools(input, logger); + return result.tfm; + } + case 'compiler-path': { + const result = await detectFromCompilerPath(input, logger); + return result.tfm; + } + default: + throw new Error(`Unknown detection source: ${source as string}`); + } +} diff --git a/src/index.ts b/src/index.ts index e3e2c7e..43f6abc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,12 @@ export { detectFromCompilerPath, findDllFiles } from './detectors/compiler-path' // Download export { resolveVersion, downloadPackage, getDownloadUrl } from './download/nuget-api'; export { extractAnalyzers, findMatchingTfmFolder } from './download/nuget-extractor'; +export { executeDownload } from './download/download-command'; +export type { DownloadOptions, DownloadResult } from './download/download-command'; + +// Detection source resolution +export { resolveDetectSource, resolveVersionSource } from './resolve-detect-source'; +export type { DetectSource, ResolvedDetectSource } from './resolve-detect-source'; // Shared utilities export { getUserAgent } from './user-agent'; diff --git a/src/resolve-detect-source.ts b/src/resolve-detect-source.ts new file mode 100644 index 0000000..1a8a80c --- /dev/null +++ b/src/resolve-detect-source.ts @@ -0,0 +1,105 @@ +import { Logger, nullLogger } from './logger'; +import { queryNuGetRegistration } from './nuget-registration'; +import { queryMarketplace } from './detectors/marketplace'; +import { getUserAgent } from './user-agent'; +import packageJson from '../package.json'; + +const DEFAULT_USER_AGENT = getUserAgent('ALCops', packageJson.version); + +export type DetectSource = 'bc-artifact' | 'compiler-path' | 'nuget-devtools' | 'marketplace'; + +export interface ResolvedDetectSource { + source: DetectSource; + input: string; +} + +/** + * Classify a raw CLI input string into a detection source using heuristics. + * + * - Starts with http:// or https:// → bc-artifact + * - Exists as a local filesystem path → compiler-path + * - Channel keyword (latest, prerelease, current) → nuget-devtools (default) + * - Specific version string → resolved via API (NuGet first, marketplace fallback) + */ +export async function resolveDetectSource( + input: string, + logger: Logger = nullLogger, + userAgent?: string, +): Promise { + const ua = userAgent ?? DEFAULT_USER_AGENT; + + if (isUrl(input)) { + logger.debug(`Input classified as URL → bc-artifact`); + return { source: 'bc-artifact', input }; + } + + if (isChannelKeyword(input)) { + logger.debug(`Input classified as channel keyword → nuget-devtools`); + return { source: 'nuget-devtools', input }; + } + + // For version strings, resolve via API calls + return resolveVersionSource(input, logger, ua); +} + +/** + * Determine whether a version string belongs to NuGet DevTools or VS Marketplace. + * Queries NuGet registry first (faster), then marketplace as fallback. + */ +export async function resolveVersionSource( + version: string, + logger: Logger = nullLogger, + userAgent?: string, +): Promise { + const ua = userAgent ?? DEFAULT_USER_AGENT; + + logger.info(`Resolving detection source for version: ${version}`); + + // Try NuGet DevTools first (faster API) + logger.debug('Checking NuGet DevTools registry...'); + try { + const devToolsPackage = 'microsoft.dynamics.businesscentral.development.tools'; + const nugetVersions = await queryNuGetRegistration(devToolsPackage, ua, logger); + const found = nugetVersions.some( + (v) => v.version.toLowerCase() === version.toLowerCase(), + ); + if (found) { + logger.info(`Version '${version}' found in NuGet DevTools`); + return { source: 'nuget-devtools', input: version }; + } + logger.debug(`Version '${version}' not found in NuGet DevTools`); + } catch (err) { + logger.warn(`NuGet DevTools lookup failed: ${err instanceof Error ? err.message : String(err)}`); + } + + // Fallback to VS Marketplace + logger.debug('Checking VS Marketplace...'); + try { + const marketplaceVersions = await queryMarketplace(logger); + const found = marketplaceVersions.some( + (v) => v.version === version, + ); + if (found) { + logger.info(`Version '${version}' found in VS Marketplace`); + return { source: 'marketplace', input: version }; + } + logger.debug(`Version '${version}' not found in VS Marketplace`); + } catch (err) { + logger.warn(`VS Marketplace lookup failed: ${err instanceof Error ? err.message : String(err)}`); + } + + throw new Error( + `Version '${version}' not found in NuGet DevTools or VS Marketplace. ` + + `Use --detect-from to specify the source explicitly, or use --tfm to skip detection.`, + ); +} + +function isUrl(input: string): boolean { + return input.startsWith('http://') || input.startsWith('https://'); +} + +const CHANNEL_KEYWORDS = new Set(['latest', 'prerelease', 'current']); + +function isChannelKeyword(input: string): boolean { + return CHANNEL_KEYWORDS.has(input.toLowerCase()); +} diff --git a/tests/download/download-command.test.ts b/tests/download/download-command.test.ts new file mode 100644 index 0000000..e86492c --- /dev/null +++ b/tests/download/download-command.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { zipSync } from 'fflate'; + +// Mock all external network dependencies +vi.mock('../../src/nuget-registration', () => ({ + queryNuGetRegistration: vi.fn(), +})); + +vi.mock('../../src/detectors/marketplace', () => ({ + queryMarketplace: vi.fn(), + detectFromMarketplace: vi.fn(), + resolveExtensionVersion: vi.fn(), +})); + +vi.mock('../../src/detectors/bc-artifact', () => ({ + detectFromBCArtifact: vi.fn(), +})); + +vi.mock('../../src/detectors/nuget-devtools', () => ({ + detectFromNuGetDevTools: vi.fn(), +})); + +vi.mock('../../src/detectors/compiler-path', () => ({ + detectFromCompilerPath: vi.fn(), +})); + +vi.mock('../../src/http-client', () => ({ + httpsGetBuffer: vi.fn(), + httpsGetJson: vi.fn(), +})); + +import { queryNuGetRegistration } from '../../src/nuget-registration'; +import { detectFromBCArtifact } from '../../src/detectors/bc-artifact'; +import { detectFromMarketplace } from '../../src/detectors/marketplace'; +import { detectFromNuGetDevTools } from '../../src/detectors/nuget-devtools'; +import { detectFromCompilerPath } from '../../src/detectors/compiler-path'; +import { httpsGetBuffer } from '../../src/http-client'; +import { executeDownload } from '../../src/download/download-command'; +import type { RegistrationVersion } from '../../src/types'; + +const mockDetectBCArtifact = detectFromBCArtifact as ReturnType; +const mockDetectMarketplace = detectFromMarketplace as ReturnType; +const mockDetectNuGetDevTools = detectFromNuGetDevTools as ReturnType; +const mockDetectCompilerPath = detectFromCompilerPath as ReturnType; +const mockQueryRegistration = queryNuGetRegistration as ReturnType; +const mockHttpsGetBuffer = httpsGetBuffer as ReturnType; + +let tmpDir: string; + +beforeEach(() => { + vi.resetAllMocks(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'download-cmd-test-')); + + // Default: resolve ALCops version as 'latest' with a single stable version + mockQueryRegistration.mockResolvedValue([ + { version: '1.0.0', listed: true, packageContent: 'https://example.com/pkg.nupkg' }, + ] satisfies RegistrationVersion[]); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function createFakeNupkgBuffer(tfm: string): Buffer { + const fakeDll = new Uint8Array([0x4d, 0x5a, 0x90, 0x00]); + const entries: Record = { + [`lib/${tfm}/TestAnalyzer.dll`]: fakeDll, + }; + const zipped = zipSync(entries); + return Buffer.from(zipped); +} + +describe('executeDownload', () => { + it('downloads and extracts with explicit --tfm', async () => { + const nupkgBuffer = createFakeNupkgBuffer('net8.0'); + mockHttpsGetBuffer.mockResolvedValue(nupkgBuffer); + + const outputDir = path.join(tmpDir, 'output'); + const result = await executeDownload({ + tfm: 'net8.0', + outputDir, + }); + + expect(result.version).toBe('1.0.0'); + expect(result.tfm).toBe('net8.0'); + expect(result.files).toHaveLength(1); + expect(result.files[0]).toContain('TestAnalyzer.dll'); + expect(fs.existsSync(result.files[0])).toBe(true); + }); + + it('auto-detects TFM from URL (bc-artifact)', async () => { + mockDetectBCArtifact.mockResolvedValue({ tfm: 'net8.0', source: 'bc-artifact' }); + const nupkgBuffer = createFakeNupkgBuffer('net8.0'); + mockHttpsGetBuffer.mockResolvedValue(nupkgBuffer); + + const outputDir = path.join(tmpDir, 'output'); + const result = await executeDownload({ + detectSource: 'https://bcartifacts/onprem/24.0/us', + outputDir, + }); + + expect(mockDetectBCArtifact).toHaveBeenCalledWith( + 'https://bcartifacts/onprem/24.0/us', + expect.anything(), + ); + expect(result.tfm).toBe('net8.0'); + }); + + it('auto-detects TFM from channel keyword (nuget-devtools)', async () => { + mockDetectNuGetDevTools.mockResolvedValue({ tfm: 'net8.0', source: 'nuget-devtools' }); + const nupkgBuffer = createFakeNupkgBuffer('net8.0'); + mockHttpsGetBuffer.mockResolvedValue(nupkgBuffer); + + const outputDir = path.join(tmpDir, 'output'); + const result = await executeDownload({ + detectSource: 'latest', + outputDir, + }); + + expect(mockDetectNuGetDevTools).toHaveBeenCalledWith('latest', expect.anything()); + expect(result.tfm).toBe('net8.0'); + }); + + it('respects --detect-from override', async () => { + mockDetectMarketplace.mockResolvedValue({ tfm: 'net8.0', source: 'marketplace' }); + const nupkgBuffer = createFakeNupkgBuffer('net8.0'); + mockHttpsGetBuffer.mockResolvedValue(nupkgBuffer); + + const outputDir = path.join(tmpDir, 'output'); + const result = await executeDownload({ + detectSource: 'https://bcartifacts/some-url', + detectFrom: 'marketplace', + outputDir, + }); + + // Should use marketplace despite URL input + expect(mockDetectMarketplace).toHaveBeenCalled(); + expect(mockDetectBCArtifact).not.toHaveBeenCalled(); + expect(result.tfm).toBe('net8.0'); + }); + + it('throws when neither --tfm nor --detect-using is provided', async () => { + const outputDir = path.join(tmpDir, 'output'); + await expect(executeDownload({ outputDir })).rejects.toThrow( + /Either --tfm or --detect-using is required/, + ); + }); + + it('cleans up temp directory even on extraction error', async () => { + const nupkgBuffer = createFakeNupkgBuffer('net6.0'); + mockHttpsGetBuffer.mockResolvedValue(nupkgBuffer); + + // Count alcops temp dirs before + const before = fs.readdirSync(os.tmpdir()).filter((d) => d.startsWith('alcops-')).length; + + const outputDir = path.join(tmpDir, 'output'); + await expect( + executeDownload({ tfm: 'netstandard1.0', outputDir }), + ).rejects.toThrow(); + + // No new temp dirs should remain + const after = fs.readdirSync(os.tmpdir()).filter((d) => d.startsWith('alcops-')).length; + expect(after).toBe(before); + }); +}); diff --git a/tests/resolve-detect-source.test.ts b/tests/resolve-detect-source.test.ts new file mode 100644 index 0000000..bc8113f --- /dev/null +++ b/tests/resolve-detect-source.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock nuget-registration module +vi.mock('../src/nuget-registration', () => ({ + queryNuGetRegistration: vi.fn(), +})); + +// Mock marketplace module +vi.mock('../src/detectors/marketplace', () => ({ + queryMarketplace: vi.fn(), +})); + +import { queryNuGetRegistration } from '../src/nuget-registration'; +import { queryMarketplace } from '../src/detectors/marketplace'; +import { + resolveDetectSource, + resolveVersionSource, +} from '../src/resolve-detect-source'; +import type { RegistrationVersion } from '../src/types'; + +const mockQueryRegistration = queryNuGetRegistration as ReturnType; +const mockQueryMarketplace = queryMarketplace as ReturnType; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe('resolveDetectSource', () => { + it('classifies HTTP URLs as bc-artifact', async () => { + const result = await resolveDetectSource('https://bcartifacts/onprem/24.0/us'); + expect(result).toEqual({ + source: 'bc-artifact', + input: 'https://bcartifacts/onprem/24.0/us', + }); + }); + + it('classifies HTTPS URLs as bc-artifact', async () => { + const result = await resolveDetectSource('http://example.com/artifact'); + expect(result).toEqual({ + source: 'bc-artifact', + input: 'http://example.com/artifact', + }); + }); + + it('classifies "latest" as nuget-devtools channel', async () => { + const result = await resolveDetectSource('latest'); + expect(result).toEqual({ source: 'nuget-devtools', input: 'latest' }); + }); + + it('classifies "prerelease" as nuget-devtools channel', async () => { + const result = await resolveDetectSource('prerelease'); + expect(result).toEqual({ source: 'nuget-devtools', input: 'prerelease' }); + }); + + it('classifies "current" as nuget-devtools channel', async () => { + const result = await resolveDetectSource('current'); + expect(result).toEqual({ source: 'nuget-devtools', input: 'current' }); + }); + + it('resolves a NuGet DevTools version via API', async () => { + mockQueryRegistration.mockResolvedValue([ + { version: '18.0.35.14686', listed: true, packageContent: '' }, + ] satisfies RegistrationVersion[]); + + const result = await resolveDetectSource('18.0.35.14686'); + expect(result).toEqual({ source: 'nuget-devtools', input: '18.0.35.14686' }); + expect(mockQueryRegistration).toHaveBeenCalled(); + expect(mockQueryMarketplace).not.toHaveBeenCalled(); + }); + + it('falls back to marketplace when version not in NuGet', async () => { + mockQueryRegistration.mockResolvedValue([ + { version: '17.0.34.45391', listed: true, packageContent: '' }, + ] satisfies RegistrationVersion[]); + + mockQueryMarketplace.mockResolvedValue([ + { version: '18.0.2293710', vsixUrl: 'https://example.com', isPreRelease: false }, + ]); + + const result = await resolveDetectSource('18.0.2293710'); + expect(result).toEqual({ source: 'marketplace', input: '18.0.2293710' }); + expect(mockQueryRegistration).toHaveBeenCalled(); + expect(mockQueryMarketplace).toHaveBeenCalled(); + }); + + it('throws when version not found in either source', async () => { + mockQueryRegistration.mockResolvedValue([]); + mockQueryMarketplace.mockResolvedValue([]); + + await expect(resolveDetectSource('99.99.99')).rejects.toThrow( + /not found in NuGet DevTools or VS Marketplace/, + ); + }); +}); + +describe('resolveVersionSource', () => { + it('finds version in NuGet DevTools without checking marketplace', async () => { + mockQueryRegistration.mockResolvedValue([ + { version: '17.0.34.45391', listed: true, packageContent: '' }, + ] satisfies RegistrationVersion[]); + + const result = await resolveVersionSource('17.0.34.45391'); + expect(result.source).toBe('nuget-devtools'); + expect(mockQueryMarketplace).not.toHaveBeenCalled(); + }); + + it('checks marketplace when NuGet API fails', async () => { + mockQueryRegistration.mockRejectedValue(new Error('Network error')); + mockQueryMarketplace.mockResolvedValue([ + { version: '18.0.2293710', vsixUrl: 'https://example.com', isPreRelease: false }, + ]); + + const result = await resolveVersionSource('18.0.2293710'); + expect(result.source).toBe('marketplace'); + }); + + it('performs case-insensitive match for NuGet versions', async () => { + mockQueryRegistration.mockResolvedValue([ + { version: '18.0.35.14686-Beta', listed: true, packageContent: '' }, + ] satisfies RegistrationVersion[]); + + const result = await resolveVersionSource('18.0.35.14686-beta'); + expect(result.source).toBe('nuget-devtools'); + }); +});