diff --git a/.changeset/smooth-showers-double.md b/.changeset/smooth-showers-double.md new file mode 100644 index 0000000..9e928ad --- /dev/null +++ b/.changeset/smooth-showers-double.md @@ -0,0 +1,5 @@ +--- +"@multiverse-io/cari": patch +--- + +Gracefully handle user not entering any repo URLs during init diff --git a/src/commands/init.test.ts b/src/commands/init.test.ts index 2fbca18..47bd740 100644 --- a/src/commands/init.test.ts +++ b/src/commands/init.test.ts @@ -83,7 +83,7 @@ describe("init command", () => { }, ]); await init(); - expect(pathExists(`${homeDir}/.cari`)).resolves.toBe(true); + await expect(pathExists(`${homeDir}/.cari`)).resolves.toBe(true); expect(gitMock.clone).toHaveBeenCalledWith( repoUrl, `${homeDir}/.cari/my-org/ai-rules` @@ -183,4 +183,27 @@ describe("init command", () => { "No rules were selected to include. Please select at least one rule to include with or press Ctrl-c to exit." ); }); + + it("should exit with an error when no repositories are provided", async () => { + mockDirs(populatedAriHomeDir, emptyProjectDir); + + // Mock empty input to simulate no repos being added + inputMock.mockResolvedValueOnce(""); + + // Suppress console.error messages because this test will throw an (expected) error + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + const mockExit = vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`Process exited with code ${code}`); + }); + + // Expect the init function to throw the error from our mocked process.exit + await expect(init()).rejects.toThrow("Process exited with code 1"); + + expect(errorMessageMock).toHaveBeenCalledWith( + "No repositories were added. Please try again and enter at least one repository URL." + ); + }); }); diff --git a/src/commands/init.ts b/src/commands/init.ts index e6a8146..36bb4bd 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -23,8 +23,12 @@ import { RepoRules, SelectedRules } from "../rules/types.js"; export const init = async (): Promise => { try { const repoUrls = await askUserToSelectRepos(); + if (!repoUrls.ok) { + errorMessage(repoUrls.error.message); + process.exit(1); + } await createAriHomeDirIfNotExists(); - const allRepoDetails = repoUrls.map((repoUrl) => + const allRepoDetails = repoUrls.value.map((repoUrl) => extractRepoDetails(repoUrl) ); for (const repoDetails of allRepoDetails) { diff --git a/src/prompting/init-prompts.test.ts b/src/prompting/init-prompts.test.ts index 0c0ec0f..1f20e77 100644 --- a/src/prompting/init-prompts.test.ts +++ b/src/prompting/init-prompts.test.ts @@ -1,6 +1,14 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi, beforeEach } from "vitest"; import { directoryChoice, fileChoice, repoChoice } from "./common.js"; -import { getSelectRuleChoices } from "./init-prompts.js"; +import { askUserToSelectRepos, getSelectRuleChoices } from "./init-prompts.js"; +import { input } from "@inquirer/prompts"; +import { ok, error, userInputError } from "../utils/result.js"; + +// Mock the input prompt +vi.mock("@inquirer/prompts", () => ({ + input: vi.fn(), + checkbox: vi.fn(), +})); describe("getSelectRuleChoices", () => { it("should return a list of choices", () => { @@ -95,3 +103,40 @@ describe("getSelectRuleChoices", () => { ]); }); }); + +describe("askUserToSelectRepos", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return a success result with repository URLs when valid repositories are provided", async () => { + const repoUrl1 = "git@github.com:my-org/my-repo.git"; + const repoUrl2 = "git@github.com:my-other-org/my-other-repo.git"; + + // Mock input to first return two repo URLs and then an empty string to finish + vi.mocked(input).mockResolvedValueOnce(repoUrl1); + vi.mocked(input).mockResolvedValueOnce(repoUrl2); + vi.mocked(input).mockResolvedValueOnce(""); + + const result = await askUserToSelectRepos(); + + expect(result).toEqual(ok([repoUrl1, repoUrl2])); + expect(input).toHaveBeenCalledTimes(3); + }); + + it("should return an error result when no repositories are provided", async () => { + // Mock input to immediately return an empty string (user doesn't add any repos) + vi.mocked(input).mockResolvedValueOnce(""); + + const result = await askUserToSelectRepos(); + + expect(result).toEqual( + error( + userInputError( + "No repositories were added. Please try again and enter at least one repository URL." + ) + ) + ); + expect(input).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/prompting/init-prompts.ts b/src/prompting/init-prompts.ts index 826a1a2..beacd61 100644 --- a/src/prompting/init-prompts.ts +++ b/src/prompting/init-prompts.ts @@ -12,8 +12,17 @@ import { normaliseSelectedRules, } from "../rules/rule-flattening.js"; import { RepoRules, RuleFilePath, SelectedRules } from "../rules/types.js"; +import { + error, + ok, + Result, + userInputError, + UserInputError, +} from "../utils/result.js"; -export const askUserToSelectRepos = async (): Promise => { +export const askUserToSelectRepos = async (): Promise< + Result +> => { const repos: string[] = []; while (true) { const repoToAdd = await input({ @@ -24,7 +33,14 @@ export const askUserToSelectRepos = async (): Promise => { } repos.push(repoToAdd); } - return repos; + if (repos.length === 0) { + return error( + userInputError( + "No repositories were added. Please try again and enter at least one repository URL." + ) + ); + } + return ok(repos); }; export const askUserToSelectRules = async ( diff --git a/src/utils/result.ts b/src/utils/result.ts new file mode 100644 index 0000000..b566295 --- /dev/null +++ b/src/utils/result.ts @@ -0,0 +1,37 @@ +export type Ok = { + ok: true; + value: T; +}; + +export function ok(value: T): Ok { + return { + ok: true, + value, + }; +} + +export type Error = { + ok: false; + error: E; +}; + +export function error(error: E): Error { + return { + ok: false, + error, + }; +} + +export type Result = Ok | Error; + +export type UserInputError = { + type: "user-input"; + message: string; +}; + +export function userInputError(message: string): UserInputError { + return { + type: "user-input", + message, + }; +}