diff --git a/.changeset/big-pans-grin.md b/.changeset/big-pans-grin.md new file mode 100644 index 0000000000..5636816d3a --- /dev/null +++ b/.changeset/big-pans-grin.md @@ -0,0 +1,5 @@ +--- +"@effect/cli": patch +--- + +add ConfigFile module to cli diff --git a/packages/cli/src/ConfigFile.ts b/packages/cli/src/ConfigFile.ts new file mode 100644 index 0000000000..29e4cda46c --- /dev/null +++ b/packages/cli/src/ConfigFile.ts @@ -0,0 +1,73 @@ +/** + * @since 2.0.0 + */ +import type { FileSystem } from "@effect/platform/FileSystem" +import type { Path } from "@effect/platform/Path" +import type { YieldableError } from "effect/Cause" +import type { ConfigProvider } from "effect/ConfigProvider" +import type { Effect } from "effect/Effect" +import type { Layer } from "effect/Layer" +import type { NonEmptyReadonlyArray } from "effect/ReadonlyArray" +import * as Internal from "./internal/configFile.js" + +/** + * @since 2.0.0 + * @category models + */ +export type Kind = "json" | "yaml" | "ini" | "toml" + +/** + * @since 2.0.0 + * @category errors + */ +export const ConfigErrorTypeId: unique symbol = Internal.ConfigErrorTypeId + +/** + * @since 2.0.0 + * @category errors + */ +export type ConfigErrorTypeId = typeof ConfigErrorTypeId + +/** + * @since 2.0.0 + * @category errors + */ +export interface ConfigFileError extends YieldableError { + readonly [ConfigErrorTypeId]: ConfigErrorTypeId + readonly _tag: "ConfigFileError" + readonly message: string +} + +/** + * @since 2.0.0 + * @category errors + */ +export const ConfigFileError: (message: string) => ConfigFileError = Internal.ConfigFileError + +/** + * @since 2.0.0 + * @category constructors + */ +export const makeProvider: ( + fileName: string, + options?: + | { + readonly formats?: ReadonlyArray + readonly searchPaths?: NonEmptyReadonlyArray + } + | undefined +) => Effect = Internal.makeProvider + +/** + * @since 2.0.0 + * @category layers + */ +export const layer: ( + fileName: string, + options?: + | { + readonly formats?: ReadonlyArray + readonly searchPaths?: NonEmptyReadonlyArray + } + | undefined +) => Layer = Internal.layer diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 3688e121b9..4b3ba4d009 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -38,6 +38,11 @@ export * as CommandDescriptor from "./CommandDescriptor.js" */ export * as CommandDirective from "./CommandDirective.js" +/** + * @since 2.0.0 + */ +export * as ConfigFile from "./ConfigFile.js" + /** * @since 1.0.0 */ diff --git a/packages/cli/src/internal/args.ts b/packages/cli/src/internal/args.ts index 9d508cd578..fa4d49e2af 100644 --- a/packages/cli/src/internal/args.ts +++ b/packages/cli/src/internal/args.ts @@ -208,13 +208,20 @@ export const file = (config?: Args.Args.PathArgsConfig): Args.Args => export const fileContent = ( config?: Args.Args.BaseArgsConfig ): Args.Args => - mapOrFail(file({ ...config, exists: "yes" }), (path) => InternalFiles.read(path)) + mapOrFail( + file({ ...config, exists: "yes" }), + (path) => Effect.mapError(InternalFiles.read(path), (e) => InternalHelpDoc.p(e)) + ) /** @internal */ export const fileParse = ( config?: Args.Args.FormatArgsConfig ): Args.Args => - mapOrFail(fileText(config), ([path, content]) => InternalFiles.parse(path, content, config?.format)) + mapOrFail(fileText(config), ([path, content]) => + Effect.mapError( + InternalFiles.parse(path, content, config?.format), + (e) => InternalHelpDoc.p(e) + )) /** @internal */ export const fileSchema = ( @@ -226,7 +233,11 @@ export const fileSchema = ( export const fileText = ( config?: Args.Args.BaseArgsConfig ): Args.Args => - mapOrFail(file({ ...config, exists: "yes" }), (path) => InternalFiles.readString(path)) + mapOrFail(file({ ...config, exists: "yes" }), (path) => + Effect.mapError( + InternalFiles.readString(path), + (e) => InternalHelpDoc.p(e) + )) /** @internal */ export const float = (config?: Args.Args.BaseArgsConfig): Args.Args => diff --git a/packages/cli/src/internal/configFile.ts b/packages/cli/src/internal/configFile.ts new file mode 100644 index 0000000000..313c64f2af --- /dev/null +++ b/packages/cli/src/internal/configFile.ts @@ -0,0 +1,93 @@ +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import * as Cause from "effect/Cause" +import * as ConfigProvider from "effect/ConfigProvider" +import * as Context from "effect/Context" +import * as DefaultServices from "effect/DefaultServices" +import * as Effect from "effect/Effect" +import { pipe } from "effect/Function" +import * as Layer from "effect/Layer" +import type * as ReadonlyArray from "effect/ReadonlyArray" +import type * as ConfigFile from "../ConfigFile.js" +import * as InternalFiles from "./files.js" + +const fileExtensions: Record> = { + json: ["json"], + yaml: ["yaml", "yml"], + ini: ["ini"], + toml: ["toml", "tml"] +} + +const allFileExtensions = Object.values(fileExtensions).flat() + +/** @internal */ +export const makeProvider = (fileName: string, options?: { + readonly formats?: ReadonlyArray + readonly searchPaths?: ReadonlyArray.NonEmptyReadonlyArray +}): Effect.Effect => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const fs = yield* _(FileSystem.FileSystem) + const searchPaths = options?.searchPaths ?? ["."] + const extensions = options?.formats + ? options.formats.flatMap((_) => fileExtensions[_]) + : allFileExtensions + const filePaths = yield* _(Effect.filter( + searchPaths.flatMap( + (searchPath) => extensions.map((ext) => path.join(searchPath, `${fileName}.${ext}`)) + ), + (path) => Effect.orElseSucceed(fs.exists(path), () => false) + )) + const providers = yield* _(Effect.forEach(filePaths, (path) => + pipe( + fs.readFileString(path), + Effect.mapError((_) => ConfigFileError(`Could not read file (${path})`)), + Effect.flatMap((content) => + Effect.mapError( + InternalFiles.parse(path, content), + (message) => ConfigFileError(message) + ) + ), + Effect.map((data) => ConfigProvider.fromJson(data)) + ))) + + if (providers.length === 0) { + return ConfigProvider.fromMap(new Map()) + } + + return providers.reduce((acc, provider) => ConfigProvider.orElse(acc, () => provider)) + }) + +/** @internal */ +export const layer = (fileName: string, options?: { + readonly formats?: ReadonlyArray + readonly searchPaths?: ReadonlyArray.NonEmptyReadonlyArray +}): Layer.Layer => + pipe( + makeProvider(fileName, options), + Effect.map((provider) => + Layer.fiberRefLocallyScopedWith(DefaultServices.currentServices, (services) => { + const current = Context.get(services, ConfigProvider.ConfigProvider) + return Context.add(services, ConfigProvider.ConfigProvider, ConfigProvider.orElse(current, () => provider)) + }) + ), + Layer.unwrapEffect + ) + +/** @internal */ +export const ConfigErrorTypeId: ConfigFile.ConfigErrorTypeId = Symbol.for( + "@effect/cli/ConfigFile/ConfigFileError" +) as ConfigFile.ConfigErrorTypeId + +const ConfigFileErrorProto = { + __proto__: Cause.YieldableError.prototype, + [ConfigErrorTypeId]: ConfigErrorTypeId +} + +/** @internal */ +export const ConfigFileError = (message: string): ConfigFile.ConfigFileError => { + const self = Object.create(ConfigFileErrorProto) + self._tag = "ConfigFileError" + self.message = message + return self +} diff --git a/packages/cli/src/internal/files.ts b/packages/cli/src/internal/files.ts index 558f2882d3..5f35ce22da 100644 --- a/packages/cli/src/internal/files.ts +++ b/packages/cli/src/internal/files.ts @@ -3,10 +3,9 @@ import * as Effect from "effect/Effect" import * as Ini from "ini" import * as Toml from "toml" import * as Yaml from "yaml" -import type * as HelpDoc from "../HelpDoc.js" -import * as InternalHelpDoc from "./helpDoc.js" -const fileParsers: Record unknown> = { +/** @internal */ +export const fileParsers: Record unknown> = { json: (content: string) => JSON.parse(content), yaml: (content: string) => Yaml.parse(content), yml: (content: string) => Yaml.parse(content), @@ -18,15 +17,12 @@ const fileParsers: Record unknown> = { /** @internal */ export const read = ( path: string -): Effect.Effect => +): Effect.Effect => Effect.flatMap( FileSystem.FileSystem, (fs) => Effect.matchEffect(fs.readFile(path), { - onFailure: (error) => - Effect.fail( - InternalHelpDoc.p(`Could not read file (${path}): ${error}`) - ), + onFailure: (error) => Effect.fail(`Could not read file (${path}): ${error}`), onSuccess: (content) => Effect.succeed([path, content] as const) }) ) @@ -34,15 +30,12 @@ export const read = ( /** @internal */ export const readString = ( path: string -): Effect.Effect => +): Effect.Effect => Effect.flatMap( FileSystem.FileSystem, (fs) => Effect.matchEffect(fs.readFileString(path), { - onFailure: (error) => - Effect.fail( - InternalHelpDoc.p(`Could not read file (${path}): ${error}`) - ), + onFailure: (error) => Effect.fail(`Could not read file (${path}): ${error}`), onSuccess: (content) => Effect.succeed([path, content] as const) }) ) @@ -52,14 +45,14 @@ export const parse = ( path: string, content: string, format?: "json" | "yaml" | "ini" | "toml" -): Effect.Effect => { +): Effect.Effect => { const parser = fileParsers[format ?? path.split(".").pop() as string] if (parser === undefined) { - return Effect.fail(InternalHelpDoc.p(`Unsupported file format: ${format}`)) + return Effect.fail(`Unsupported file format: ${format}`) } return Effect.try({ try: () => parser(content), - catch: (e) => InternalHelpDoc.p(`Could not parse ${format} file (${path}): ${e}`) + catch: (e) => `Could not parse ${format} file (${path}): ${e}` }) } diff --git a/packages/cli/src/internal/options.ts b/packages/cli/src/internal/options.ts index 8c533359d3..1c521fda28 100644 --- a/packages/cli/src/internal/options.ts +++ b/packages/cli/src/internal/options.ts @@ -293,7 +293,7 @@ export const fileContent = ( mapOrFail(file(name, { exists: "yes" }), (path) => Effect.mapError( InternalFiles.read(path), - (doc) => InternalValidationError.invalidValue(doc) + (msg) => InternalValidationError.invalidValue(InternalHelpDoc.p(msg)) )) /** @internal */ @@ -304,7 +304,7 @@ export const fileParse = ( mapOrFail(fileText(name), ([path, content]) => Effect.mapError( InternalFiles.parse(path, content, format), - (error) => InternalValidationError.invalidValue(error) + (error) => InternalValidationError.invalidValue(InternalHelpDoc.p(error)) )) /** @internal */ @@ -321,7 +321,7 @@ export const fileText = ( mapOrFail(file(name, { exists: "yes" }), (path) => Effect.mapError( InternalFiles.readString(path), - (doc) => InternalValidationError.invalidValue(doc) + (error) => InternalValidationError.invalidValue(InternalHelpDoc.p(error)) )) /** @internal */ diff --git a/packages/cli/test/ConfigFile.test.ts b/packages/cli/test/ConfigFile.test.ts new file mode 100644 index 0000000000..1bca832c66 --- /dev/null +++ b/packages/cli/test/ConfigFile.test.ts @@ -0,0 +1,43 @@ +import * as ConfigFile from "@effect/cli/ConfigFile" +import * as FileSystem from "@effect/platform-node/FileSystem" +import * as Path from "@effect/platform-node/Path" +import { Layer } from "effect" +import * as Config from "effect/Config" +import * as Effect from "effect/Effect" +import { assert, describe, it } from "vitest" + +const MainLive = Layer.mergeAll(FileSystem.layer, Path.layer) + +const runEffect = ( + self: Effect.Effect +): Promise => Effect.provide(self, MainLive).pipe(Effect.runPromise) + +describe("ConfigFile", () => { + it("loads json files", () => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const result = yield* _( + Config.all([ + Config.boolean("foo"), + Config.string("bar") + ]), + Effect.provide(ConfigFile.layer("config", { + searchPaths: [path.join(__dirname, "fixtures")], + formats: ["json"] + })) + ) + assert.deepStrictEqual(result, [true, "baz"]) + }).pipe(runEffect)) + + it("loads yaml", () => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const result = yield* _( + Config.integer("foo"), + Effect.provide(ConfigFile.layer("config-file", { + searchPaths: [path.join(__dirname, "fixtures")] + })) + ) + assert.deepStrictEqual(result, 123) + }).pipe(runEffect)) +}) diff --git a/packages/cli/test/fixtures/config-file.toml b/packages/cli/test/fixtures/config-file.toml new file mode 100644 index 0000000000..eba385ff02 --- /dev/null +++ b/packages/cli/test/fixtures/config-file.toml @@ -0,0 +1 @@ +foo = 123