Skip to content

Commit

Permalink
feat: new config system (#5)
Browse files Browse the repository at this point in the history
* wip(draft): progress on new system

* test(unit): add tests

* refactor: apply new config sys

* style(biome): fix biome conflict

* fix: fix stderr empty when non-zero-exit

* feat(config): add write module

* test: update and add cases

* refactor: improve cancel util

* feat: finished config prompt, refactor, fix bugs & test

* fix(config): return default config if custom hasn't been created yet
  • Loading branch information
renejfc authored Feb 21, 2025
1 parent b78e8ec commit 0545a3a
Show file tree
Hide file tree
Showing 18 changed files with 378 additions and 91 deletions.
112 changes: 112 additions & 0 deletions __test__/config/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"
import { mkdir, rm } from "node:fs/promises"
import { join } from "node:path"
import { config, loadConfig } from "~/lib/config"
import { writeDefaultConfig } from "~/lib/config/write"

const WRITE_TEST_PATH = ".temp/conmmit"

beforeAll(() => config.init())
beforeEach(async () => await mkdir(WRITE_TEST_PATH, { recursive: true }))
afterEach(async () => await rm(WRITE_TEST_PATH, { recursive: true, force: true }))

describe("Config Loading", () => {
test("should load default config", async () => {
const config = await loadConfig()

expect(config).toBeDefined()
expect(config.commit_types).toBeArray()
expect(config.commit_types.length).toBeGreaterThan(0)
})

test("should validate commit type structure", async () => {
const config = await loadConfig()
const firstType = config.commit_types[0]

expect(firstType).toHaveProperty("name")
expect(firstType).toHaveProperty("description")
expect(firstType).toHaveProperty("example_scopes")
expect(firstType.example_scopes).toBeArray()
})

test("should throw on invalid config structure", async () => {
// this is enough thanks to abortPipeEarly on validation fn
const invalidConfig = `
[[commit_types]]
name = ""
`
const configPath = join(WRITE_TEST_PATH, "invalid-config.toml")
const configFile = Bun.file(configPath)
await configFile.write(invalidConfig)

expect(loadConfig(configPath)).rejects.toThrow()
})

test("should load config from custom path", async () => {
const customConfig = `
[[commit_types]]
name = "custom"
description = "Custom type"
example_scopes = ["test"]
`
const configPath = join(WRITE_TEST_PATH, "custom-config.toml")
const configFile = Bun.file(configPath)
await configFile.write(customConfig)
const {
commit_types: [commit_type],
} = await loadConfig(configPath)

expect(commit_type).toHaveProperty("name", "custom")
expect(commit_type).toHaveProperty("description", "Custom type")
expect(commit_type).toHaveProperty("example_scopes", ["test"])
})
})

describe("Config Writing", () => {
test("should copy default config to user's filesystem", async () => {
const configPath = join(WRITE_TEST_PATH, "config.toml")
const userConfigFile = Bun.file(configPath)

const defaultConfig = await loadConfig()

await writeDefaultConfig({ customPath: configPath })
const userConfig = await loadConfig(configPath)

expect(await userConfigFile.exists()).toBeTrue()
expect(userConfig).toStrictEqual(defaultConfig)
})

test("should not overwrite existing config without override", async () => {
const customConfig = `
[[commit_types]]
name = "custom"
description = "Custom type"
example_scopes = ["test"]
`
const configPath = join(WRITE_TEST_PATH, "config.toml")
const configFile = Bun.file(configPath)
await configFile.write(customConfig)

const userConfigOriginal = await loadConfig(configPath)
expect(writeDefaultConfig({ customPath: configPath })).rejects.toThrow()
const userConfigAfterAttempt = await loadConfig(configPath)
expect(userConfigAfterAttempt).toStrictEqual(userConfigOriginal)
})

test("should overwrite existing config with override", async () => {
const customConfig = `
[[commit_types]]
name = "custom"
description = "Custom type"
example_scopes = ["test"]
`
const configPath = join(WRITE_TEST_PATH, "config.toml")
const configFile = Bun.file(configPath)
await configFile.write(customConfig)

const defaultConfig = await loadConfig()
await writeDefaultConfig({ customPath: configPath, override: true })
const userConfig = await loadConfig(configPath)
expect(userConfig).toStrictEqual(defaultConfig)
})
})
3 changes: 3 additions & 0 deletions bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"dependencies": {
"@clack/prompts": "^0.9.1",
"picocolors": "^1.1.1",
"valibot": "^1.0.0-beta.14",
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
Expand Down Expand Up @@ -227,6 +228,8 @@

"undici-types": ["[email protected]", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],

"valibot": ["[email protected]", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-tLyV2rE5QL6U29MFy3xt4AqMrn+/HErcp2ZThASnQvPMwfSozjV1uBGKIGiegtZIGjinJqn0SlBdannf18wENA=="],

"which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],

"yallist": ["[email protected]", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
},
"dependencies": {
"@clack/prompts": "^0.9.1",
"picocolors": "^1.1.1"
"picocolors": "^1.1.1",
"valibot": "^1.0.0-beta.14"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
Expand Down
67 changes: 0 additions & 67 deletions src/config.ts

This file was deleted.

60 changes: 60 additions & 0 deletions src/lib/config/default.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# COMMIT TYPES
[[commit_types]]
name = "feat"
description = "A new feature"
example_scopes = ["api", "ui", "auth", "db"]

[[commit_types]]
name = "fix"
description = "A bug fix"
example_scopes = ["memory", "crash", "data", "performance"]

[[commit_types]]
name = "wip"
description = "Work in progress"
example_scopes = ["prototype", "experiment", "draft", "concept"]

[[commit_types]]
name = "docs"
description = "Documentation only changes"
example_scopes = ["guide", "setup", "reference", "faq"]

[[commit_types]]
name = "style"
description = "Code style changes (white-space, formatting, semi-colons, etc)"
example_scopes = ["format", "lint", "typo"]

[[commit_types]]
name = "refactor"
description = "A code change that neither fixes a bug nor adds a feature"
example_scopes = ["cleanup", "split", "interface"]

[[commit_types]]
name = "perf"
description = "A code change that improves performance"
example_scopes = ["query", "cache", "workers"]

[[commit_types]]
name = "test"
description = "Adding missing tests or correcting existing ones"
example_scopes = ["unit", "integration", "e2e", "performance"]

[[commit_types]]
name = "build"
description = "Changes that affect the build system or external deps"
example_scopes = ["webpack", "babel", "npm", "gradle"]

[[commit_types]]
name = "ci"
description = "Changes to the CI configuration files and scripts"
example_scopes = ["actions", "gitlab", "jenkins", "circleci"]

[[commit_types]]
name = "chore"
description = "Other changes that don't modify src or test files"
example_scopes = ["deps", "tooling", "workflow", "structure"]

[[commit_types]]
name = "revert"
description = "Reverts a previous commit"
example_scopes = ["rollback"]
21 changes: 21 additions & 0 deletions src/lib/config/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import internals from "./internals"
import { loadConfig } from "./load"
import type { Config } from "./schema"

const config = (() => {
let cache: Config | undefined

return {
init: async (customPath?: string) => {
if (cache) return cache
cache = await loadConfig(customPath || internals.customConfigPath)
return cache
},
get: () => {
if (!cache) throw new Error("Config not initialized.")
return { ...cache, internals }
},
}
})()

export { config, type Config, loadConfig }
8 changes: 8 additions & 0 deletions src/lib/config/internals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { homedir } from "node:os"
import { join } from "node:path"

export default {
lineMaxLength: 100,
lineMinLength: 3,
customConfigPath: join(homedir(), ".conmmit/config.toml"),
} as const
26 changes: 26 additions & 0 deletions src/lib/config/load.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { TOML } from "bun"
import { safeParse } from "valibot"
import defaultConfig from "./default.toml"
import { type Config, ConfigSchema } from "./schema"

export async function loadConfig(customPath?: string): Promise<Config> {
try {
if (!customPath) return validateConfig(defaultConfig)
const configFile = Bun.file(customPath)
if (!(await configFile.exists())) return validateConfig(defaultConfig)
const parsedConfig = TOML.parse(await configFile.text())
return validateConfig(parsedConfig)
} catch (error) {
throw new Error(`${error instanceof Error ? error.message : "Unknown error"}`)
}
}

function validateConfig(config: unknown): Config {
const result = safeParse(ConfigSchema, config, { abortPipeEarly: true })

if (!result.success) {
throw new Error(`Invalid config: ${result.issues[0]?.message}`)
}

return result.output
}
32 changes: 32 additions & 0 deletions src/lib/config/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { type InferOutput, array, maxLength, minLength, object, pipe, string } from "valibot"

export const CommitTypeSchema = object({
name: pipe(
string(),
minLength(1, "Type name cannot be empty"),
maxLength(12, "Type name cannot exceed 12 characters")
),
description: pipe(
string(),
minLength(1, "Description cannot be empty"),
maxLength(90, "Description cannot exceed 90 characters")
),
example_scopes: pipe(
array(
pipe(
string(),
minLength(1, "Scope cannot be empty"),
maxLength(12, "Scope cannot exceed 12 characters")
)
),
minLength(1, "At least 1 example scope is required"),
maxLength(5, "At most 5 example scopes are allowed")
),
})

export const ConfigSchema = object({
commit_types: pipe(array(CommitTypeSchema), minLength(1, "At least 1 commit type is required")),
})

export type CommitType = InferOutput<typeof CommitTypeSchema>
export type Config = InferOutput<typeof ConfigSchema>
26 changes: 26 additions & 0 deletions src/lib/config/write.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import internals from "./internals"

export async function writeDefaultConfig({
customPath,
override = false,
}: {
customPath?: string
override?: boolean
}) {
try {
const userConfigPath = customPath || internals.customConfigPath
const userConfigFile = Bun.file(userConfigPath)

if ((await userConfigFile.exists()) && !override)
throw new Error(`Config file already exists at ${userConfigPath}`)

const defaultConfigFile = Bun.file(new URL("./default.toml", import.meta.url).pathname)

// not sure why BunFile.write is not working but Bun.write does...
await Bun.write(userConfigPath, defaultConfigFile)
} catch (error) {
throw new Error(
`Failed to write config: ${error instanceof Error ? error.message : "Unknown error"}`
)
}
}
Loading

0 comments on commit 0545a3a

Please sign in to comment.