add arg parser

This commit is contained in:
Elizabeth Hunt 2024-08-01 09:15:33 -07:00
parent ad74ff9995
commit 5dbd168785
8 changed files with 246 additions and 34 deletions

View File

@ -1,9 +1,98 @@
import * as E from "fp-ts/lib/Either";
import * as O from "fp-ts/lib/Option";
import * as R from "fp-ts/lib/ReadonlyArray";
import { pipe } from "fp-ts/lib/function";
import { parseTestType, type TestType } from "./canary";
export interface Args { export interface Args {
testFile: string; readonly testsFile: string;
readonly testName: O.Option<string>;
readonly testType: O.Option<TestType>;
} }
export const parseArgs = (argv: string[]): Args => { interface ArgsBuilder {
const useful = argv.slice(2); // skip bun path and script path readonly testsFile: O.Option<string>;
readonly testName: O.Option<string>;
readonly testType: O.Option<string>;
}
const flags = useful.map((x, i) => x.startsWith("--") && i); type ArgsBuilderField = (arg: string) => (builder: ArgsBuilder) => ArgsBuilder;
const createArgsBuilder = (): ArgsBuilder => ({
testsFile: O.none,
testName: O.none,
testType: O.none,
});
const withTestsFile: ArgsBuilderField = (testsFile) => (builder) => ({
...builder,
testsFile: O.some(testsFile),
});
const withTestName: ArgsBuilderField = (testName) => (builder) => ({
...builder,
testName: O.some(testName),
});
const withTestType: ArgsBuilderField = (testType) => (builder) => ({
...builder,
testType: O.some(testType),
});
const flagBuilders: Record<string, ArgsBuilderField> = {
"--tests-file": withTestsFile,
"--test-name": withTestName,
"--test-type": withTestType,
};
const buildArgs = (builder: ArgsBuilder): E.Either<string, Args> => {
if (O.isNone(builder.testsFile)) {
return E.left("please specify a test file");
}
const testType = pipe(builder.testType, O.flatMap(parseTestType));
if (O.isSome(builder.testType) && !O.isSome(testType)) {
return E.left("bad test type " + builder.testType.value);
}
return E.right({
testsFile: builder.testsFile.value,
testName: builder.testName,
testType,
});
};
const isArg = (arg: string) => arg.startsWith("--") && arg in flagBuilders;
export const parseArgs = (argv: string[]): E.Either<string, Args> => {
if (argv.length < 2) return E.left("bad argv");
const useful = argv.slice(2);
const argApplicators = pipe(
useful,
R.mapWithIndex((i, arg) => (isArg(arg) ? O.some({ arg, i }) : O.none)),
R.compact,
R.map(({ arg, i }) =>
pipe(
O.fromNullable(flagBuilders[arg]),
O.bindTo("argApplicator"),
O.bind("val", () =>
pipe(
O.fromNullable(useful.at(i + 1)),
O.flatMap((argValue) =>
isArg(argValue) ? O.none : O.some(argValue),
),
),
),
O.map(({ val, argApplicator }) => argApplicator(val)),
),
),
);
return pipe(
argApplicators,
R.compact,
R.reduce(createArgsBuilder(), (builder, applyArgTo) => applyArgTo(builder)),
buildArgs,
);
}; };

View File

@ -1,2 +1,3 @@
export * from "./job"; export * from "./job";
export * from "./testable"; export * from "./test";
export * from "./email";

View File

@ -1,4 +1,5 @@
import type { TaskEither } from "fp-ts/lib/TaskEither"; import * as TE from "fp-ts/lib/TaskEither";
import * as O from "fp-ts/lib/Option";
import type { Job } from "./job"; import type { Job } from "./job";
import type { D } from "../util"; import type { D } from "../util";
@ -21,4 +22,9 @@ export interface Test {
schedule: Schedule; schedule: Schedule;
} }
export type Testable<T extends Job> = (job: T) => TaskEither<Error, boolean>; export type Testable<T extends Job> = (job: T) => TE.TaskEither<Error, boolean>;
export const parseTestType = (testType: string): O.Option<TestType> =>
Object.values(TestType).includes(testType as TestType)
? O.some(testType as TestType)
: O.none;

View File

@ -1,11 +1,13 @@
import * as IO from "fp-ts/IO"; import * as IO from "fp-ts/IO";
import type { Publisher } from "./publisher"; import type { Publisher } from "./publisher";
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import type { Test } from "./canary";
export interface Config { export interface Config {
result_publishers: Publisher[]; result_publishers: Publisher[];
dns: string[]; dns: string[];
timeout: string; http_timeout: string;
tests: Test[];
} }
export const readConfig = export const readConfig =

View File

@ -1,6 +1,16 @@
import * as IO from "fp-ts/IO"; import * as TE from "fp-ts/lib/TaskEither";
import { readFileSync } from "fs"; import { pipe } from "fp-ts/lib/function";
import { toError } from "fp-ts/lib/Either";
const main: IO.IO<void> = ConsoleLogger.log("Hello, world!"); import { readConfig } from "./config";
import { parseArgs } from "./args";
const main: TE.TaskEither<Error, void> = pipe(
TE.fromEither(parseArgs(Bun.argv)),
TE.bindTo("args"),
TE.bind("config", ({ args }) => TE.fromIO(readConfig(args.testsFile))),
TE.fold(
(e) => TE.left(toError(e)),
() => TE.right(undefined),
),
);
main(); main();

View File

@ -1,6 +1,6 @@
import { flow, pipe } from "fp-ts/function"; import { flow, pipe } from "fp-ts/function";
import * as E from "fp-ts/Either"; import * as E from "fp-ts/lib/Either";
import * as S from "fp-ts/String"; import * as S from "fp-ts/lib/string";
import * as O from "fp-ts/lib/Option"; import * as O from "fp-ts/lib/Option";
import * as R from "fp-ts/lib/ReadonlyArray"; import * as R from "fp-ts/lib/ReadonlyArray";
@ -60,30 +60,30 @@ export const createDurationBuilder = (): DurationBuilder => ({
hours: 0, hours: 0,
}); });
export const withMillis = export type DurationBuilderField<T> = (
(millis: number) => arg: T,
(builder: DurationBuilder): DurationBuilder => ({ ) => (builder: DurationBuilder) => DurationBuilder;
export const withMillis: DurationBuilderField<number> =
(millis) => (builder) => ({
...builder, ...builder,
millis, millis,
}); });
export const withSeconds = export const withSeconds: DurationBuilderField<number> =
(seconds: number) => (seconds) => (builder) => ({
(builder: DurationBuilder): DurationBuilder => ({
...builder, ...builder,
seconds, seconds,
}); });
export const withMinutes = export const withMinutes: DurationBuilderField<number> =
(minutes: number) => (minutes) => (builder) => ({
(builder: DurationBuilder): DurationBuilder => ({
...builder, ...builder,
minutes, minutes,
}); });
export const withHours = export const withHours: DurationBuilderField<number> =
(hours: number) => (hours) => (builder) => ({
(builder: DurationBuilder): DurationBuilder => ({
...builder, ...builder,
hours, hours,
}); });
@ -99,7 +99,7 @@ export const parse = (duration: string): E.Either<string, Duration> => {
duration, duration,
S.split(" "), S.split(" "),
R.map(S.trim), R.map(S.trim),
R.filter((part) => !S.isEmpty(part)) R.filter((part) => !S.isEmpty(part)),
); );
const valueUnitPairs = pipe( const valueUnitPairs = pipe(
@ -120,9 +120,9 @@ export const parse = (duration: string): E.Either<string, Duration> => {
E.map( E.map(
flow( flow(
R.filter(O.isSome), R.filter(O.isSome),
R.map(({ value }) => value) R.map(({ value }) => value),
) ),
) ),
); );
return pipe( return pipe(
@ -146,10 +146,10 @@ export const parse = (duration: string): E.Either<string, Duration> => {
default: default:
return E.left(`unknown unit: ${unit}`); return E.left(`unknown unit: ${unit}`);
} }
}) }),
) ),
) ),
), ),
E.map(build) E.map(build),
); );
}; };

104
tst/args.spec.ts Normal file
View File

@ -0,0 +1,104 @@
import { test, expect } from "bun:test";
import * as E from "fp-ts/lib/Either";
import * as O from "fp-ts/lib/Option";
import { parseArgs, type Args } from "../src/args";
import { TestType } from "../src/canary";
test("should return an error if argv has less than 2 arguments", () => {
const result = parseArgs([]);
expect(E.isLeft(result)).toBe(true);
expect(E.left<string, Args>("bad argv")).toEqual(result);
});
test("should return an error if --tests-file is not specified", () => {
const result = parseArgs(["node", "script.js", "--test-name", "test"]);
expect(E.isLeft(result)).toBe(true);
expect(E.left<string, Args>("please specify a test file")).toEqual(result);
});
test("should return an error if test type is invalid", () => {
const result = parseArgs([
"bun",
"script.js",
"--tests-file",
"testFile",
"--test-type",
"invalidType",
]);
expect(E.isLeft(result)).toBe(true);
expect(E.left<string, Args>("bad test type invalidType")).toEqual(result);
});
test("should parse arguments correctly with all options specified", () => {
const result = parseArgs([
"node",
"script.js",
"--tests-file",
"testFile",
"--test-name",
"testName",
"--test-type",
"dns",
]);
const expected: Args = {
testsFile: "testFile",
testName: O.some("testName"),
testType: O.some(TestType.DNS),
};
expect(E.isRight(result)).toBe(true);
expect(E.right<string, Args>(expected)).toEqual(result);
});
test("should parse arguments correctly with only testsFile specified", () => {
const result = parseArgs(["node", "script.js", "--tests-file", "testFile"]);
const expected: Args = {
testsFile: "testFile",
testName: O.none,
testType: O.none,
};
expect(E.isRight(result)).toBe(true);
expect(E.right<string, Args>(expected)).toEqual(result);
});
test("should handle missing values for flags gracefully", () => {
const result = parseArgs([
"node",
"script.js",
"--tests-file",
"testFile",
"--test-name",
]);
const expected: Args = {
testsFile: "testFile",
testName: O.none,
testType: O.none,
};
expect(E.isRight(result)).toBe(true);
expect(E.right<string, Args>(expected)).toEqual(result);
});
test("should not parse args as values", () => {
const result = parseArgs([
"node",
"script.js",
"--tests-file",
"testFile",
"--test-name",
"--tests-file",
]);
const expected: Args = {
testsFile: "testFile",
testName: O.none,
testType: O.none,
};
expect(E.isRight(result)).toBe(true);
expect(E.right<string, Args>(expected)).toEqual(result);
});