diff --git a/.changeset/rust-cargo-doctor-diagnostics.md b/.changeset/rust-cargo-doctor-diagnostics.md new file mode 100644 index 0000000..7e60cff --- /dev/null +++ b/.changeset/rust-cargo-doctor-diagnostics.md @@ -0,0 +1,5 @@ +--- +"@stellarforge/cli": minor +--- + +Add Rust and Cargo environment checks to `stellarforge doctor`, including the Stellar smart-contract Rust 1.84.0 minimum and remediation guidance. diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index eb72063..ece8c12 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,6 +1,10 @@ import { Command } from 'commander'; import { createGitDiagnostic } from '../diagnostics/git.js'; import { runDiagnostics } from '../diagnostics/run.js'; +import { + createCargoDiagnostic, + createRustDiagnostic, +} from '../diagnostics/rust.js'; import { createRuntimeDiagnostics } from '../diagnostics/runtime.js'; import type { DiagnosticCheck, @@ -21,7 +25,12 @@ function formatResult(result: DiagnosticResult): string { } function createDefaultDiagnostics(): readonly DiagnosticCheck[] { - return [...createRuntimeDiagnostics(), createGitDiagnostic()]; + return [ + ...createRuntimeDiagnostics(), + createGitDiagnostic(), + createRustDiagnostic(), + createCargoDiagnostic(), + ]; } export function renderDiagnosticReport( diff --git a/src/diagnostics/rust.ts b/src/diagnostics/rust.ts new file mode 100644 index 0000000..b271bcc --- /dev/null +++ b/src/diagnostics/rust.ts @@ -0,0 +1,113 @@ +import { runCommand, type RunCommand } from './process.js'; +import type { DiagnosticCheck, DiagnosticResult } from './types.js'; +import { isAtLeast, parseNumericVersion } from './version.js'; + +const MINIMUM_RUST = { major: 1, minor: 84, patch: 0 } as const; + +function parseToolVersion( + output: string, + tool: 'rustc' | 'cargo', +): string | null { + const match = new RegExp(`^${tool} (\\d+\\.\\d+\\.\\d+)`, 'i').exec( + output.trim(), + ); + return match?.[1] ?? null; +} + +export function createRustDiagnostic( + execute: RunCommand = runCommand, +): DiagnosticCheck { + return { + id: 'rust', + label: 'Rust', + run(): DiagnosticResult { + const result = execute('rustc', ['--version']); + + if (result.error || result.status !== 0) { + return { + id: 'rust', + label: 'Rust', + status: 'fail', + message: 'Rust is unavailable or could not be executed.', + remediation: + 'Install Rust >=1.84.0 and ensure `rustc` is available on PATH.', + }; + } + + const version = parseToolVersion(result.stdout, 'rustc'); + const parsed = version ? parseNumericVersion(version) : null; + + if (!version || !parsed) { + return { + id: 'rust', + label: 'Rust', + status: 'fail', + message: 'Rust returned an unrecognized version.', + remediation: + 'Verify `rustc --version` works and reinstall Rust if necessary.', + }; + } + + if (!isAtLeast(parsed, MINIMUM_RUST)) { + return { + id: 'rust', + label: 'Rust', + status: 'fail', + message: `Unsupported Rust version ${version}.`, + remediation: + 'Upgrade to Rust >=1.84.0 before building Stellar smart contracts.', + }; + } + + return { + id: 'rust', + label: 'Rust', + status: 'pass', + message: `Supported version ${version} detected.`, + }; + }, + }; +} + +export function createCargoDiagnostic( + execute: RunCommand = runCommand, +): DiagnosticCheck { + return { + id: 'cargo', + label: 'Cargo', + run(): DiagnosticResult { + const result = execute('cargo', ['--version']); + + if (result.error || result.status !== 0) { + return { + id: 'cargo', + label: 'Cargo', + status: 'fail', + message: 'Cargo is unavailable or could not be executed.', + remediation: + 'Install Cargo with the Rust toolchain and ensure `cargo` is available on PATH.', + }; + } + + const version = parseToolVersion(result.stdout, 'cargo'); + + if (!version || !parseNumericVersion(version)) { + return { + id: 'cargo', + label: 'Cargo', + status: 'fail', + message: 'Cargo returned an unrecognized version.', + remediation: + 'Verify `cargo --version` works and reinstall Rust if necessary.', + }; + } + + return { + id: 'cargo', + label: 'Cargo', + status: 'pass', + message: `Version ${version} detected.`, + }; + }, + }; +} diff --git a/tests/executable.test.ts b/tests/executable.test.ts index 5e01ac1..75bb04a 100644 --- a/tests/executable.test.ts +++ b/tests/executable.test.ts @@ -56,7 +56,9 @@ describe('StellarForge CLI executable', () => { expect(result.stdout).toContain('[PASS] Node.js:'); expect(result.stdout).toContain('[PASS] npm:'); expect(result.stdout).toContain('[PASS] Git:'); - expect(result.stdout).toContain('Summary: 3 passed, 0 warnings, 0 failed.'); + expect(result.stdout).toContain('[PASS] Rust:'); + expect(result.stdout).toContain('[PASS] Cargo:'); + expect(result.stdout).toContain('Summary: 5 passed, 0 warnings, 0 failed.'); }); it('prints the package version from the built executable', () => { diff --git a/tests/rust-diagnostics.test.ts b/tests/rust-diagnostics.test.ts new file mode 100644 index 0000000..6924282 --- /dev/null +++ b/tests/rust-diagnostics.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { + createCargoDiagnostic, + createRustDiagnostic, +} from '../src/diagnostics/rust.js'; +import type { RunCommand } from '../src/diagnostics/process.js'; + +function result( + status: number | null, + stdout = '', + stderr = '', + error?: Error, +) { + return { + status, + stdout, + stderr, + ...(error === undefined ? {} : { error }), + }; +} + +describe('Rust diagnostic', () => { + it('passes Rust 1.84.0 and newer', () => { + const execute: RunCommand = () => + result(0, 'rustc 1.84.0 (9fc6b4312 2025-01-07)\n'); + + const diagnostic = createRustDiagnostic(execute).run(); + + expect(diagnostic.status).toBe('pass'); + expect(diagnostic.message).toContain('1.84.0'); + }); + + it('fails Rust below the Stellar smart-contract minimum', () => { + const execute: RunCommand = () => + result(0, 'rustc 1.83.0 (90b35a623 2024-11-26)\n'); + + const diagnostic = createRustDiagnostic(execute).run(); + + expect(diagnostic.status).toBe('fail'); + expect(diagnostic.remediation).toContain('>=1.84.0'); + }); + + it('fails when rustc is unavailable without leaking process details', () => { + const execute: RunCommand = () => + result( + null, + '', + 'SECRET_TOKEN=do-not-render', + new Error('spawn rustc ENOENT SECRET_TOKEN=do-not-render'), + ); + + const diagnostic = createRustDiagnostic(execute).run(); + + expect(diagnostic.status).toBe('fail'); + expect(diagnostic.message).toBe( + 'Rust is unavailable or could not be executed.', + ); + expect(JSON.stringify(diagnostic)).not.toContain('SECRET_TOKEN'); + }); + + it('fails malformed rustc output', () => { + const execute: RunCommand = () => result(0, 'rustc unknown\n'); + + const diagnostic = createRustDiagnostic(execute).run(); + + expect(diagnostic.status).toBe('fail'); + expect(diagnostic.message).toBe('Rust returned an unrecognized version.'); + }); +}); + +describe('Cargo diagnostic', () => { + it('checks Cargo independently and parses its version', () => { + const calls: Array<{ executable: string; args: readonly string[] }> = []; + const execute: RunCommand = (executable, args) => { + calls.push({ executable, args }); + return result(0, 'cargo 1.89.0 (c24e10642 2025-06-23)\n'); + }; + + const diagnostic = createCargoDiagnostic(execute).run(); + + expect(calls).toEqual([{ executable: 'cargo', args: ['--version'] }]); + expect(diagnostic.status).toBe('pass'); + expect(diagnostic.message).toContain('1.89.0'); + }); + + it('fails when Cargo is unavailable without affecting Rust checks', () => { + const execute: RunCommand = () => + result(null, '', '', new Error('spawn cargo ENOENT')); + + const diagnostic = createCargoDiagnostic(execute).run(); + + expect(diagnostic.status).toBe('fail'); + expect(diagnostic.remediation).toContain('cargo'); + }); + + it('fails malformed Cargo output', () => { + const execute: RunCommand = () => result(0, 'cargo unknown\n'); + + const diagnostic = createCargoDiagnostic(execute).run(); + + expect(diagnostic.status).toBe('fail'); + expect(diagnostic.message).toBe('Cargo returned an unrecognized version.'); + }); +});