Skip to content

Commit

Permalink
add ConfigFile module to cli (#1907)
Browse files Browse the repository at this point in the history
  • Loading branch information
tim-smart authored Jan 11, 2024
1 parent 4863253 commit d1c7cf5
Show file tree
Hide file tree
Showing 9 changed files with 246 additions and 22 deletions.
5 changes: 5 additions & 0 deletions .changeset/big-pans-grin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect/cli": patch
---

add ConfigFile module to cli
73 changes: 73 additions & 0 deletions packages/cli/src/ConfigFile.ts
Original file line number Diff line number Diff line change
@@ -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<Kind>
readonly searchPaths?: NonEmptyReadonlyArray<string>
}
| undefined
) => Effect<Path | FileSystem, ConfigFileError, ConfigProvider> = Internal.makeProvider

/**
* @since 2.0.0
* @category layers
*/
export const layer: (
fileName: string,
options?:
| {
readonly formats?: ReadonlyArray<Kind>
readonly searchPaths?: NonEmptyReadonlyArray<string>
}
| undefined
) => Layer<Path | FileSystem, ConfigFileError, never> = Internal.layer
5 changes: 5 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
17 changes: 14 additions & 3 deletions packages/cli/src/internal/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,13 +208,20 @@ export const file = (config?: Args.Args.PathArgsConfig): Args.Args<string> =>
export const fileContent = (
config?: Args.Args.BaseArgsConfig
): Args.Args<readonly [path: string, content: Uint8Array]> =>
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<unknown> =>
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 = <I, A>(
Expand All @@ -226,7 +233,11 @@ export const fileSchema = <I, A>(
export const fileText = (
config?: Args.Args.BaseArgsConfig
): Args.Args<readonly [path: string, content: string]> =>
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<number> =>
Expand Down
93 changes: 93 additions & 0 deletions packages/cli/src/internal/configFile.ts
Original file line number Diff line number Diff line change
@@ -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<ConfigFile.Kind, ReadonlyArray<string>> = {
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<ConfigFile.Kind>
readonly searchPaths?: ReadonlyArray.NonEmptyReadonlyArray<string>
}): Effect.Effect<Path.Path | FileSystem.FileSystem, ConfigFile.ConfigFileError, ConfigProvider.ConfigProvider> =>
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<ConfigFile.Kind>
readonly searchPaths?: ReadonlyArray.NonEmptyReadonlyArray<string>
}): Layer.Layer<Path.Path | FileSystem.FileSystem, ConfigFile.ConfigFileError, never> =>
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
}
25 changes: 9 additions & 16 deletions packages/cli/src/internal/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, (content: string) => unknown> = {
/** @internal */
export const fileParsers: Record<string, (content: string) => unknown> = {
json: (content: string) => JSON.parse(content),
yaml: (content: string) => Yaml.parse(content),
yml: (content: string) => Yaml.parse(content),
Expand All @@ -18,31 +17,25 @@ const fileParsers: Record<string, (content: string) => unknown> = {
/** @internal */
export const read = (
path: string
): Effect.Effect<FileSystem.FileSystem, HelpDoc.HelpDoc, readonly [path: string, content: Uint8Array]> =>
): Effect.Effect<FileSystem.FileSystem, string, readonly [path: string, content: Uint8Array]> =>
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)
})
)

/** @internal */
export const readString = (
path: string
): Effect.Effect<FileSystem.FileSystem, HelpDoc.HelpDoc, readonly [path: string, content: string]> =>
): Effect.Effect<FileSystem.FileSystem, string, readonly [path: string, content: string]> =>
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)
})
)
Expand All @@ -52,14 +45,14 @@ export const parse = (
path: string,
content: string,
format?: "json" | "yaml" | "ini" | "toml"
): Effect.Effect<never, HelpDoc.HelpDoc, unknown> => {
): Effect.Effect<never, string, unknown> => {
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}`
})
}
6 changes: 3 additions & 3 deletions packages/cli/src/internal/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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 */
Expand All @@ -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 */
Expand Down
43 changes: 43 additions & 0 deletions packages/cli/test/ConfigFile.test.ts
Original file line number Diff line number Diff line change
@@ -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 = <E, A>(
self: Effect.Effect<FileSystem.FileSystem | Path.Path, E, A>
): Promise<A> => 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))
})
1 change: 1 addition & 0 deletions packages/cli/test/fixtures/config-file.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
foo = 123

0 comments on commit d1c7cf5

Please sign in to comment.