Ver Fonte

Detect installed Rust version (#71)

Loris Leiva há 1 ano atrás
pai
commit
4a55c62e77

+ 5 - 0
.changeset/nine-sheep-wash.md

@@ -0,0 +1,5 @@
+---
+"create-solana-program": patch
+---
+
+Detect installed Rust version

+ 19 - 13
index.ts

@@ -1,20 +1,19 @@
 #!/usr/bin/env node
 
-import * as path from 'node:path';
 import * as fs from 'node:fs';
+import * as path from 'node:path';
 
-import { createOrEmptyTargetDirectory } from './utils/fsHelpers';
-import { getInputs } from './utils/getInputs';
-import { getLanguage } from './utils/getLanguage';
-import { logBanner, logDone, logStep } from './utils/getLogs';
-import { RenderContext, getRenderContext } from './utils/getRenderContext';
+import { createOrEmptyTargetDirectory } from './utils/filesystem';
+import { getInputs } from './utils/inputs';
+import { getLanguage } from './utils/localization';
+import { logBanner, logDone, logStep } from './utils/logs';
+import { RenderContext, getRenderContext } from './utils/renderContext';
 import { renderTemplate } from './utils/renderTemplates';
-import {
-  detectAnchorVersion,
-  detectSolanaVersion,
-  generateKeypair,
-  patchSolanaDependencies,
-} from './utils/solanaCli';
+import { generateKeypair, patchSolanaDependencies } from './utils/solanaCli';
+import { detectAnchorVersion } from './utils/version-anchor';
+import { detectRustVersion } from './utils/version-rust';
+import { detectSolanaVersion } from './utils/version-solana';
+import { Version } from './utils/version-core';
 
 (async function init() {
   logBanner();
@@ -36,8 +35,14 @@ import {
     () => detectSolanaVersion(language)
   );
 
+  // Detect the Rust version.
+  const rustVersionDetected = await logStep(
+    language.infos.detectRustVersion,
+    () => detectRustVersion()
+  );
+
   // Detect the Anchor version.
-  let anchorVersionDetected: string | undefined;
+  let anchorVersionDetected: Version | undefined;
   if (inputs.programFramework === 'anchor') {
     anchorVersionDetected = await logStep(
       language.infos.detectAnchorVersion,
@@ -64,6 +69,7 @@ import {
     inputs,
     programAddress,
     solanaVersionDetected,
+    rustVersionDetected,
     anchorVersionDetected,
   });
 

+ 5 - 3
locales/en-US.json

@@ -50,10 +50,11 @@
   "errors": {
     "anchorCliNotFound": "Command `$command` unavailable. Please install the Anchor CLI.",
     "cannotOverrideDirectory": "Cannot override target directory \"$targetDirectory\". Run with option --force to override.",
-    "invalidSolanaVersion": "Invalid Solana version: $version.",
-    "operationCancelled": "Operation cancelled",
+    "invalidVersion": "Invalid $tool version: $version.",
+    "operationCancelled": "Operation cancelled.",
+    "rustVersionIncompatibleWithSolana": "Solana version $solanaVersion requires at least Rust $minimumRustVersion installed, found Rust $rustVersion.",
     "solanaCliNotFound": "Command `$command` unavailable. Please install the Solana CLI.",
-    "solanaKeygenFailed": "Failed to generate program keypair"
+    "solanaKeygenFailed": "Failed to generate program keypair."
   },
   "defaultToggleOptions": {
     "active": "Yes",
@@ -65,6 +66,7 @@
   },
   "infos": {
     "detectAnchorVersion": "Detect Anchor version",
+    "detectRustVersion": "Detect Rust version",
     "detectSolanaVersion": "Detect Solana version",
     "generateKeypair": "Generate program keypair",
     "scaffold": "Scaffold project in $targetDirectory",

+ 7 - 5
locales/fr-FR.json

@@ -53,10 +53,11 @@
   "errors": {
     "anchorCliNotFound": "Commande `$command` indisponible. Veuillez installer Anchor dans votre terminal.",
     "cannotOverrideDirectory": "Impossible de remplacer le répertoire cible \"$targetDirectory\". Exécutez avec l'option --force pour remplacer.",
-    "invalidSolanaVersion": "Version Solana invalide\u00a0: $version.",
-    "operationCancelled": "Operation annulée",
+    "invalidVersion": "Version de $tool invalide\u00a0: $version.",
+    "operationCancelled": "Operation annulée.",
+    "rustVersionIncompatibleWithSolana": "La version de Solana $solanaVersion nécessite au moins Rust $minimumRustVersion installé, Rust $rustVersion trouvé.",
     "solanaCliNotFound": "Commande `$command` indisponible. Veuillez installer Solana dans votre terminal.",
-    "solanaKeygenFailed": "La génération de la paire de clés a échoué"
+    "solanaKeygenFailed": "La génération de la paire de clés a échoué."
   },
   "defaultToggleOptions": {
     "active": "Oui",
@@ -67,8 +68,9 @@
     "multiselect": "[↑/↓]: Sélectionner / [espace]: Basculer la sélection / [a]: Basculer tout / [entrée]: Valider"
   },
   "infos": {
-    "detectAnchorVersion": "Détect la version d'Anchor",
-    "detectSolanaVersion": "Détect la version de Solana",
+    "detectAnchorVersion": "Détecte la version d'Anchor",
+    "detectRustVersion": "Détecte la version de Rust",
+    "detectSolanaVersion": "Détecte la version de Solana",
     "generateKeypair": "Génére la paire de clés du program",
     "scaffold": "Génére le projet dans $targetDirectory",
     "done": "Terminé. Exécutez maintenant\u00a0:"

+ 2 - 2
template/anchor/base/program/Cargo.toml.njk

@@ -20,5 +20,5 @@ cpi = ["no-entrypoint"]
 idl-build = ["anchor-lang/idl-build"]
 
 [dependencies]
-anchor-lang = "{{ anchorVersion }}"
-solana-program = "~{{ solanaVersionWithoutPatch }}"
+anchor-lang = "{{ anchorVersion.full }}"
+solana-program = "~{{ solanaVersion.withoutPatch }}"

+ 1 - 1
template/base/.github/actions/setup/action.yml.njk

@@ -48,7 +48,7 @@ runs:
       shell: bash
       run: pnpm zx ./scripts/ci/set-env.mjs
 
-{% if solanaVersionWithoutPatch === '2.0' %}
+{% if solanaVersion.withoutPatch === '2.0' %}
     - name: Install Protobuf Compiler (Temporary Workaround for Solana 2.0)
       if: {% raw %}${{ inputs.solana || inputs.rustfmt == 'true' || inputs.clippy == 'true' }}{% endraw %}
       shell: bash

+ 2 - 2
template/base/.github/workflows/main.yml.njk

@@ -8,9 +8,9 @@ on:
 
 env:
   NODE_VERSION: 18
-  SOLANA_VERSION: {{ solanaVersion }}
+  SOLANA_VERSION: {{ solanaVersion.full }}
 {% if programFramework === 'anchor' %}
-  ANCHOR_VERSION: {{ anchorVersion }}
+  ANCHOR_VERSION: {{ anchorVersion.full }}
 {% endif %}
 
 jobs:

+ 3 - 3
template/clients/rust/clients/rust/Cargo.toml.njk

@@ -15,10 +15,10 @@ num-derive = "^0.3"
 num-traits = "^0.2"
 serde = { version = "^1.0", features = ["derive"], optional = true }
 serde_with = { version = "^3.0", optional = true }
-solana-program = "~{{ solanaVersionWithoutPatch }}"
+solana-program = "~{{ solanaVersion.withoutPatch }}"
 thiserror = "^1.0"
 
 [dev-dependencies]
 assert_matches = "1.5.0"
-solana-program-test = "~{{ solanaVersionWithoutPatch }}"
-solana-sdk = "~{{ solanaVersionWithoutPatch }}"
+solana-program-test = "~{{ solanaVersion.withoutPatch }}"
+solana-sdk = "~{{ solanaVersion.withoutPatch }}"

+ 1 - 1
template/shank/base/program/Cargo.toml.njk

@@ -19,5 +19,5 @@ borsh = "^0.10"
 shank = "^0.4.2"
 num-derive = "^0.3"
 num-traits = "^0.2"
-solana-program = "~{{ solanaVersionWithoutPatch }}"
+solana-program = "~{{ solanaVersion.withoutPatch }}"
 thiserror = "^1.0"

+ 0 - 0
utils/runCommands.ts → utils/commands.ts


+ 2 - 2
utils/fsHelpers.ts → utils/filesystem.ts

@@ -1,8 +1,8 @@
 import * as fs from 'node:fs';
 import * as path from 'node:path';
 
-import { Language } from './getLanguage';
-import { logErrorAndExit } from './getLogs';
+import { Language } from './localization';
+import { logErrorAndExit } from './logs';
 
 export function createOrEmptyTargetDirectory(
   language: Language,

+ 0 - 128
utils/getRenderContext.ts

@@ -1,128 +0,0 @@
-import * as path from 'node:path';
-
-import { Client, Inputs, allClients } from './getInputs';
-import { Language } from './getLanguage';
-import {
-  PackageManager,
-  getPackageManager,
-  getPackageManagerCommand,
-} from './getPackageManager';
-import { toMinorSolanaVersion } from './solanaCli';
-
-export type RenderContext = Omit<Inputs, 'programAddress' | 'solanaVersion'> & {
-  anchorVersion: string;
-  clientDirectory: string;
-  clients: Client[];
-  currentDirectory: string;
-  getNpmCommand: (scriptName: string, args?: string) => string;
-  language: Language;
-  programAddress: string;
-  programDirectory: string;
-  packageManager: PackageManager;
-  solanaVersion: string;
-  solanaVersionDetected: string;
-  solanaVersionWithoutPatch: string;
-  targetDirectory: string;
-  templateDirectory: string;
-  toolchain: string;
-};
-
-export function getRenderContext({
-  inputs,
-  language,
-  programAddress,
-  solanaVersionDetected,
-  anchorVersionDetected,
-}: {
-  inputs: Inputs;
-  language: Language;
-  programAddress: string;
-  solanaVersionDetected: string;
-  anchorVersionDetected?: string;
-}): RenderContext {
-  const packageManager = getPackageManager();
-  const clients = allClients.flatMap((client) =>
-    inputs[`${client}Client`] ? [client] : []
-  );
-  const getNpmCommand: RenderContext['getNpmCommand'] = (...args) =>
-    getPackageManagerCommand(packageManager, ...args);
-  const solanaVersion = resolveSolanaVersion(
-    language,
-    inputs.solanaVersion,
-    solanaVersionDetected
-  );
-  const solanaVersionWithoutPatch = toMinorSolanaVersion(
-    language,
-    solanaVersion
-  );
-  const toolchain = getToolchainFromSolanaVersion(solanaVersionWithoutPatch);
-
-  // Directories.
-  const templateDirectory = path.resolve(__dirname, 'template');
-  const currentDirectory = process.cwd();
-  const targetDirectory = path.join(
-    currentDirectory,
-    inputs.targetDirectoryName
-  );
-  const programDirectory = path.join(targetDirectory, 'program');
-  const clientDirectory = path.join(targetDirectory, 'client');
-
-  return {
-    ...inputs,
-    anchorVersion: resolveAnchorVersion(anchorVersionDetected),
-    clientDirectory,
-    clients,
-    currentDirectory,
-    getNpmCommand,
-    language,
-    packageManager,
-    programAddress,
-    programDirectory,
-    solanaVersion,
-    solanaVersionDetected,
-    solanaVersionWithoutPatch,
-    targetDirectory,
-    templateDirectory,
-    toolchain,
-  };
-}
-
-function getToolchainFromSolanaVersion(
-  solanaVersionWithoutPatch: string
-): string {
-  const map: Record<string, string> = {
-    '1.17': '1.75.0',
-    '1.18': '1.75.0',
-    '2.0': '1.75.0',
-  };
-
-  return map[solanaVersionWithoutPatch] ?? '1.75.0';
-}
-
-function resolveSolanaVersion(
-  language: Language,
-  inputVersion: string | undefined,
-  detectedVersion: string
-): string {
-  if (!inputVersion) {
-    return detectedVersion;
-  }
-  if (!inputVersion.match(/^\d+\.\d+(\.\d+)?$/)) {
-    throw new Error(
-      language.errors.invalidSolanaVersion.replace('$version', inputVersion)
-    );
-  }
-  const versionSegments = inputVersion.split('.');
-  if (versionSegments.length === 3) {
-    return inputVersion;
-  }
-  const map: Record<string, string> = {
-    '1.17': '1.17.34',
-    '1.18': '1.18.18',
-  };
-  return map[inputVersion] ?? `${inputVersion}.0`;
-}
-
-function resolveAnchorVersion(detectedVersion: string | undefined): string {
-  return detectedVersion ?? '';
-}

+ 10 - 10
utils/getInputs.ts → utils/inputs.ts

@@ -3,9 +3,8 @@ import * as fs from 'node:fs';
 import { parseArgs } from 'node:util';
 import prompts from 'prompts';
 
-import { Language } from './getLanguage';
-import { kebabCase } from './stringHelpers';
-import { toMinorSolanaVersion } from './solanaCli';
+import { Language } from './localization';
+import { kebabCase } from './strings';
 
 export const allClients = ['js', 'rust'] as const;
 export type Client = (typeof allClients)[number];
@@ -20,6 +19,7 @@ export type Inputs = {
   programName: string;
   rustClient: boolean;
   rustClientCrateName: string;
+  rustVersion?: string;
   shouldOverride: boolean;
   solanaVersion?: string;
   targetDirectoryName: string;
@@ -27,7 +27,7 @@ export type Inputs = {
 };
 
 export async function getInputs(language: Language): Promise<Inputs> {
-  const argInputs = getInputsFromArgs(language);
+  const argInputs = getInputsFromArgs();
   const defaultInputs = getDefaultInputs(argInputs);
 
   if (argInputs.useDefaults) {
@@ -212,7 +212,7 @@ async function getInputsFromPrompts(
   }
 }
 
-function getInputsFromArgs(language: Language): Partial<Inputs> {
+function getInputsFromArgs(): Partial<Inputs> {
   type ArgInputs = {
     address?: string;
     anchorProgram: boolean;
@@ -221,6 +221,7 @@ function getInputsFromArgs(language: Language): Partial<Inputs> {
     noClients: boolean;
     organizationName?: string;
     programName?: string;
+    rustVersion?: string;
     shankProgram: boolean;
     solanaVersion?: string;
     useDefaults: boolean;
@@ -235,11 +236,8 @@ function getInputsFromArgs(language: Language): Partial<Inputs> {
       inputs.organizationName = kebabCase(argInputs.organizationName);
     if (argInputs.programName)
       inputs.programName = kebabCase(argInputs.programName);
-    if (argInputs.solanaVersion)
-      inputs.solanaVersion = toMinorSolanaVersion(
-        language,
-        argInputs.solanaVersion
-      );
+    if (argInputs.rustVersion) inputs.rustVersion = argInputs.rustVersion;
+    if (argInputs.solanaVersion) inputs.solanaVersion = argInputs.solanaVersion;
     if (argInputs.targetDirectoryName)
       inputs.targetDirectoryName = argInputs.targetDirectoryName;
     if (argInputs.force) inputs.shouldOverride = true;
@@ -273,6 +271,7 @@ function getInputsFromArgs(language: Language): Partial<Inputs> {
       force: { type: 'boolean' },
       'no-clients': { type: 'boolean' },
       org: { type: 'string' },
+      rust: { type: 'string' },
       shank: { type: 'boolean' },
       solana: { type: 'string' },
     },
@@ -287,6 +286,7 @@ function getInputsFromArgs(language: Language): Partial<Inputs> {
     noClients: options['no-clients'] ?? false,
     organizationName: options.org,
     programName: positionals[1],
+    rustVersion: options.rust,
     shankProgram: options.shank ?? false,
     solanaVersion: options.solana,
     useDefaults: options.default ?? false,

+ 3 - 1
utils/getLanguage.ts → utils/localization.ts

@@ -31,8 +31,9 @@ export interface Language {
   errors: {
     anchorCliNotFound: string;
     cannotOverrideDirectory: string;
-    invalidSolanaVersion: string;
+    invalidVersion: string;
     operationCancelled: string;
+    rustVersionIncompatibleWithSolana: string;
     solanaCliNotFound: string;
     solanaKeygenFailed: string;
   };
@@ -46,6 +47,7 @@ export interface Language {
   };
   infos: {
     detectAnchorVersion: string;
+    detectRustVersion: string;
     detectSolanaVersion: string;
     generateKeypair: string;
     scaffold: string;

+ 5 - 1
utils/getLogs.ts → utils/logs.ts

@@ -3,7 +3,7 @@ import chalk from 'chalk';
 // @ts-ignore
 import gradient from 'gradient-string';
 
-import type { RenderContext } from './getRenderContext';
+import type { RenderContext } from './renderContext';
 
 export function logBanner() {
   console.log(`\n${getBanner()}\n`);
@@ -13,6 +13,10 @@ export function logSuccess(message: string) {
   console.log(chalk.green('✔︎') + ` ${message}`);
 }
 
+export function logWarning(message: string) {
+  console.log(chalk.yellow(`► ${message}`));
+}
+
 export function logError(message: string) {
   console.log(chalk.red('✖') + ` ${message}`);
 }

+ 0 - 0
utils/objectHelpers.ts → utils/objects.ts


+ 0 - 0
utils/getPackageManager.ts → utils/packageManager.ts


+ 98 - 0
utils/renderContext.ts

@@ -0,0 +1,98 @@
+import * as path from 'node:path';
+
+import { Client, Inputs, allClients } from './inputs';
+import { Language } from './localization';
+import {
+  PackageManager,
+  getPackageManager,
+  getPackageManagerCommand,
+} from './packageManager';
+import { ResolvedVersion, Version } from './version-core';
+import { resolveRustVersion } from './version-rust';
+import { resolveAnchorVersion } from './version-anchor';
+import { resolveSolanaVersion } from './version-solana';
+
+export type RenderContext = Omit<
+  Inputs,
+  'programAddress' | 'rustVersion' | 'solanaVersion'
+> & {
+  anchorVersion?: ResolvedVersion;
+  clientDirectory: string;
+  clients: Client[];
+  currentDirectory: string;
+  getNpmCommand: (scriptName: string, args?: string) => string;
+  language: Language;
+  programAddress: string;
+  programDirectory: string;
+  packageManager: PackageManager;
+  rustVersion: ResolvedVersion;
+  solanaVersion: ResolvedVersion;
+  targetDirectory: string;
+  templateDirectory: string;
+  toolchain: string;
+};
+
+export function getRenderContext({
+  inputs,
+  language,
+  programAddress,
+  solanaVersionDetected,
+  rustVersionDetected,
+  anchorVersionDetected,
+}: {
+  inputs: Inputs;
+  language: Language;
+  programAddress: string;
+  solanaVersionDetected: Version;
+  rustVersionDetected?: Version;
+  anchorVersionDetected?: Version;
+}): RenderContext {
+  const packageManager = getPackageManager();
+  const clients = allClients.flatMap((client) =>
+    inputs[`${client}Client`] ? [client] : []
+  );
+  const getNpmCommand: RenderContext['getNpmCommand'] = (...args) =>
+    getPackageManagerCommand(packageManager, ...args);
+
+  // Versions.
+  const anchorVersion = resolveAnchorVersion(anchorVersionDetected);
+  const solanaVersion = resolveSolanaVersion(
+    language,
+    inputs.solanaVersion,
+    solanaVersionDetected
+  );
+  const rustVersion = resolveRustVersion(
+    language,
+    solanaVersion,
+    inputs.rustVersion,
+    rustVersionDetected
+  );
+
+  // Directories.
+  const templateDirectory = path.resolve(__dirname, 'template');
+  const currentDirectory = process.cwd();
+  const targetDirectory = path.join(
+    currentDirectory,
+    inputs.targetDirectoryName
+  );
+  const programDirectory = path.join(targetDirectory, 'program');
+  const clientDirectory = path.join(targetDirectory, 'client');
+
+  return {
+    ...inputs,
+    anchorVersion,
+    clientDirectory,
+    clients,
+    currentDirectory,
+    getNpmCommand,
+    language,
+    packageManager,
+    programAddress,
+    programDirectory,
+    rustVersion,
+    solanaVersion,
+    targetDirectory,
+    templateDirectory,
+    toolchain: rustVersion.full,
+  };
+}

+ 3 - 3
utils/renderTemplates.ts

@@ -2,15 +2,15 @@ import * as fs from 'node:fs';
 import * as path from 'node:path';
 import nunjucks, { ConfigureOptions } from 'nunjucks';
 
-import { RenderContext } from './getRenderContext';
-import { deepMerge, sortDependencies } from './objectHelpers';
+import { RenderContext } from './renderContext';
+import { deepMerge, sortDependencies } from './objects';
 import {
   camelCase,
   kebabCase,
   pascalCase,
   snakeCase,
   titleCase,
-} from './stringHelpers';
+} from './strings';
 
 /**
  * Renders a template folder/file to the provided destination,

+ 7 - 55
utils/solanaCli.ts

@@ -1,56 +1,21 @@
-import { Language } from './getLanguage';
-import { RenderContext } from './getRenderContext';
+import { Language } from './localization';
+import { RenderContext } from './renderContext';
 import {
   hasCommand,
   readStdout,
   spawnCommand,
   waitForCommand,
-} from './runCommands';
-
-export async function detectSolanaVersion(language: Language): Promise<string> {
-  const hasSolanaCli = await hasCommand('solana');
-  if (!hasSolanaCli) {
-    throw new Error(
-      language.errors.solanaCliNotFound.replace('$command', 'solana')
-    );
-  }
-
-  const child = spawnCommand('solana', ['--version']);
-  const [stdout] = await Promise.all([
-    readStdout(child),
-    waitForCommand(child),
-  ]);
-
-  const version = stdout.join('').match(/(\d+\.\d+\.\d+)/)?.[1];
-  return version!;
-}
-
-export async function detectAnchorVersion(language: Language): Promise<string> {
-  const hasAnchorCli = await hasCommand('anchor');
-  if (!hasAnchorCli) {
-    throw new Error(
-      language.errors.solanaCliNotFound.replace('$command', 'anchor')
-    );
-  }
-
-  const child = spawnCommand('anchor', ['--version']);
-  const [stdout] = await Promise.all([
-    readStdout(child),
-    waitForCommand(child),
-  ]);
-
-  const version = stdout.join('').match(/(\d+\.\d+\.\d+)/)?.[1];
-  return version!;
-}
+} from './commands';
+import { VersionWithoutPatch } from './version-core';
 
 export async function patchSolanaDependencies(
-  ctx: Pick<RenderContext, 'solanaVersionWithoutPatch' | 'targetDirectory'>
+  ctx: Pick<RenderContext, 'solanaVersion' | 'targetDirectory'>
 ): Promise<void> {
-  const patchMap: Record<string, string[]> = {
+  const patchMap: Record<VersionWithoutPatch, string[]> = {
     '1.17': ['-p ahash@0.8.11 --precise 0.8.6'],
   };
 
-  const patches = patchMap[ctx.solanaVersionWithoutPatch] ?? [];
+  const patches = patchMap[ctx.solanaVersion.withoutPatch] ?? [];
   await Promise.all(
     patches.map(async (patch) =>
       waitForCommand(
@@ -62,19 +27,6 @@ export async function patchSolanaDependencies(
   );
 }
 
-export function toMinorSolanaVersion(
-  language: Language,
-  version: string
-): string {
-  const validVersion = version.match(/^(\d+\.\d+)/);
-  if (!validVersion) {
-    throw new Error(
-      language.errors.invalidSolanaVersion.replace('$version', version)
-    );
-  }
-  return validVersion[0];
-}
-
 export async function generateKeypair(
   language: Language,
   outfile: string

+ 0 - 0
utils/stringHelpers.ts → utils/strings.ts


+ 29 - 0
utils/version-anchor.ts

@@ -0,0 +1,29 @@
+import { hasCommand, spawnCommand } from './commands';
+import { Language } from './localization';
+import {
+  getVersionAndVersionWithoutPatch,
+  getVersionFromStdout,
+  ResolvedVersion,
+  Version,
+} from './version-core';
+
+export async function detectAnchorVersion(
+  language: Language
+): Promise<Version> {
+  const hasAnchorCli = await hasCommand('anchor');
+  if (!hasAnchorCli) {
+    throw new Error(
+      language.errors.solanaCliNotFound.replace('$command', 'anchor')
+    );
+  }
+  return getVersionFromStdout(spawnCommand('anchor', ['--version']));
+}
+
+export function resolveAnchorVersion(
+  detectedVersion: Version | undefined
+): ResolvedVersion | undefined {
+  if (!detectedVersion) return undefined;
+  const [full, withoutPatch] =
+    getVersionAndVersionWithoutPatch(detectedVersion);
+  return { full, withoutPatch, detected: detectedVersion };
+}

+ 66 - 0
utils/version-core.ts

@@ -0,0 +1,66 @@
+import { ChildProcess } from 'node:child_process';
+import { readStdout, waitForCommand } from './commands';
+import { Language } from './localization';
+
+export type Version = `${number}.${number}.${number}`;
+export type VersionWithoutPatch = `${number}.${number}`;
+export type ResolvedVersion = {
+  full: Version;
+  withoutPatch: VersionWithoutPatch;
+  detected?: Version;
+};
+
+export function isValidVersion(
+  version: string
+): version is Version | VersionWithoutPatch {
+  return !!version.match(/^\d+\.\d+(\.\d+)?$/);
+}
+
+export function assertIsValidVersion(
+  language: Language,
+  tool: string,
+  version: string
+): asserts version is Version | VersionWithoutPatch {
+  if (!isValidVersion(version)) {
+    throw new Error(
+      language.errors.invalidVersion
+        .replace('$version', version)
+        .replace('$tool', tool)
+    );
+  }
+}
+
+export function compareVersions(a: Version, b: Version): number {
+  return a.localeCompare(b, undefined, { numeric: true });
+}
+
+export function getVersionWithoutPatch(
+  version: Version | VersionWithoutPatch
+): VersionWithoutPatch {
+  return version.match(/^(\d+\.\d+)/)?.[1] as VersionWithoutPatch;
+}
+
+export function getVersionAndVersionWithoutPatch(
+  version: Version | VersionWithoutPatch,
+  patchMap: Record<VersionWithoutPatch, Version> = {}
+): [Version, VersionWithoutPatch] {
+  const segments = version.split('.').length;
+  if (segments === 3) {
+    return [version as Version, getVersionWithoutPatch(version)];
+  }
+  return [
+    patchMap[version as VersionWithoutPatch] ?? `${version}.0`,
+    version as VersionWithoutPatch,
+  ];
+}
+
+export async function getVersionFromStdout(
+  child: ChildProcess
+): Promise<Version> {
+  const [stdout] = await Promise.all([
+    readStdout(child),
+    waitForCommand(child),
+  ]);
+
+  return stdout.join('').match(/(\d+\.\d+\.\d+)/)?.[1] as Version;
+}

+ 69 - 0
utils/version-rust.ts

@@ -0,0 +1,69 @@
+import { hasCommand, spawnCommand } from './commands';
+import { Language } from './localization';
+import { logWarning } from './logs';
+import {
+  assertIsValidVersion,
+  compareVersions,
+  getVersionAndVersionWithoutPatch,
+  getVersionFromStdout,
+  ResolvedVersion,
+  Version,
+  VersionWithoutPatch,
+} from './version-core';
+
+export async function detectRustVersion(): Promise<Version | undefined> {
+  const hasRustc = await hasCommand('rustc');
+  if (!hasRustc) {
+    return undefined;
+  }
+  return getVersionFromStdout(spawnCommand('rustc', ['--version']));
+}
+
+export function resolveRustVersion(
+  language: Language,
+  solanaVersion: ResolvedVersion,
+  inputVersion: string | undefined,
+  detectedVersion: Version | undefined
+): ResolvedVersion {
+  const solanaToRustMap: Record<VersionWithoutPatch, Version> = {
+    '1.17': '1.75.0',
+    '1.18': '1.75.0',
+    '2.0': '1.75.0',
+  };
+  const fallbackVersion =
+    solanaToRustMap[solanaVersion.withoutPatch] ?? '1.75.0';
+
+  const version = inputVersion ?? detectedVersion ?? fallbackVersion;
+  assertIsValidVersion(language, 'Rust', version);
+  const [full, withoutPatch] = getVersionAndVersionWithoutPatch(version);
+  const rustVersion = { full, withoutPatch, detected: detectedVersion };
+  warnAboutSolanaRustVersionMismatch(language, rustVersion, solanaVersion);
+  return rustVersion;
+}
+
+function warnAboutSolanaRustVersionMismatch(
+  language: Language,
+  rustVersion: ResolvedVersion,
+  solanaVersion: ResolvedVersion
+) {
+  const minimumViableRustVersionPerSolanaVersion: Record<
+    VersionWithoutPatch,
+    Version
+  > = {
+    '1.17': '1.68.0',
+    '1.18': '1.75.0',
+    '2.0': '1.75.0',
+  };
+  const minimumViableRustVersion: Version | undefined =
+    minimumViableRustVersionPerSolanaVersion[solanaVersion.withoutPatch];
+  if (!minimumViableRustVersion) return;
+
+  if (compareVersions(rustVersion.full, minimumViableRustVersion) < 0) {
+    logWarning(
+      language.errors.rustVersionIncompatibleWithSolana
+        .replace('$solanaVersion', solanaVersion.withoutPatch)
+        .replace('$minimumRustVersion', minimumViableRustVersion)
+        .replace('$rustVersion', rustVersion.full)
+    );
+  }
+}

+ 35 - 0
utils/version-solana.ts

@@ -0,0 +1,35 @@
+import { hasCommand, spawnCommand } from './commands';
+import { Language } from './localization';
+import {
+  assertIsValidVersion,
+  getVersionAndVersionWithoutPatch,
+  getVersionFromStdout,
+  ResolvedVersion,
+  Version,
+} from './version-core';
+
+export async function detectSolanaVersion(
+  language: Language
+): Promise<Version> {
+  const hasSolanaCli = await hasCommand('solana');
+  if (!hasSolanaCli) {
+    throw new Error(
+      language.errors.solanaCliNotFound.replace('$command', 'solana')
+    );
+  }
+  return getVersionFromStdout(spawnCommand('solana', ['--version']));
+}
+
+export function resolveSolanaVersion(
+  language: Language,
+  inputVersion: string | undefined,
+  detectedVersion: Version
+): ResolvedVersion {
+  const version = inputVersion ?? detectedVersion ?? '';
+  assertIsValidVersion(language, 'Solana', version);
+  const [full, withoutPatch] = getVersionAndVersionWithoutPatch(version, {
+    '1.17': '1.17.34',
+    '1.18': '1.18.18',
+  });
+  return { full, withoutPatch, detected: detectedVersion };
+}