diff --git a/internal/check/registry.go b/internal/check/registry.go index af3655a..0749e81 100644 --- a/internal/check/registry.go +++ b/internal/check/registry.go @@ -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 { diff --git a/internal/check/version.go b/internal/check/version.go index 9786201..8aa76a5 100644 --- a/internal/check/version.go +++ b/internal/check/version.go @@ -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") +} \ No newline at end of file diff --git a/internal/check/version_test.go b/internal/check/version_test.go index 1ed702d..ec4edbd 100644 --- a/internal/check/version_test.go +++ b/internal/check/version_test.go @@ -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") + } +} \ No newline at end of file diff --git a/internal/detector/detector.go b/internal/detector/detector.go index aaefd32..29f1aac 100644 --- a/internal/detector/detector.go +++ b/internal/detector/detector.go @@ -15,6 +15,7 @@ type DetectedStack struct { PackageManager string Python bool Ruby bool + Rust bool Java bool Maven bool Gradle bool @@ -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 @@ -68,4 +70,4 @@ func Detect(dir string) DetectedStack { func fileExists(path string) bool { _, err := os.Stat(path) return err == nil -} +} \ No newline at end of file diff --git a/internal/detector/detector_test.go b/internal/detector/detector_test.go index feebedd..572ab9d 100644 --- a/internal/detector/detector_test.go +++ b/internal/detector/detector_test.go @@ -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") + } } \ No newline at end of file