diff --git a/src/NewProject.res b/src/NewProject.res index 37848bb..e1a968e 100644 --- a/src/NewProject.res +++ b/src/NewProject.res @@ -2,19 +2,6 @@ open Node module P = ClackPrompts -let packageNameRegExp = /^[a-z0-9-]+$/ - -let validateProjectName = projectName => - if projectName->String.trim->String.length === 0 { - Error("Project name must not be empty.") - } else if !(packageNameRegExp->RegExp.test(projectName)) { - Error("Project name may only contain lower case letters, numbers and hyphens.") - } else if Fs.existsSync(Path.join2(Process.cwd(), projectName)) { - Error(`The folder ${projectName} already exist in the current directory.`) - } else { - Ok() - } - let updatePackageJson = async (~projectName, ~versions) => await JsonUtils.updateJsonFile("package.json", json => switch json { @@ -159,7 +146,7 @@ let createNewProject = async () => { let projectName = switch commandLineArguments.projectName { | Some(projectName) if useDefaultVersions => // Note this throws in the some case, which is why we cannot use Option.getOrThrow here. - switch validateProjectName(projectName) { + switch NewProjectValidation.validateProjectName(projectName) { | Error(message) => JsError.throwWithMessage(message) | Ok() => projectName } @@ -170,7 +157,7 @@ let createNewProject = async () => { placeholder: "my-rescript-app", ?initialValue, validate: projectName => - switch validateProjectName(projectName) { + switch NewProjectValidation.validateProjectName(projectName) { | Ok() => None | Error(error) => Some(error) }, diff --git a/src/NewProjectValidation.res b/src/NewProjectValidation.res new file mode 100644 index 0000000..0d37a74 --- /dev/null +++ b/src/NewProjectValidation.res @@ -0,0 +1,30 @@ +open Node + +let packageNameRegExp = /^[a-z0-9-]+$/ +let devcontainerDirectoryName = ".devcontainer" + +let containsOnlyDevcontainerDirectory = projectPath => + try { + switch Fs.readdirSync(projectPath) { + | [entry] if entry === devcontainerDirectoryName => + Path.join2(projectPath, devcontainerDirectoryName)->Fs.statSync->Fs.Stats.isDirectory + | _ => false + } + } catch { + | Exn.Error(_) => false + } + +let validateProjectName = (~cwd=Process.cwd(), projectName) => + if projectName->String.trim->String.length === 0 { + Error("Project name must not be empty.") + } else if !(packageNameRegExp->RegExp.test(projectName)) { + Error("Project name may only contain lower case letters, numbers and hyphens.") + } else { + let projectPath = Path.join2(cwd, projectName) + + if Fs.existsSync(projectPath) && !(projectPath->containsOnlyDevcontainerDirectory) { + Error(`The folder ${projectName} already exist in the current directory.`) + } else { + Ok() + } + } diff --git a/src/bindings/Node.res b/src/bindings/Node.res index d3519b8..b9ff0e6 100644 --- a/src/bindings/Node.res +++ b/src/bindings/Node.res @@ -1,9 +1,19 @@ module Fs = { @module("node:fs") external existsSync: string => bool = "existsSync" + @module("node:fs") external readdirSync: string => array = "readdirSync" + @module("node:fs") external readFileSync: (string, @as(json`"utf8"`) _) => string = "readFileSync" + type stats + + @module("node:fs") external statSync: string => stats = "statSync" + + module Stats = { + @send external isDirectory: stats => bool = "isDirectory" + } + module Promises = { @module("node:fs") @scope("promises") external readFile: (string, @as(json`"utf8"`) _) => promise = "readFile" diff --git a/test/NewProjectValidationTest.res b/test/NewProjectValidationTest.res new file mode 100644 index 0000000..6be884f --- /dev/null +++ b/test/NewProjectValidationTest.res @@ -0,0 +1,65 @@ +open Node + +let testRoot = Path.join2(Process.cwd(), ".tmp-new-project-validation-test") +let existingProjectMessage = "The folder my-app already exist in the current directory." + +let cleanupTestRoot = async () => + await Fs.Promises.rm(testRoot, ~options={recursive: true, force: true}) + +let resetTestRoot = async () => { + await cleanupTestRoot() + await Fs.Promises.mkdir(testRoot, ~options={recursive: true}) +} + +let assertValidationOk = result => + switch result { + | Ok() => () + | Error(message) => Assert.fail(`Expected project name to be valid, got: ${message}`) + } + +let assertValidationError = (result, expectedMessage) => + switch result { + | Error(message) => Assert.strictEqual(message, expectedMessage) + | Ok() => Assert.fail(`Expected validation error: ${expectedMessage}`) + } + +let validateProjectName = projectName => + NewProjectValidation.validateProjectName(~cwd=testRoot, projectName) + +Test.describe("NewProjectValidation", () => { + Test.testAsync("allows an existing project directory with only .devcontainer", async () => { + await resetTestRoot() + await Fs.Promises.mkdir( + Path.join([testRoot, "my-app", ".devcontainer"]), + ~options={ + recursive: true, + }, + ) + + validateProjectName("my-app")->assertValidationOk + await cleanupTestRoot() + }) + + Test.testAsync("rejects an existing project directory with additional files", async () => { + await resetTestRoot() + await Fs.Promises.mkdir( + Path.join([testRoot, "my-app", ".devcontainer"]), + ~options={ + recursive: true, + }, + ) + await Fs.Promises.writeFile(Path.join([testRoot, "my-app", "package.json"]), "{}") + + validateProjectName("my-app")->assertValidationError(existingProjectMessage) + await cleanupTestRoot() + }) + + Test.testAsync("rejects an existing project directory with a .devcontainer file", async () => { + await resetTestRoot() + await Fs.Promises.mkdir(Path.join2(testRoot, "my-app"), ~options={recursive: true}) + await Fs.Promises.writeFile(Path.join([testRoot, "my-app", ".devcontainer"]), "") + + validateProjectName("my-app")->assertValidationError(existingProjectMessage) + await cleanupTestRoot() + }) +})