diff --git a/src/clippy.ts b/src/clippy.ts new file mode 100644 index 00000000..7c7b4963 --- /dev/null +++ b/src/clippy.ts @@ -0,0 +1,121 @@ +import * as core from "@actions/core"; +import * as exec from "@actions/exec"; +import { Cargo, Cross } from "@actions-rs-plus/core"; + +import type * as input from "./input"; +import { OutputParser } from "./outputParser"; +import { Reporter } from "./reporter"; +import type { AnnotationWithMessageAndLevel, Context, Stats } from "./schema"; + +type Program = Cargo | Cross; + +interface ClippyResult { + stats: Stats; + annotations: AnnotationWithMessageAndLevel[]; + exitCode: number; +} + +async function buildContext(program: Program): Promise { + const context: Context = { + cargo: "", + clippy: "", + rustc: "", + }; + + await Promise.all([ + await exec.exec("rustc", ["-V"], { + silent: true, + listeners: { + stdout: (buffer: Buffer) => { + return (context.rustc = buffer.toString().trim()); + }, + }, + }), + await program.call(["-V"], { + silent: true, + listeners: { + stdout: (buffer: Buffer) => { + return (context.cargo = buffer.toString().trim()); + }, + }, + }), + await program.call(["clippy", "-V"], { + silent: true, + listeners: { + stdout: (buffer: Buffer) => { + return (context.clippy = buffer.toString().trim()); + }, + }, + }), + ]); + + return context; +} + +async function runClippy(actionInput: input.ParsedInput, program: Program): Promise { + const args = buildArgs(actionInput); + const outputParser = new OutputParser(); + + let exitCode = 0; + + try { + core.startGroup("Executing cargo clippy (JSON output)"); + exitCode = await program.call(args, { + ignoreReturnCode: true, + failOnStdErr: false, + listeners: { + stdline: (line: string) => { + outputParser.tryParseClippyLine(line); + }, + }, + }); + } finally { + core.endGroup(); + } + + return { + stats: outputParser.stats, + annotations: outputParser.annotations, + exitCode, + }; +} + +function getProgram(useCross: boolean): Promise { + if (useCross) { + return Cross.getOrInstall(); + } else { + return Cargo.get(); + } +} + +export async function run(actionInput: input.ParsedInput): Promise { + const program: Program = await getProgram(actionInput.useCross); + + const context = await buildContext(program); + + const { stats, annotations, exitCode } = await runClippy(actionInput, program); + + await new Reporter().report(stats, annotations, context); + + if (exitCode !== 0) { + throw new Error(`Clippy had exited with the ${exitCode} exit code`); + } +} + +function buildArgs(actionInput: input.ParsedInput): string[] { + const args: string[] = []; + + // Toolchain selection MUST go first in any condition + if (actionInput.toolchain) { + args.push(`+${actionInput.toolchain}`); + } + + args.push("clippy"); + + // `--message-format=json` should just right after the `cargo clippy` + // because usually people are adding the `-- -D warnings` at the end + // of arguments and it will mess up the output. + args.push("--message-format=json"); + + return args.concat(actionInput.args); +} diff --git a/src/main.ts b/src/main.ts index fa04fbe8..6c4ea8a9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,106 +1,8 @@ import * as core from "@actions/core"; -import * as exec from "@actions/exec"; -import { Cargo, Cross } from "@actions-rs-plus/core"; import * as input from "./input"; -import { OutputParser } from "./outputParser"; -import { Reporter } from "./reporter"; -import type { AnnotationWithMessageAndLevel, Context, Stats } from "./schema"; -type Program = Cargo | Cross; - -interface ClippyResult { - stats: Stats; - annotations: AnnotationWithMessageAndLevel[]; - exitCode: number; -} - -async function buildContext(program: Program): Promise { - const context: Context = { - cargo: "", - clippy: "", - rustc: "", - }; - - await Promise.all([ - await exec.exec("rustc", ["-V"], { - silent: true, - listeners: { - stdout: (buffer: Buffer) => { - return (context.rustc = buffer.toString().trim()); - }, - }, - }), - await program.call(["-V"], { - silent: true, - listeners: { - stdout: (buffer: Buffer) => { - return (context.cargo = buffer.toString().trim()); - }, - }, - }), - await program.call(["clippy", "-V"], { - silent: true, - listeners: { - stdout: (buffer: Buffer) => { - return (context.clippy = buffer.toString().trim()); - }, - }, - }), - ]); - - return context; -} - -async function runClippy(actionInput: input.ParsedInput, program: Program): Promise { - const args = buildArgs(actionInput); - const outputParser = new OutputParser(); - - let exitCode = 0; - - try { - core.startGroup("Executing cargo clippy (JSON output)"); - exitCode = await program.call(args, { - ignoreReturnCode: true, - failOnStdErr: false, - listeners: { - stdline: (line: string) => { - outputParser.tryParseClippyLine(line); - }, - }, - }); - } finally { - core.endGroup(); - } - - return { - stats: outputParser.stats, - annotations: outputParser.annotations, - exitCode, - }; -} - -function getProgram(useCross: boolean): Promise { - if (useCross) { - return Cross.getOrInstall(); - } else { - return Cargo.get(); - } -} - -export async function run(actionInput: input.ParsedInput): Promise { - const program: Program = await getProgram(actionInput.useCross); - - const context = await buildContext(program); - - const { stats, annotations, exitCode } = await runClippy(actionInput, program); - - await new Reporter().report(stats, annotations, context); - - if (exitCode !== 0) { - throw new Error(`Clippy had exited with the ${exitCode} exit code`); - } -} +import { run } from "clippy"; async function main(): Promise { try { @@ -117,22 +19,4 @@ async function main(): Promise { } } -function buildArgs(actionInput: input.ParsedInput): string[] { - const args: string[] = []; - - // Toolchain selection MUST go first in any condition - if (actionInput.toolchain) { - args.push(`+${actionInput.toolchain}`); - } - - args.push("clippy"); - - // `--message-format=json` should just right after the `cargo clippy` - // because usually people are adding the `-- -D warnings` at the end - // of arguments and it will mess up the output. - args.push("--message-format=json"); - - return args.concat(actionInput.args); -} - void main(); diff --git a/src/schema.ts b/src/schema.ts index a8e09635..ca9a2963 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -24,7 +24,7 @@ export interface MaybeCargoMessage { } export interface CargoMessage { - reason: string; + reason: "compiler-message"; message: { code: string; level: string; diff --git a/src/tests/clippy.test.ts b/src/tests/clippy.test.ts new file mode 100644 index 00000000..fa9c6776 --- /dev/null +++ b/src/tests/clippy.test.ts @@ -0,0 +1,122 @@ +import * as exec from "@actions/exec"; + +import { run } from "clippy"; +import { type ParsedInput } from "input"; +import { Reporter } from "reporter"; +import { type CargoMessage } from "schema"; + +jest.mock("@actions/core"); +jest.mock("@actions/exec"); +jest.mock("reporter"); + +describe("clippy", () => { + it("runs with cargo", async () => { + jest.spyOn(exec, "exec").mockResolvedValue(0); + + const actionInput: ParsedInput = { + toolchain: "stable", + args: [], + useCross: false, + }; + + await expect(run(actionInput)).resolves.toBeUndefined(); + }); + + it("runs with cross", async () => { + jest.spyOn(exec, "exec").mockResolvedValue(0); + + const actionInput: ParsedInput = { + toolchain: "stable", + args: [], + useCross: true, + }; + + await expect(run(actionInput)).resolves.toBeUndefined(); + }); + + it("reports when clippy fails", async () => { + jest.spyOn(exec, "exec").mockImplementation((_commandline: string, args?: string[] | undefined) => { + const expected = ["clippy", "--message-format=json"]; + + if ( + (args ?? []).length > 0 && + expected.every((c) => { + return args?.includes(c); + }) + ) { + return Promise.resolve(101); + } else { + return Promise.resolve(0); + } + }); + + const actionInput: ParsedInput = { + toolchain: "stable", + args: [], + useCross: false, + }; + + await expect(run(actionInput)).rejects.toThrow(/Clippy had exited with the (\d)+ exit code/); + }); + + it("records versions", async () => { + const reportSpy = jest.spyOn(Reporter.prototype, "report"); + jest.spyOn(exec, "exec").mockImplementation((commandline: string, args?: string[], options?: exec.ExecOptions) => { + if (commandline.endsWith("cargo")) { + if (args?.[0] === "-V") { + options?.listeners?.stdout?.(Buffer.from("cargo version")); + } else if (args?.[0] === "clippy" && args?.[1] === "-V") { + options?.listeners?.stdout?.(Buffer.from("clippy version")); + } + } else if (commandline === "rustc" && args?.[0] === "-V") { + options?.listeners?.stdout?.(Buffer.from("rustc version")); + } + return Promise.resolve(0); + }); + + const actionInput: ParsedInput = { + toolchain: "stable", + args: [], + useCross: false, + }; + + await expect(run(actionInput)).resolves.toBeUndefined(); + + expect(reportSpy).toBeCalledWith({ error: 0, help: 0, ice: 0, note: 0, warning: 0 }, [], { cargo: "cargo version", clippy: "clippy version", rustc: "rustc version" }); + }); + + it("clippy captures stdout", async () => { + jest.spyOn(exec, "exec").mockImplementation((_commandline: string, args?: string[] | undefined, options?: exec.ExecOptions) => { + const expected = ["clippy", "--message-format=json"]; + + if ( + (args ?? []).length > 0 && + expected.every((c) => { + return args?.includes(c); + }) + ) { + const data: CargoMessage = { + reason: "compiler-message", + message: { + code: "500", + level: "warning", + message: "message", + rendered: "rendered", + spans: [{ is_primary: true, file_name: "main.rs", line_start: 12, line_end: 12, column_start: 30, column_end: 45 }], + }, + }; + options?.listeners?.stdline?.(JSON.stringify(data)); + } + + return Promise.resolve(0); + }); + + const actionInput: ParsedInput = { + toolchain: "stable", + args: [], + useCross: false, + }; + + await expect(run(actionInput)).resolves.toBeUndefined(); + }); +}); diff --git a/src/tests/main.test.ts b/src/tests/main.test.ts new file mode 100644 index 00000000..9b8e7b6d --- /dev/null +++ b/src/tests/main.test.ts @@ -0,0 +1,43 @@ +import * as core from "@actions/core"; + +import * as clippy from "clippy"; + +jest.mock("clippy"); +jest.mock("input"); +jest.mock("@actions/core"); + +describe("main", () => { + it("works", async () => { + const runSpy = jest.spyOn(clippy, "run"); + + await jest.isolateModulesAsync(async () => { + await import("main"); + }); + + expect(runSpy).toHaveBeenCalledTimes(1); + }); + + it("catches Error", async () => { + jest.spyOn(clippy, "run").mockRejectedValue(new Error("It looks like you're running a test")); + + const setFailedSpy = jest.spyOn(core, "setFailed"); + + await jest.isolateModulesAsync(async () => { + await import("main"); + }); + + expect(setFailedSpy).toHaveBeenCalledWith("It looks like you're running a test"); + }); + + it("catches not-error", async () => { + jest.spyOn(clippy, "run").mockRejectedValue("It looks like you're trying to write a test, would you like some assistance? [YES / NO]"); + + const setFailedSpy = jest.spyOn(core, "setFailed"); + + await jest.isolateModulesAsync(async () => { + await import("main"); + }); + + expect(setFailedSpy).toHaveBeenCalledWith("It looks like you're trying to write a test, would you like some assistance? [YES / NO]"); + }); +});