From f619736677af1b0981b2226221681c849ae79a28 Mon Sep 17 00:00:00 2001
From: Jason Sipula <SnakeDoc@users.noreply.github.com>
Date: Mon, 26 Aug 2024 02:22:11 -0700
Subject: [PATCH] feat(manager/gleam): extract locked versions (#31000)

Co-authored-by: Rhys Arkins <rhys@arkins.net>
Co-authored-by: Michael Kriese <michael.kriese@visualon.de>
---
 lib/modules/manager/gleam/extract.spec.ts     | 164 +++++++++++++++++-
 lib/modules/manager/gleam/extract.ts          |  59 ++++++-
 .../manager/gleam/locked-version.spec.ts      |  74 ++++++++
 lib/modules/manager/gleam/locked-version.ts   |  36 ++++
 lib/modules/manager/gleam/schema.ts           |  13 ++
 5 files changed, 337 insertions(+), 9 deletions(-)
 create mode 100644 lib/modules/manager/gleam/locked-version.spec.ts
 create mode 100644 lib/modules/manager/gleam/locked-version.ts

diff --git a/lib/modules/manager/gleam/extract.spec.ts b/lib/modules/manager/gleam/extract.spec.ts
index 7e91b12ce9..ecd61afc62 100644
--- a/lib/modules/manager/gleam/extract.spec.ts
+++ b/lib/modules/manager/gleam/extract.spec.ts
@@ -1,8 +1,14 @@
 import { codeBlock } from 'common-tags';
+import { mocked } from '../../../../test/util';
+import * as _fs from '../../../util/fs';
 import * as gleamManager from '.';
 
+jest.mock('../../../util/fs');
+
+const fs = mocked(_fs);
+
 describe('modules/manager/gleam/extract', () => {
-  it('should extract dev and prod dependencies', () => {
+  it('should extract dev and prod dependencies', async () => {
     const gleamTomlString = codeBlock`
       name = "test_gleam_toml"
       version = "1.0.0"
@@ -13,7 +19,12 @@ describe('modules/manager/gleam/extract', () => {
       [dev-dependencies]
       gleeunit = "~> 1.0"
     `;
-    const extracted = gleamManager.extractPackageFile(gleamTomlString);
+
+    fs.readLocalFile.mockResolvedValueOnce(gleamTomlString);
+    const extracted = await gleamManager.extractPackageFile(
+      gleamTomlString,
+      'gleam.toml',
+    );
     expect(extracted?.deps).toEqual([
       {
         currentValue: '~> 0.6.0',
@@ -30,7 +41,7 @@ describe('modules/manager/gleam/extract', () => {
     ]);
   });
 
-  it('should extract dev only dependencies', () => {
+  it('should extract dev only dependencies', async () => {
     const gleamTomlString = codeBlock`
       name = "test_gleam_toml"
       version = "1.0.0"
@@ -38,7 +49,12 @@ describe('modules/manager/gleam/extract', () => {
       [dev-dependencies]
       gleeunit = "~> 1.0"
     `;
-    const extracted = gleamManager.extractPackageFile(gleamTomlString);
+
+    fs.readLocalFile.mockResolvedValueOnce(gleamTomlString);
+    const extracted = await gleamManager.extractPackageFile(
+      gleamTomlString,
+      'gleam.toml',
+    );
     expect(extracted?.deps).toEqual([
       {
         currentValue: '~> 1.0',
@@ -49,7 +65,7 @@ describe('modules/manager/gleam/extract', () => {
     ]);
   });
 
-  it('should return null when no dependencies are found', () => {
+  it('should return null when no dependencies are found', async () => {
     const gleamTomlString = codeBlock`
       name = "test_gleam_toml"
       version = "1.0.0"
@@ -57,7 +73,143 @@ describe('modules/manager/gleam/extract', () => {
       [unknown]
       gleam_http = "~> 3.6.0"
     `;
-    const extracted = gleamManager.extractPackageFile(gleamTomlString);
+
+    fs.readLocalFile.mockResolvedValueOnce(gleamTomlString);
+    const extracted = await gleamManager.extractPackageFile(
+      gleamTomlString,
+      'gleam.toml',
+    );
+    expect(extracted).toBeNull();
+  });
+
+  it('should return null when gleam.toml is invalid', async () => {
+    fs.readLocalFile.mockResolvedValueOnce('foo');
+    const extracted = await gleamManager.extractPackageFile(
+      'foo',
+      'gleam.toml',
+    );
     expect(extracted).toBeNull();
   });
+
+  it('should return locked versions', async () => {
+    const packageFileContent = codeBlock`
+      name = "test_gleam_toml"
+      version = "1.0.0"
+
+      [dependencies]
+      foo = ">= 1.0.0 and < 2.0.0"
+    `;
+    const lockFileContent = codeBlock`
+      packages = [
+        { name = "foo", version = "1.0.4", build_tools = ["gleam"], requirements = ["bar"], otp_app = "foo", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" },
+        { name = "bar", version = "2.1.0", build_tools = ["rebar3"], requirements = [], otp_app = "bar", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" },
+      ]
+
+      [requirements]
+      foo = { version = ">= 1.0.0 and < 2.0.0" }
+    `;
+
+    fs.getSiblingFileName.mockReturnValueOnce('manifest.toml');
+    fs.readLocalFile.mockResolvedValueOnce(lockFileContent);
+    fs.localPathExists.mockResolvedValueOnce(true);
+    const extracted = await gleamManager.extractPackageFile(
+      packageFileContent,
+      'gleam.toml',
+    );
+    expect(extracted!.deps.every((dep) => 'lockedVersion' in dep)).toBe(true);
+  });
+
+  it('should fail to extract locked version', async () => {
+    const packageFileContent = codeBlock`
+      name = "test_gleam_toml"
+      version = "1.0.0"
+
+      [dependencies]
+      foo = ">= 1.0.0 and < 2.0.0"
+    `;
+
+    fs.getSiblingFileName.mockReturnValueOnce('manifest.toml');
+    fs.readLocalFile.mockResolvedValueOnce(null);
+    fs.localPathExists.mockResolvedValueOnce(true);
+    const extracted = await gleamManager.extractPackageFile(
+      packageFileContent,
+      'gleam.toml',
+    );
+    expect(extracted!.deps.every((dep) => 'lockedVersion' in dep)).toBe(false);
+  });
+
+  it('should fail to find locked version in range', async () => {
+    const packageFileContent = codeBlock`
+      name = "test_gleam_toml"
+      version = "1.0.0"
+
+      [dependencies]
+      foo = ">= 1.0.0 and < 2.0.0"
+    `;
+    const lockFileContent = codeBlock`
+      packages = [
+        { name = "foo", version = "2.0.1", build_tools = ["gleam"], requirements = ["bar"], otp_app = "foo", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" },
+        { name = "bar", version = "2.1.0", build_tools = ["rebar3"], requirements = [], otp_app = "bar", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" },
+      ]
+
+      [requirements]
+      foo = { version = ">= 1.0.0 and < 2.0.0" }
+    `;
+
+    fs.getSiblingFileName.mockReturnValueOnce('manifest.toml');
+    fs.readLocalFile.mockResolvedValueOnce(lockFileContent);
+    fs.localPathExists.mockResolvedValueOnce(true);
+    const extracted = await gleamManager.extractPackageFile(
+      packageFileContent,
+      'gleam.toml',
+    );
+    expect(extracted!.deps.every((dep) => 'lockedVersion' in dep)).toBe(false);
+  });
+
+  it('should handle invalid versions in lock file', async () => {
+    const packageFileContent = codeBlock`
+      name = "test_gleam_toml"
+      version = "1.0.0"
+
+      [dependencies]
+      foo = ">= 1.0.0 and < 2.0.0"
+    `;
+    const lockFileContent = codeBlock`
+      packages = [
+        { name = "foo", version = "fooey", build_tools = ["gleam"], requirements = [], otp_app = "foo", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" },
+      ]
+
+      [requirements]
+      foo = { version = ">= 1.0.0 and < 2.0.0" }
+    `;
+
+    fs.getSiblingFileName.mockReturnValueOnce('manifest.toml');
+    fs.readLocalFile.mockResolvedValueOnce(lockFileContent);
+    fs.localPathExists.mockResolvedValueOnce(true);
+    const extracted = await gleamManager.extractPackageFile(
+      packageFileContent,
+      'gleam.toml',
+    );
+    expect(extracted!.deps).not.toHaveProperty('lockedVersion');
+  });
+
+  it('should handle lock file parsing and extracting errors', async () => {
+    const packageFileContent = codeBlock`
+      name = "test_gleam_toml"
+      version = "1.0.0"
+
+      [dependencies]
+      foo = ">= 1.0.0 and < 2.0.0"
+    `;
+    const lockFileContent = codeBlock`invalid`;
+
+    fs.getSiblingFileName.mockReturnValueOnce('manifest.toml');
+    fs.readLocalFile.mockResolvedValueOnce(lockFileContent);
+    fs.localPathExists.mockResolvedValueOnce(true);
+    const extracted = await gleamManager.extractPackageFile(
+      packageFileContent,
+      'gleam.toml',
+    );
+    expect(extracted!.deps).not.toHaveProperty('lockedVersion');
+  });
 });
diff --git a/lib/modules/manager/gleam/extract.ts b/lib/modules/manager/gleam/extract.ts
index 5e44489857..0e8e05aaf5 100644
--- a/lib/modules/manager/gleam/extract.ts
+++ b/lib/modules/manager/gleam/extract.ts
@@ -1,5 +1,10 @@
+import { logger } from '../../../logger';
+import { coerceArray } from '../../../util/array';
+import { getSiblingFileName, localPathExists } from '../../../util/fs';
 import { HexDatasource } from '../../datasource/hex';
+import { api as versioning } from '../../versioning/hex';
 import type { PackageDependency, PackageFileContent } from '../types';
+import { extractLockFileVersions } from './locked-version';
 import { GleamToml } from './schema';
 
 const dependencySections = ['dependencies', 'dev-dependencies'] as const;
@@ -53,7 +58,55 @@ function extractGleamTomlDeps(gleamToml: GleamToml): PackageDependency[] {
   );
 }
 
-export function extractPackageFile(content: string): PackageFileContent | null {
-  const deps = extractGleamTomlDeps(GleamToml.parse(content));
-  return deps.length ? { deps } : null;
+export async function extractPackageFile(
+  content: string,
+  packageFile: string,
+): Promise<PackageFileContent | null> {
+  const result = GleamToml.safeParse(content);
+  if (!result.success) {
+    logger.debug(
+      { err: result.error, packageFile },
+      'Error parsing Gleam package file content',
+    );
+    return null;
+  }
+
+  const deps = extractGleamTomlDeps(result.data);
+  if (!deps.length) {
+    logger.debug(`No dependencies found in Gleam package file ${packageFile}`);
+    return null;
+  }
+
+  const packageFileContent: PackageFileContent = { deps };
+  const lockFileName = getSiblingFileName(packageFile, 'manifest.toml');
+
+  const lockFileExists = await localPathExists(lockFileName);
+  if (!lockFileExists) {
+    logger.debug(`Lock file ${lockFileName} does not exist.`);
+    return packageFileContent;
+  }
+
+  const versionsByPackage = await extractLockFileVersions(lockFileName);
+  if (!versionsByPackage) {
+    return packageFileContent;
+  }
+
+  packageFileContent.lockFiles = [lockFileName];
+
+  for (const dep of packageFileContent.deps) {
+    const packageName = dep.depName!;
+    const versions = coerceArray(versionsByPackage.get(packageName));
+    const lockedVersion = versioning.getSatisfyingVersion(
+      versions,
+      dep.currentValue!,
+    );
+    if (lockedVersion) {
+      dep.lockedVersion = lockedVersion;
+    } else {
+      logger.debug(
+        `No locked version found for package ${dep.depName} in the range of ${dep.currentValue}.`,
+      );
+    }
+  }
+  return packageFileContent;
 }
diff --git a/lib/modules/manager/gleam/locked-version.spec.ts b/lib/modules/manager/gleam/locked-version.spec.ts
new file mode 100644
index 0000000000..3203d04f14
--- /dev/null
+++ b/lib/modules/manager/gleam/locked-version.spec.ts
@@ -0,0 +1,74 @@
+import { codeBlock } from 'common-tags';
+import { mocked } from '../../../../test/util';
+import { logger } from '../../../logger';
+import * as _fs from '../../../util/fs';
+import { extractLockFileVersions, parseLockFile } from './locked-version';
+
+jest.mock('../../../util/fs');
+
+const fs = mocked(_fs);
+
+const lockFileContent = codeBlock`
+  packages = [
+    { name = "foo", version = "1.0.4", build_tools = ["gleam"], requirements = ["bar"], otp_app = "foo", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" },
+    { name = "bar", version = "2.1.0", build_tools = ["rebar3"], requirements = [], otp_app = "bar", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" },
+  ]
+
+  [requirements]
+  foo = { version = ">= 1.0.0 and < 2.0.0" }
+`;
+
+describe('modules/manager/gleam/locked-version', () => {
+  describe('extractLockFileVersions()', () => {
+    it('returns null for missing lock file', async () => {
+      expect(await extractLockFileVersions('manifest.toml')).toBeNull();
+    });
+
+    it('returns null for invalid lock file', async () => {
+      fs.readLocalFile.mockResolvedValueOnce('foo');
+      expect(await extractLockFileVersions('manifest.toml')).toBeNull();
+    });
+
+    it('returns empty map for lock file without packages', async () => {
+      fs.readLocalFile.mockResolvedValueOnce('[requirements]');
+      expect(await extractLockFileVersions('manifest.toml')).toEqual(new Map());
+    });
+
+    it('returns a map of package versions', async () => {
+      fs.readLocalFile.mockResolvedValueOnce(lockFileContent);
+      expect(await extractLockFileVersions('manifest.toml')).toEqual(
+        new Map([
+          ['foo', ['1.0.4']],
+          ['bar', ['2.1.0']],
+        ]),
+      );
+    });
+  });
+
+  describe('parseLockFile', () => {
+    it('parses lockfile string into an object', () => {
+      const parseLockFileResult = parseLockFile(lockFileContent);
+      logger.debug({ parseLockFileResult }, 'parseLockFile');
+      expect(parseLockFileResult).toStrictEqual({
+        packages: [
+          {
+            name: 'foo',
+            version: '1.0.4',
+            requirements: ['bar'],
+          },
+          {
+            name: 'bar',
+            version: '2.1.0',
+            requirements: [],
+          },
+        ],
+      });
+    });
+
+    it('can deal with invalid lockfiles', () => {
+      const lockFile = 'foo';
+      const parseLockFileResult = parseLockFile(lockFile);
+      expect(parseLockFileResult).toBeNull();
+    });
+  });
+});
diff --git a/lib/modules/manager/gleam/locked-version.ts b/lib/modules/manager/gleam/locked-version.ts
new file mode 100644
index 0000000000..e36cbc3faa
--- /dev/null
+++ b/lib/modules/manager/gleam/locked-version.ts
@@ -0,0 +1,36 @@
+import { logger } from '../../../logger';
+import { coerceArray } from '../../../util/array';
+import { readLocalFile } from '../../../util/fs';
+import { ManifestToml } from './schema';
+
+export async function extractLockFileVersions(
+  lockFilePath: string,
+): Promise<Map<string, string[]> | null> {
+  const content = await readLocalFile(lockFilePath, 'utf8');
+  if (!content) {
+    logger.debug(`Gleam lock file ${lockFilePath} not found`);
+    return null;
+  }
+
+  const versionsByPackage = new Map<string, string[]>();
+  const lock = parseLockFile(content);
+  if (!lock) {
+    logger.debug(`Error parsing Gleam lock file ${lockFilePath}`);
+    return null;
+  }
+  for (const pkg of coerceArray(lock.packages)) {
+    const versions = coerceArray(versionsByPackage.get(pkg.name));
+    versions.push(pkg.version);
+    versionsByPackage.set(pkg.name, versions);
+  }
+  return versionsByPackage;
+}
+
+export function parseLockFile(lockFileContent: string): ManifestToml | null {
+  const res = ManifestToml.safeParse(lockFileContent);
+  if (res.success) {
+    return res.data;
+  }
+  logger.debug({ err: res.error }, 'Error parsing manifest.toml.');
+  return null;
+}
diff --git a/lib/modules/manager/gleam/schema.ts b/lib/modules/manager/gleam/schema.ts
index 3a53f7e135..a80a64a6c4 100644
--- a/lib/modules/manager/gleam/schema.ts
+++ b/lib/modules/manager/gleam/schema.ts
@@ -9,4 +9,17 @@ export const GleamToml = Toml.pipe(
   }),
 );
 
+const Package = z.object({
+  name: z.string(),
+  version: z.string(),
+  requirements: z.array(z.string()).optional(),
+});
+
+export const ManifestToml = Toml.pipe(
+  z.object({
+    packages: z.array(Package).optional(),
+  }),
+);
+
 export type GleamToml = z.infer<typeof GleamToml>;
+export type ManifestToml = z.infer<typeof ManifestToml>;
-- 
GitLab