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
5 changes: 5 additions & 0 deletions .changeset/rust-cargo-doctor-diagnostics.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 10 additions & 1 deletion src/commands/doctor.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -21,7 +25,12 @@ function formatResult(result: DiagnosticResult): string {
}

function createDefaultDiagnostics(): readonly DiagnosticCheck[] {
return [...createRuntimeDiagnostics(), createGitDiagnostic()];
return [
...createRuntimeDiagnostics(),
createGitDiagnostic(),
createRustDiagnostic(),
createCargoDiagnostic(),
];
}

export function renderDiagnosticReport(
Expand Down
113 changes: 113 additions & 0 deletions src/diagnostics/rust.ts
Original file line number Diff line number Diff line change
@@ -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.`,
};
},
};
}
4 changes: 3 additions & 1 deletion tests/executable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
104 changes: 104 additions & 0 deletions tests/rust-diagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -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.');
});
});