import fs from "node:fs/promises" import path from "node:path" import { globby } from "globby" export const configFileNames = [ "automate.config.ts", "automate.config.mts", "automate.config.cts", "automate.config.js", "automate.config.mjs", "automate.config.cjs", "automate.config.json", ] as const /** Preference order used when a directory contains multiple config formats. */ const configNamePriority = new Map( configFileNames.map((fileName, index) => [fileName, index]), ) export type ConfigDirPriority = 0 | 1 | 2 export interface ConfigDirMatch { configPath: string dir: string priority: ConfigDirPriority } export interface FindConfigDirMatchesOptions { cwd?: string maxDownDepth?: number } /** * Finds the nearest project config directories around a working directory. * * @param options - Search root and maximum downward traversal depth. * @param options.cwd - Directory from which to begin searching. * @param options.maxDownDepth - Maximum descendant depth to inspect. */ export async function findConfigDirMatches({ cwd = process.cwd(), maxDownDepth = 5, }: FindConfigDirMatchesOptions = {}) { const resolvedCwd = path.resolve(cwd) const currentConfigPath = await findConfigPath(resolvedCwd) if (currentConfigPath) { return [ { configPath: currentConfigPath, dir: resolvedCwd, priority: 0, }, ] satisfies ConfigDirMatch[] } const parentMatch = await findParentConfigDirMatch(resolvedCwd) if (parentMatch) return [parentMatch] return findDownConfigDirMatches(resolvedCwd, maxDownDepth) } /** * Finds a project config only when discovery identifies one directory. * * @param options - Search root and maximum downward traversal depth. */ export async function findUnambiguousConfigDirMatch( options: FindConfigDirMatchesOptions = {}, ) { const matches = await findConfigDirMatches(options) return matches.length === 1 ? matches[0]! : undefined } /** * Resolves a user-selected directory and finds its project config file. * * @param dir - Directory path, relative to the supplied working directory. * @param options - Resolution options. * @param options.cwd - Base directory for resolving `dir`. * @throws When the directory contains no recognized config file. */ export async function findExplicitConfigDirMatch( dir: string, { cwd = process.cwd() }: { cwd?: string } = {}, ) { const resolvedDir = path.resolve(cwd, dir) const configPath = await findConfigPath(resolvedDir) if (!configPath) { throw new Error( `No config found in ${resolvedDir}. Expected one of: ${configFileNames.join( ", ", )}`, ) } return { configPath, dir: resolvedDir, priority: 0, } satisfies ConfigDirMatch } /** * Walks parent directories looking for the nearest project config. * * @param cwd - Directory whose parents should be searched. */ async function findParentConfigDirMatch(cwd: string) { let dir = path.dirname(cwd) while (dir !== cwd) { const configPath = await findConfigPath(dir) if (configPath) { return { configPath, dir, priority: 1, } satisfies ConfigDirMatch } const parent = path.dirname(dir) if (parent === dir) return null dir = parent } return null } /** * Finds project configs below a directory within a fixed depth. * * @param cwd - Directory whose descendants should be searched. * @param maxDepth - Maximum number of descendant levels to inspect. */ async function findDownConfigDirMatches(cwd: string, maxDepth: number) { const configPathByDir = new Map() for (const configPath of await globby( configFileNames.map((fileName) => `**/${fileName}`), { absolute: true, cwd, deep: maxDepth + 1, gitignore: true, onlyFiles: true, }, )) { const dir = path.dirname(configPath) const currentConfigPath = configPathByDir.get(dir) if ( !currentConfigPath || getConfigNamePriority(configPath) < getConfigNamePriority(currentConfigPath) ) { configPathByDir.set(dir, configPath) } } return Array.from(configPathByDir.entries()) .map( ([dir, configPath]) => ({ configPath, dir, priority: 2, }) satisfies ConfigDirMatch, ) .toSorted((a, b) => a.dir.localeCompare(b.dir)) } /** * Finds the highest-priority recognized config file in a directory. * * @param dir - Directory to inspect. */ async function findConfigPath(dir: string) { for (const fileName of configFileNames) { const configPath = path.join(dir, fileName) if (await isFile(configPath)) return configPath } return null } /** * Checks whether a path points to a regular file. * * @param filePath - Filesystem path to inspect. */ async function isFile(filePath: string) { try { return (await fs.stat(filePath)).isFile() } catch { return false } } /** * Looks up a config filename's selection priority. * * @param configPath - Full config path whose basename should be ranked. */ function getConfigNamePriority(configPath: string) { return configNamePriority.get(path.basename(configPath)) ?? Infinity }