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 internal/check/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ func Build(stack detector.DetectedStack) []Check {
cs = append(cs, &BinaryCheck{Binary: "bundler"})
cs = append(cs, &DepsCheck{Dir: ".", Stack: "ruby"})
}
if stack.Rust {
cs = append(cs, &BinaryCheck{Binary: "rustc"})
cs = append(cs, &BinaryCheck{Binary: "cargo"})
cs = append(cs, &RustVersionCheck{Dir: "."})
}
if stack.Java {
cs = append(cs, &BinaryCheck{Binary: "java"})
if stack.Maven {
Expand Down
77 changes: 77 additions & 0 deletions internal/check/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,80 @@ func parseVersion(v string) []int {
}
return result
}

// RustVersionCheck reads the required Rust version from rust-toolchain.toml
// (channel field) and compares it against the installed rustc version.
type RustVersionCheck struct {
Dir string
}

func (c *RustVersionCheck) Name() string {
return "Rust version"
}

func (c *RustVersionCheck) Run(_ context.Context) Result {
required, err := readRustRequired(c.Dir)
if err != nil {
return Result{Name: c.Name(), Status: StatusSkipped, Message: "no Rust version requirement found (rust-toolchain.toml absent or has no channel)"}
}

out, err := exec.Command("rustc", "--version").Output()
if err != nil {
return Result{Name: c.Name(), Status: StatusFail, Message: "could not run rustc --version"}
}
// output: "rustc 1.76.0 (07dca489a 2024-02-04)"
fields := strings.Fields(string(out))
if len(fields) < 2 {
return Result{Name: c.Name(), Status: StatusFail, Message: "unexpected rustc --version output"}
}
installed := fields[1]

if versionLess(installed, required) {
return Result{
Name: c.Name(),
Status: StatusFail,
Message: fmt.Sprintf("need Rust %s, got %s", required, installed),
Fix: fmt.Sprintf("run `rustup update` or install Rust %s via rustup", required),
}
}
return Result{
Name: c.Name(),
Status: StatusPass,
Message: fmt.Sprintf("Rust %s installed (need %s)", installed, required),
}
}

// readRustRequired reads the channel from rust-toolchain.toml.
// It handles the [toolchain] section and a bare channel = "x.y.z" line.
func readRustRequired(dir string) (string, error) {
data, err := os.ReadFile(dir + "/rust-toolchain.toml")
if err != nil {
// Also try the legacy plain-text rust-toolchain file.
data, err = os.ReadFile(dir + "/rust-toolchain")
if err != nil {
return "", err
}
v := strings.TrimSpace(string(data))
return strings.TrimPrefix(v, "stable-"), nil
}

scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Match: channel = "1.76.0" or channel = "stable"
if strings.HasPrefix(line, "channel") {
_, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
v := strings.TrimSpace(val)
v = strings.Trim(v, `"'`)
// Only return concrete semver versions, not "stable"/"nightly"/"beta".
if v != "stable" && v != "nightly" && v != "beta" && v != "" {
return v, nil
}
return "", fmt.Errorf("channel %q is not a pinned version", v)
}
}
return "", fmt.Errorf("channel not found in rust-toolchain.toml")
}
76 changes: 76 additions & 0 deletions internal/check/version_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,79 @@ func TestNodeVersionCheck_NoRequirement(t *testing.T) {
t.Errorf("expected skipped, got %v", result.Status)
}
}

func TestRustVersionCheck_NoToolchainFile_Skipped(t *testing.T) {
c := &RustVersionCheck{Dir: t.TempDir()}
r := c.Run(context.Background())
if r.Status != StatusSkipped {
t.Errorf("expected skipped when no rust-toolchain.toml, got %v: %s", r.Status, r.Message)
}
}

func TestRustVersionCheck_Pass(t *testing.T) {
dir := t.TempDir()
os.WriteFile(dir+"/rust-toolchain.toml", []byte("[toolchain]\nchannel = \"1.0.0\"\n"), 0644)

c := &RustVersionCheck{Dir: dir}
r := c.Run(context.Background())
// rustc is likely installed in CI; if not, we get Fail, not Skipped.
// Just ensure it doesn't panic and returns a result.
if r.Name != "Rust version" {
t.Errorf("unexpected check name: %s", r.Name)
}
}

func TestRustVersionCheck_Fail(t *testing.T) {
dir := t.TempDir()
os.WriteFile(dir+"/rust-toolchain.toml", []byte("[toolchain]\nchannel = \"9999.0.0\"\n"), 0644)

c := &RustVersionCheck{Dir: dir}
r := c.Run(context.Background())
// If rustc is installed, this must fail (version too high).
// If rustc is not installed, it fails with "could not run rustc --version".
if r.Status != StatusFail {
t.Errorf("expected fail for unreachable version, got %v: %s", r.Status, r.Message)
}
}

func TestRustVersionCheck_StableChannel_Skipped(t *testing.T) {
dir := t.TempDir()
os.WriteFile(dir+"/rust-toolchain.toml", []byte("[toolchain]\nchannel = \"stable\"\n"), 0644)

c := &RustVersionCheck{Dir: dir}
r := c.Run(context.Background())
if r.Status != StatusSkipped {
t.Errorf("expected skipped for non-pinned channel 'stable', got %v: %s", r.Status, r.Message)
}
}

func TestReadRustRequired_TomlPinned(t *testing.T) {
dir := t.TempDir()
os.WriteFile(dir+"/rust-toolchain.toml", []byte("[toolchain]\nchannel = \"1.76.0\"\n"), 0644)
v, err := readRustRequired(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if v != "1.76.0" {
t.Errorf("expected 1.76.0, got %q", v)
}
}

func TestReadRustRequired_LegacyFile(t *testing.T) {
dir := t.TempDir()
os.WriteFile(dir+"/rust-toolchain", []byte("1.70.0\n"), 0644)
v, err := readRustRequired(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if v != "1.70.0" {
t.Errorf("expected 1.70.0, got %q", v)
}
}

func TestReadRustRequired_NoFile_Error(t *testing.T) {
_, err := readRustRequired(t.TempDir())
if err == nil {
t.Error("expected error when no toolchain file present")
}
}
4 changes: 3 additions & 1 deletion internal/detector/detector.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type DetectedStack struct {
PackageManager string
Python bool
Ruby bool
Rust bool
Java bool
Maven bool
Gradle bool
Expand Down Expand Up @@ -46,6 +47,7 @@ func Detect(dir string) DetectedStack {
stack.Python = fileExists(filepath.Join(dir, "requirements.txt")) ||
fileExists(filepath.Join(dir, "pyproject.toml"))
stack.Ruby = fileExists(filepath.Join(dir, "Gemfile"))
stack.Rust = fileExists(filepath.Join(dir, "Cargo.toml"))
stack.Maven = fileExists(filepath.Join(dir, "pom.xml"))
stack.Gradle = fileExists(filepath.Join(dir, "build.gradle"))
stack.Java = stack.Maven || stack.Gradle
Expand All @@ -68,4 +70,4 @@ func Detect(dir string) DetectedStack {
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
}
17 changes: 17 additions & 0 deletions internal/detector/detector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,21 @@ func TestDetect_Ruby_false_when_absent(t *testing.T) {
if stack.Ruby {
t.Error("expected Ruby=false when no Gemfile present")
}
}

func TestDetect_Rust_true_when_cargo_toml_present(t *testing.T) {
dir := t.TempDir()
touch(t, filepath.Join(dir, "Cargo.toml"))
stack := Detect(dir)
if !stack.Rust {
t.Error("expected Rust=true when Cargo.toml exists")
}
}

func TestDetect_Rust_false_when_absent(t *testing.T) {
dir := t.TempDir()
stack := Detect(dir)
if stack.Rust {
t.Error("expected Rust=false when no Cargo.toml present")
}
}
Loading