cargo/spec/dependabot/cargo/file_updater/lockfile_updater_spec.rb
# typed: false
# frozen_string_literal: true

require "spec_helper"
require "dependabot/dependency"
require "dependabot/dependency_file"
require "dependabot/cargo/file_updater/lockfile_updater"

RSpec.describe Dependabot::Cargo::FileUpdater::LockfileUpdater do
  let(:updater) do
    described_class.new(
      dependencies: [dependency],
      dependency_files: dependency_files,
      credentials: [{
        "type" => "git_source",
        "host" => "github.com"
      }]
    )
  end

  let(:dependency_files) { [manifest, lockfile] }
  let(:manifest) do
    Dependabot::DependencyFile.new(name: "Cargo.toml", content: manifest_body)
  end
  let(:lockfile) do
    Dependabot::DependencyFile.new(name: "Cargo.lock", content: lockfile_body)
  end
  let(:manifest_body) { fixture("manifests", manifest_fixture_name) }
  let(:lockfile_body) { fixture("lockfiles", lockfile_fixture_name) }
  let(:manifest_fixture_name) { "bare_version_specified" }
  let(:lockfile_fixture_name) { "bare_version_specified" }

  let(:dependency) do
    Dependabot::Dependency.new(
      name: dependency_name,
      version: dependency_version,
      requirements: requirements,
      previous_version: dependency_previous_version,
      previous_requirements: previous_requirements,
      package_manager: "cargo"
    )
  end
  let(:dependency_name) { "time" }
  let(:dependency_version) { "0.1.40" }
  let(:dependency_previous_version) { "0.1.38" }
  let(:requirements) { previous_requirements }
  let(:previous_requirements) do
    [{ file: "Cargo.toml", requirement: "0.1.12", groups: [], source: nil }]
  end
  let(:tmp_path) { Dependabot::Utils::BUMP_TMP_DIR_PATH }

  before { FileUtils.mkdir_p(tmp_path) }

  describe "#updated_lockfile_content" do
    subject(:updated_lockfile_content) { updater.updated_lockfile_content }

    it "doesn't store the files permanently" do
      expect { updated_lockfile_content }
        .not_to(change { Dir.entries(tmp_path) })
    end

    it { expect { updated_lockfile_content }.not_to output.to_stdout }

    context "when using a toolchain file that is too old" do
      let(:toolchain_file) do
        Dependabot::DependencyFile.new(
          name: "rust-toolchain",
          content: "[toolchain]\nchannel = \"1.67\"\n"
        )
      end
      let(:dependency_files) { [manifest, lockfile, toolchain_file] }

      it "raises a helpful error" do
        expect { updated_lockfile_content }
          .to raise_error(Dependabot::DependencyFileNotEvaluatable)
      end
    end

    context "when updating the lockfile fails" do
      let(:dependency_version) { "99.0.0" }
      let(:requirements) do
        [{ file: "Cargo.toml", requirement: "99", groups: [], source: nil }]
      end

      it "raises a helpful error" do
        expect { updater.updated_lockfile_content }
          .to raise_error do |error|
            expect(error)
              .to be_a(Dependabot::SharedHelpers::HelperSubprocessFailed)
            expect(error.message).to include("failed to select a version")
          end
      end

      context "when an existing requirement is not sufficient" do
        let(:dependency_version) { "0.1.38" }
        let(:requirements) do
          [{ file: "Cargo.toml", requirement: "0.3.20", groups: [], source: nil }]
        end

        let(:manifest_fixture_name) { "missing_version" }
        let(:lockfile_fixture_name) { "missing_version" }

        it "raises a helpful error" do
          expect { updater.updated_lockfile_content }
            .to raise_error do |error|
              expect(error).to be_a(Dependabot::DependencyFileNotResolvable)
              expect(error.message)
                .to include("version for the requirement `regex = \"^99.0.0\"`")
            end
        end
      end
    end

    context "when the dependency doesn't exist" do
      random_unlikely_package_name = (0...255).map { ("a".."z").to_a[rand(26)] }.join
      content = <<~CONTENT
        [package]
        name = "foo"
        version = "0.1.0"
        authors = ["me"]

        [dependencies]
        #{random_unlikely_package_name} = "99.99.99"
      CONTENT

      let(:manifest) do
        Dependabot::DependencyFile.new(name: "Cargo.toml", content: content)
      end

      it "raises a helpful error" do
        expect { updated_lockfile_content }
          .to raise_error do |error|
            expect(error).to be_a(Dependabot::DependencyFileNotResolvable)
            expect(error.message).to include(random_unlikely_package_name)
          end
      end
    end

    context "when the package doesn't exist at the git source" do
      content = <<~CONTENT
        [package]
        name = "foo"
        version = "0.1.0"
        authors = ["me"]
        [dependencies]
        yewtil = { git = "https://github.com/yewstack/yew" }
      CONTENT

      let(:manifest) do
        Dependabot::DependencyFile.new(name: "Cargo.toml", content: content)
      end

      it "raises a helpful error" do
        expect { updated_lockfile_content }
          .to raise_error do |error|
          expect(error).to be_a(Dependabot::DependencyFileNotResolvable)
          expect(error.message).to include("yewtil")
        end
      end
    end

    describe "the updated lockfile" do
      it "updates the dependency version in the lockfile" do
        expect(updated_lockfile_content)
          .to include(%(name = "time"\nversion = "0.1.40"))
        expect(updated_lockfile_content).to include(
          <<~CHECKSUM
            checksum = "d825be0eb33fda1a7e68012d51e9c7f451dc1a69391e7fdc197060bb8c56667b"
          CHECKSUM
        )
        expect(updated_lockfile_content).not_to include(
          <<~CHECKSUM
            checksum = "d5d788d3aa77bc0ef3e9621256885555368b47bd495c13dd2e7413c89f845520"
          CHECKSUM
        )
      end

      context "with a binary specified" do
        let(:manifest_fixture_name) { "binary" }

        it "updates the dependency version in the lockfile" do
          expect(updated_lockfile_content)
            .to include(%(name = "time"\nversion = "0.1.40"))
        end
      end

      context "with a default-run specified" do
        let(:manifest_fixture_name) { "default_run" }

        it "updates the dependency version in the lockfile" do
          expect(updated_lockfile_content)
            .to include(%(name = "time"\nversion = "0.1.40"))
        end
      end

      context "with a target-specific dependency" do
        let(:manifest_fixture_name) { "target_dependency" }
        let(:lockfile_fixture_name) { "target_dependency" }
        let(:dependency_previous_version) { "0.1.12" }
        let(:requirements) do
          [{
            file: "Cargo.toml",
            requirement: "<= 0.1.38",
            groups: [],
            source: nil
          }]
        end
        let(:previous_requirements) do
          [{
            file: "Cargo.toml",
            requirement: "<= 0.1.12",
            groups: [],
            source: nil
          }]
        end

        it "updates the dependency version in the lockfile" do
          expect(updated_lockfile_content)
            .to include(%(name = "time"\nversion = "0.1.40"))
        end
      end

      context "with a blank requirement" do
        let(:manifest_fixture_name) { "blank_version" }
        let(:lockfile_fixture_name) { "blank_version" }
        let(:previous_requirements) do
          [{ file: "Cargo.toml", requirement: nil, groups: [], source: nil }]
        end

        it "raises a DependencyFileNotResolvable error" do
          expect { updated_lockfile_content }.to raise_error(Dependabot::DependencyFileNotResolvable) do |error|
            expect(error.message).to include("unexpected end of input while parsing major version")
          end
        end
      end

      context "with multiple versions available of the dependency" do
        let(:manifest_fixture_name) { "multiple_versions" }
        let(:lockfile_fixture_name) { "multiple_versions" }

        let(:dependency_name) { "rand" }
        let(:dependency_version) { "0.4.2" }
        let(:dependency_previous_version) { "0.4.1" }
        let(:requirements) { previous_requirements }
        let(:previous_requirements) do
          [{
            file: "Cargo.toml",
            requirement: "0.4",
            groups: [],
            source: nil
          }]
        end

        it "updates the dependency version in the lockfile" do
          expect(updated_lockfile_content)
            .to include(%(name = "rand"\nversion = "0.4.2"))
        end
      end

      context "with an old format lockfile" do
        let(:manifest_fixture_name) { "old_lockfile" }
        let(:lockfile_fixture_name) { "old_lockfile" }

        it "updates the lockfile to the new version" do
          expect(updated_lockfile_content).to include(
            <<~CHECKSUM
              checksum = "d825be0eb33fda1a7e68012d51e9c7f451dc1a69391e7fdc197060bb8c56667b"
            CHECKSUM
          )
          expect(updated_lockfile_content).not_to include("[metadata]")
        end
      end

      context "with a git dependency" do
        let(:manifest_fixture_name) { "git_dependency" }
        let(:lockfile_fixture_name) { "git_dependency" }

        let(:dependency_name) { "utf8-ranges" }
        let(:dependency_version) do
          "be9b8dfcaf449453cbf83ac85260ee80323f4f77"
        end
        let(:dependency_previous_version) do
          "83141b376b93484341c68fbca3ca110ae5cd2708"
        end
        let(:requirements) { previous_requirements }
        let(:previous_requirements) do
          [{
            file: "Cargo.toml",
            requirement: nil,
            groups: ["dependencies"],
            source: {
              type: "git",
              url: "https://github.com/BurntSushi/utf8-ranges",
              branch: nil,
              ref: nil
            }
          }]
        end

        it "updates the dependency version in the lockfile" do
          expect(updated_lockfile_content)
            .to include("utf8-ranges#be9b8dfcaf449453cbf83ac85260ee80323f4f77")
        end

        context "with an ssh URl" do
          let(:manifest_fixture_name) { "git_dependency_ssh" }
          let(:lockfile_fixture_name) { "git_dependency_ssh" }
          let(:requirements) { previous_requirements }
          let(:previous_requirements) do
            [{
              file: "Cargo.toml",
              requirement: nil,
              groups: ["dependencies"],
              source: {
                type: "git",
                url: "ssh://git@github.com/BurntSushi/utf8-ranges",
                branch: nil,
                ref: nil
              }
            }]
          end

          it "updates the dependency version in the lockfile" do
            expect(updated_lockfile_content)
              .to include(
                "git+ssh://git@github.com/BurntSushi/utf8-ranges#" \
                "be9b8dfcaf449453cbf83ac85260ee80323f4f77"
              )
            expect(updated_lockfile_content).not_to include("git+https://")

            content = updated_lockfile_content
            expect(content.scan('name = "utf8-ranges"').count).to eq(1)
          end
        end

        context "with an updated tag" do
          let(:manifest_fixture_name) { "git_dependency_with_tag" }
          let(:lockfile_fixture_name) { "git_dependency_with_tag" }
          let(:dependency_version) do
            "83141b376b93484341c68fbca3ca110ae5cd2708"
          end
          let(:dependency_previous_version) do
            "d5094c7e9456f2965dec20de671094a98c6929c2"
          end
          let(:requirements) do
            [{
              file: "Cargo.toml",
              requirement: nil,
              groups: ["dependencies"],
              source: {
                type: "git",
                url: "https://github.com/BurntSushi/utf8-ranges",
                branch: nil,
                ref: "1.0.0"
              }
            }]
          end
          let(:previous_requirements) do
            [{
              file: "Cargo.toml",
              requirement: nil,
              groups: ["dependencies"],
              source: {
                type: "git",
                url: "https://github.com/BurntSushi/utf8-ranges",
                branch: nil,
                ref: "0.1.3"
              }
            }]
          end

          it "updates the dependency version in the lockfile" do
            expect(updated_lockfile_content)
              .to include "?tag=1.0.0#83141b376b93484341c68fbca3ca110ae5cd2708"
          end
        end
      end

      context "when there is a path dependency" do
        let(:dependency_files) { [manifest, lockfile, path_dependency_file] }
        let(:manifest_fixture_name) { "path_dependency" }
        let(:lockfile_fixture_name) { "path_dependency" }
        let(:dependency) do
          Dependabot::Dependency.new(
            name: "regex",
            version: "0.2.10",
            requirements: [{
              file: "Cargo.toml",
              requirement: "=0.2.10",
              groups: [],
              source: nil
            }],
            previous_version: "0.1.38",
            previous_requirements: [{
              file: "Cargo.toml",
              requirement: "=0.1.38",
              groups: [],
              source: nil
            }],
            package_manager: "cargo"
          )
        end
        let(:path_dependency_file) do
          Dependabot::DependencyFile.new(
            name: "src/s3/Cargo.toml",
            content: fixture("manifests", "cargo-registry-s3")
          )
        end

        it "updates the dependency version in the lockfile" do
          expect(updated_lockfile_content)
            .to include(%(name = "regex"\nversion = "0.2.10"))
          expect(updated_lockfile_content).to include(
            "aec3f58d903a7d2a9dc2bf0e41a746f4530e0cab6b615494e058f67a3ef947fb"
          )
          expect(updated_lockfile_content).not_to include(
            "bc2a4457b0c25dae6fee3dcd631ccded31e97d689b892c26554e096aa08dd136"
          )
        end
      end

      context "when there is a linked dependency" do
        let(:dependency_files) { [manifest, lockfile] }
        let(:manifest_fixture_name) { "linked_dependency" }

        it "updates the dependency version in the lockfile" do
          expect(updated_lockfile_content)
            .to include(%(name = "time"\nversion = "0.1.40"))
        end
      end

      context "when there is a workspace" do
        let(:dependency_files) { [manifest, lockfile, workspace_child] }
        let(:manifest_fixture_name) { "workspace_root" }
        let(:lockfile_fixture_name) { "workspace" }
        let(:workspace_child) do
          Dependabot::DependencyFile.new(
            name: "lib/sub_crate/Cargo.toml",
            content: fixture("manifests", "workspace_child")
          )
        end
        let(:dependency) do
          Dependabot::Dependency.new(
            name: "log",
            version: "0.4.1",
            requirements: [{
              requirement: "=0.4.1",
              file: "lib/sub_crate/Cargo.toml",
              groups: ["dependencies"],
              source: nil
            }],
            previous_version: "0.4.0",
            previous_requirements: [{
              requirement: "=0.4.0",
              file: "lib/sub_crate/Cargo.toml",
              groups: ["dependencies"],
              source: nil
            }],
            package_manager: "cargo"
          )
        end

        it "updates the dependency version in the lockfile" do
          expect(updated_lockfile_content)
            .to include(%(name = "log"\nversion = "0.4.1"))
          expect(updated_lockfile_content).to include(
            "89f010e843f2b1a31dbd316b3b8d443758bc634bed37aabade59c686d644e0a2"
          )
          expect(updated_lockfile_content).not_to include(
            "b3a89a0c46ba789b8a247d4c567aed4d7c68e624672d238b45cc3ec20dc9f940"
          )
        end
      end

      context "when there's a virtual workspace" do
        let(:manifest_fixture_name) { "virtual_workspace_root" }
        let(:lockfile_fixture_name) { "virtual_workspace" }
        let(:dependency_files) do
          [manifest, lockfile, workspace_child]
        end
        let(:workspace_child) do
          Dependabot::DependencyFile.new(
            name: "src/sub_crate/Cargo.toml",
            content: fixture("manifests", "workspace_child")
          )
        end

        let(:dependency) do
          Dependabot::Dependency.new(
            name: "log",
            version: "0.4.1",
            requirements: [{
              requirement: "=0.4.1",
              file: "src/sub_crate/Cargo.toml",
              groups: ["dependencies"],
              source: nil
            }],
            previous_version: "0.4.0",
            previous_requirements: [{
              requirement: "=0.4.0",
              file: "src/sub_crate/Cargo.toml",
              groups: ["dependencies"],
              source: nil
            }],
            package_manager: "cargo"
          )
        end

        it "updates the dependency version in the lockfile" do
          expect(updated_lockfile_content)
            .to include(%(name = "log"\nversion = "0.4.1"))
          expect(updated_lockfile_content).to include(
            "89f010e843f2b1a31dbd316b3b8d443758bc634bed37aabade59c686d644e0a2"
          )
          expect(updated_lockfile_content).not_to include(
            "b3a89a0c46ba789b8a247d4c567aed4d7c68e624672d238b45cc3ec20dc9f940"
          )
        end
      end

      context "when cargo selects a different version due to dependency constraints" do
        subject(:result) { updated_lockfile_content }

        let(:previous_version) { Gem::Version.new(dependency_previous_version) }
        let(:time_entry_regex) { /\[\[package\]\].*?name = "time".*?version = "(.*?)"/m }

        context "when cargo selects a newer but different version" do
          it "updates the dependency successfully" do
            expect { result }.not_to raise_error

            version_str = result.match(time_entry_regex)[1]
            expect(Gem::Version.new(version_str)).to be > previous_version
          end

          # This test would fail without our implementation when cargo selects
          # a different version than expected (e.g., 0.1.39 instead of 0.1.40)
          context "when simulating version constraint scenario" do
            let(:test_updater) do
              described_class.new(
                dependencies: [dependency],
                dependency_files: dependency_files,
                credentials: [{
                  "type" => "git_source",
                  "host" => "github.com"
                }]
              )
            end

            before do
              # Mock the cargo update to return a different version
              allow(test_updater).to receive(:run_cargo_command) do |command, _fingerprint|
                if command.include?("cargo update")
                  # Write a lockfile with 0.1.39 instead of expected 0.1.40
                  constrained_lockfile = lockfile_body
                                         .gsub("0.1.38", "0.1.39")
                                         .gsub("d5d788d3aa77bc0ef3e9621256885555368b47bd495c13dd2e7413c89f845520",
                                               "a36d7c4b3b2b3bb5f51754f7b778dcbf88e99cd3")
                  File.write("Cargo.lock", constrained_lockfile)
                end
              end
            end

            it "still accepts the update when version differs from expected" do
              # This would raise "Failed to update time!" without our fix
              result = test_updater.updated_lockfile_content
              expect(result).to include('version = "0.1.39"')
            end
          end
        end

        describe ".LOCKFILE_ENTRY_REGEX" do
          let(:sample) do
            <<~LOCK
              [[package]]
              name = "time"
              version = "0.1.39"
              source = "registry+https://github.com/rust-lang/crates.io-index"
              checksum = "abc123"
            LOCK
          end

          it "matches a package stanza" do
            expect(sample).to match(described_class::LOCKFILE_ENTRY_REGEX)
          end

          it "captures multiple package entries" do
            double_sample = sample + "\n" + sample.gsub("time", "other").gsub("0.1.39", "1.0.0")
            matches = double_sample.scan(described_class::LOCKFILE_ENTRY_REGEX)
            expect(matches.size).to eq(2)
          end
        end

        describe "version comparison behavior" do
          it "correctly identifies newer versions" do
            expect(Gem::Version.new("0.1.39")).to be > previous_version
            expect(Gem::Version.new("0.1.40")).to be > previous_version
            expect(Gem::Version.new("0.1.37")).not_to be > previous_version
          end
        end

        context "when an impossible version is requested" do
          let(:dependency_version) { "99.0.0" }
          let(:requirements) do
            [{ file: "Cargo.toml", requirement: "99.0.0", groups: [], source: nil }]
          end

          it "raises HelperSubprocessFailed" do
            expect { result }.to raise_error(Dependabot::SharedHelpers::HelperSubprocessFailed)
          end
        end
      end

      context "when there are multiple packages with ambiguous specification" do
        it "catches ambiguous specification early and raises DependencyFileNotEvaluatable" do
          # Mock cargo command to return ambiguous specification output
          ambiguous_output = <<~OUTPUT
            error: There are multiple `orion_conf` packages in your project, and the specification `orion_conf@0.1.5` is ambiguous.
            Please re-run this command with one of the following specifications:
             registry+https://github.com/rust-lang/crates.io-index#orion_conf@0.1.5
             git+https://github.com/galaxy-sec/orion-conf.git?tag=v0.1.5#orion_conf@0.1.5
          OUTPUT

          # Mock the run_cargo_command method directly to simulate the ambiguous error
          allow(updater).to receive(:run_cargo_command) do |_command, _options|
            # Simulate what run_cargo_command does when it encounters ambiguous specification
            regex = /There are multiple `([^`]+)` packages.*specification `([^`]+)` is ambiguous/
            if regex.match?(ambiguous_output)
              match = ambiguous_output.match(regex)
              raise Dependabot::DependencyFileNotEvaluatable, "Ambiguous package specification: #{match[2]}"
            end
          end

          # Expect it to raise DependencyFileNotEvaluatable with the short message
          expect do
            updater.updated_lockfile_content
          end.to raise_error(
            Dependabot::DependencyFileNotEvaluatable,
            "Ambiguous package specification: orion_conf@0.1.5"
          )
        end
      end
    end
  end
end
