From 764259f36d450a27524acfead9de46b7d39a27be Mon Sep 17 00:00:00 2001 From: Dmitry Bushev Date: Tue, 2 Jul 2024 14:39:43 +0300 Subject: [PATCH] Add project/duplicate endpoint (#10407) close #10367 Changelog: - add: `project/duplicate` project manager command to duplicate existing projects --- .../protocol-project-manager.md | 45 ++++ .../jsonrpc/test/JsonRpcServerTestKit.scala | 13 +- .../file/BlockingFileSystem.scala | 14 ++ .../infrastructure/file/FileSystem.scala | 10 +- .../repository/ProjectFileRepository.scala | 73 ++++--- .../repository/ProjectRepository.scala | 17 +- .../protocol/ClientController.scala | 5 + .../projectmanager/protocol/JsonRpc.scala | 1 + .../protocol/ProjectManagementApi.scala | 21 ++ .../ProjectDuplicateHandler.scala | 83 +++++++ .../service/MoveProjectDirCmd.scala | 2 +- .../service/ProjectService.scala | 52 +++-- .../service/ProjectServiceApi.scala | 11 + .../filesystem/FileSystemService.scala | 6 + .../filesystem/FileSystemServiceApi.scala | 7 + .../protocol/ProjectManagementApiSpec.scala | 206 +++++++++++++----- .../filesystem/FileSystemServiceSpec.scala | 46 ++++ 17 files changed, 501 insertions(+), 111 deletions(-) create mode 100644 lib/scala/project-manager/src/main/scala/org/enso/projectmanager/requesthandler/ProjectDuplicateHandler.scala diff --git a/docs/language-server/protocol-project-manager.md b/docs/language-server/protocol-project-manager.md index e31b3ccf3122..9f5450b75585 100644 --- a/docs/language-server/protocol-project-manager.md +++ b/docs/language-server/protocol-project-manager.md @@ -42,6 +42,7 @@ transport formats, please look [here](./protocol-architecture.md). - [`project/delete`](#projectdelete) - [`project/listSample`](#projectlistsample) - [`project/status`](#projectstatus) + - [`project/duplicate`](#projectduplicate) - [Action Progress Reporting](#action-progress-reporting) - [`task/started`](#taskstarted) - [`task/progress-update`](#taskprogress-update) @@ -750,6 +751,50 @@ interface ProjectStatusResponse { } ``` +### `project/duplicate` + +This message requests to make a copy of the project. + +- **Type:** Request +- **Direction:** Client -> Server +- **Connection:** Protocol +- **Visibility:** Public + +#### Parameters + +```typescript +interface ProjectDuplicateRequest { + /** + * The project to duplicate. + */ + projectId: UUID; + + /** + * Custom directory with the user projects. + */ + projectsDirectory?: string; +} +``` + +#### Result + +```typescript +interface ProjectDuplicateResponse { + projectId: UUID; + projectName: string; + projectNormalizedName: string; +} +``` + +#### Errors + +- [`ProjectDataStoreError`](#projectdatastoreerror) to signal problems with + underlying data store. +- [`ProjectNotFoundError`](#projectnotfounderror) to signal that the project + doesn't exist. +- [`ServiceError`](./protocol-common.md#serviceerror) to signal that the the + operation timed out. + ## Action Progress Reporting Some actions, especially those related to installation of new components may diff --git a/lib/scala/json-rpc-server-test/src/main/scala/org/enso/jsonrpc/test/JsonRpcServerTestKit.scala b/lib/scala/json-rpc-server-test/src/main/scala/org/enso/jsonrpc/test/JsonRpcServerTestKit.scala index 5162195b84f7..94c7c4155fa4 100644 --- a/lib/scala/json-rpc-server-test/src/main/scala/org/enso/jsonrpc/test/JsonRpcServerTestKit.scala +++ b/lib/scala/json-rpc-server-test/src/main/scala/org/enso/jsonrpc/test/JsonRpcServerTestKit.scala @@ -59,7 +59,7 @@ abstract class JsonRpcServerTestKit def clientControllerFactory(): ClientControllerFactory - var _clientControllerFactory: ClientControllerFactory = _ + private var _clientControllerFactory: ClientControllerFactory = _ override def beforeEach(): Unit = { super.beforeEach() @@ -67,7 +67,7 @@ abstract class JsonRpcServerTestKit factory.init() _clientControllerFactory = clientControllerFactory() server = new JsonRpcServer(factory, _clientControllerFactory) - binding = Await.result(server.bind(interface, port = 0), 3.seconds) + binding = Await.result(server.bind(interface, port = 0), 5.seconds.dilated) address = s"ws://$interface:${binding.localAddress.getPort}" } @@ -174,10 +174,12 @@ abstract class JsonRpcServerTestKit def fuzzyExpectJson( json: Json, timeout: FiniteDuration = 5.seconds.dilated - )(implicit pos: Position): Assertion = { + )(implicit pos: Position): Json = { val parsed = parse(expectMessage(timeout)) parsed should fuzzyMatchJson(json) + + inside(parsed) { case Right(json) => json } } def expectNoMessage(): Unit = outActor.expectNoMessage() @@ -191,9 +193,10 @@ abstract class JsonRpcServerTestKit trait FuzzyJsonMatchers { self: Matchers => class JsonEquals(expected: Json) extends Matcher[Either[io.circe.ParsingFailure, Json]] { - val patch = inferPatch(expected) - def apply(left: Either[io.circe.ParsingFailure, Json]) = { + private val patch = inferPatch(expected) + + def apply(left: Either[io.circe.ParsingFailure, Json]): MatchResult = { val leftFormatted = patch[scala.util.Try](left.getOrElse(Json.Null)) val expectedFormatted = patch[scala.util.Try](expected) MatchResult( diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/file/BlockingFileSystem.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/file/BlockingFileSystem.scala index a543909ce637..283261275893 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/file/BlockingFileSystem.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/file/BlockingFileSystem.scala @@ -89,6 +89,20 @@ class BlockingFileSystem[F[+_, +_]: Sync: ErrorChannel]( } .mapError(toFsFailure) + /** @inheritdoc */ + override def copy(from: File, to: File): F[FileSystemFailure, Unit] = + Sync[F] + .blockingOp { + if (to.isDirectory) { + FileUtils.copyToDirectory(from, to) + } else if (from.isDirectory) { + FileUtils.copyDirectory(from, to) + } else { + FileUtils.copyFile(from, to) + } + } + .mapError(toFsFailure) + /** @inheritdoc */ override def exists(file: File): F[FileSystemFailure, Boolean] = Sync[F] diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/file/FileSystem.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/file/FileSystem.scala index c10e7772ecac..d23ced2f47c1 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/file/FileSystem.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/file/FileSystem.scala @@ -48,7 +48,7 @@ trait FileSystem[F[+_, +_]] { */ def remove(path: File): F[FileSystemFailure, Unit] - /** Move a file or directory recursively + /** Move a file or directory recursively. * * @param from a path to the source * @param to a path to the destination @@ -56,6 +56,14 @@ trait FileSystem[F[+_, +_]] { */ def move(from: File, to: File): F[FileSystemFailure, Unit] + /** Copy a file or directory recursively. + * + * @param from a path to the source + * @param to a path to the destination + * @return either [[FileSystemFailure]] or Unit + */ + def copy(from: File, to: File): F[FileSystemFailure, Unit] + /** Tests if a file exists. * * @param file the file to check diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/ProjectFileRepository.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/ProjectFileRepository.scala index 08b5bb934bc9..448b2c1b8946 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/ProjectFileRepository.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/ProjectFileRepository.scala @@ -5,6 +5,7 @@ import java.nio.file.Path import java.nio.file.attribute.FileTime import java.util.UUID import org.enso.pkg.{Package, PackageManager} +import org.enso.pkg.validation.NameValidation import org.enso.projectmanager.boot.configuration.MetadataStorageConfig import org.enso.projectmanager.control.core.{ Applicative, @@ -207,14 +208,7 @@ class ProjectFileRepository[ /** @inheritdoc */ def update(project: Project): F[ProjectRepositoryFailure, Unit] = metadataStorage(project.path) - .persist( - ProjectMetadata( - id = project.id, - kind = project.kind, - created = project.created, - lastOpened = project.lastOpened - ) - ) + .persist(ProjectMetadata(project)) .mapError(th => StorageFailure(th.toString)) /** @inheritdoc */ @@ -231,46 +225,55 @@ class ProjectFileRepository[ } /** @inheritdoc */ - override def moveProjectToTargetDir( + override def moveProject( projectId: UUID, newName: String ): F[ProjectRepositoryFailure, File] = { def move(project: Project) = for { - targetPath <- findTargetPath(newName) - _ <- moveProjectDir(project, targetPath) + targetPath <- findTargetPath(NameValidation.normalizeName(newName)) + _ <- moveProjectDir(project.path, targetPath) } yield targetPath for { - project <- getProject(projectId) - primaryPath = new File(projectsPath, newName) - finalPath <- - if (isLocationOk(project.path, primaryPath)) { - CovariantFlatMap[F].pure(primaryPath) - } else { - move(project) - } - } yield finalPath + project <- getProject(projectId) + projectPath <- move(project) + } yield projectPath } - private def isLocationOk( - currentFile: File, - primaryFile: File - ): Boolean = { - val currentPath = currentFile.toString - val primaryPath = primaryFile.toString - if (currentPath.startsWith(primaryPath)) { - val suffixPattern = "_\\d+" - val suffix = currentPath.substring(primaryPath.length, currentPath.length) - suffix.matches(suffixPattern) - } else { - false - } + /** @inheritdoc */ + override def copyProject( + project: Project, + newName: String, + newMetadata: ProjectMetadata + ): F[ProjectRepositoryFailure, Project] = { + def copy(project: Project) = + for { + targetPath <- findTargetPath(NameValidation.normalizeName(newName)) + _ <- copyProjectDir(project.path, targetPath) + } yield targetPath + + for { + newProjectPath <- copy(project) + _ <- metadataStorage(newProjectPath) + .persist(newMetadata) + .mapError(th => StorageFailure(th.toString)) + _ <- renamePackage(newProjectPath, newName) + newProject <- getProject(newMetadata.id) + } yield newProject + } + + private def moveProjectDir(projectPath: File, targetPath: File) = { + fileSystem + .move(projectPath, targetPath) + .mapError[ProjectRepositoryFailure](failure => + StorageFailure(failure.toString) + ) } - private def moveProjectDir(project: Project, targetPath: File) = { + private def copyProjectDir(projectPath: File, targetPath: File) = { fileSystem - .move(project.path, targetPath) + .copy(projectPath, targetPath) .mapError[ProjectRepositoryFailure](failure => StorageFailure(failure.toString) ) diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/ProjectRepository.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/ProjectRepository.scala index ae81e9b3294a..2b94fb4de6f4 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/ProjectRepository.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/infrastructure/repository/ProjectRepository.scala @@ -3,8 +3,7 @@ package org.enso.projectmanager.infrastructure.repository import java.io.File import java.nio.file.Path import java.util.UUID - -import org.enso.projectmanager.model.Project +import org.enso.projectmanager.model.{Project, ProjectMetadata} /** An abstraction for accessing project domain objects from durable storage. * @@ -82,11 +81,23 @@ trait ProjectRepository[F[+_, +_]] { * @param projectId the project id * @param newName the new project name */ - def moveProjectToTargetDir( + def moveProject( projectId: UUID, newName: String ): F[ProjectRepositoryFailure, File] + /** Create a copy of the project. + * + * @param project the project to copy + * @param newName the new project name + * @param newMetadata the new project metadata + */ + def copyProject( + project: Project, + newName: String, + newMetadata: ProjectMetadata + ): F[ProjectRepositoryFailure, Project] + /** Gets a package name for the specified project. * * @param projectId the project id diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/protocol/ClientController.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/protocol/ClientController.scala index bb1512f74576..7665687090bf 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/protocol/ClientController.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/protocol/ClientController.scala @@ -86,6 +86,11 @@ class ClientController[F[+_, +_]: Exec: CovariantFlatMap: ErrorChannel: Sync]( ), ProjectRename -> ProjectRenameHandler .props[F](projectService, timeoutConfig.requestTimeout), + ProjectDuplicate -> ProjectDuplicateHandler.props[F]( + projectService, + timeoutConfig.requestTimeout, + timeoutConfig.retries + ), EngineListInstalled -> EngineListInstalledHandler.props( runtimeVersionManagementService, timeoutConfig.requestTimeout diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/protocol/JsonRpc.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/protocol/JsonRpc.scala index 51b22889f5c5..ceb8f234b6b0 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/protocol/JsonRpc.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/protocol/JsonRpc.scala @@ -26,6 +26,7 @@ object JsonRpc { .registerRequest(ProjectClose) .registerRequest(ProjectRename) .registerRequest(ProjectList) + .registerRequest(ProjectDuplicate) .registerNotification(TaskStarted) .registerNotification(TaskProgressUpdate) .registerNotification(TaskFinished) diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/protocol/ProjectManagementApi.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/protocol/ProjectManagementApi.scala index cba96a8bf89b..28c1e338a853 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/protocol/ProjectManagementApi.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/protocol/ProjectManagementApi.scala @@ -83,6 +83,27 @@ object ProjectManagementApi { } } + case object ProjectDuplicate extends Method("project/duplicate") { + + case class Params(projectId: UUID, projectsDirectory: Option[String]) + + case class Result( + projectId: UUID, + projectName: String, + projectNormalizedName: String + ) + + implicit val hasParams: HasParams.Aux[this.type, ProjectDuplicate.Params] = + new HasParams[this.type] { + type Params = ProjectDuplicate.Params + } + + implicit val hasResult: HasResult.Aux[this.type, ProjectDuplicate.Result] = + new HasResult[this.type] { + type Result = ProjectDuplicate.Result + } + } + case object ProjectOpen extends Method("project/open") { case class Params( diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/requesthandler/ProjectDuplicateHandler.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/requesthandler/ProjectDuplicateHandler.scala new file mode 100644 index 000000000000..52df4982c82e --- /dev/null +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/requesthandler/ProjectDuplicateHandler.scala @@ -0,0 +1,83 @@ +package org.enso.projectmanager.requesthandler + +import akka.actor._ +import org.enso.projectmanager.control.core.CovariantFlatMap +import org.enso.projectmanager.control.core.syntax._ +import org.enso.projectmanager.control.effect.{Exec, Sync} +import org.enso.projectmanager.infrastructure.file.Files +import org.enso.projectmanager.protocol.ProjectManagementApi.ProjectDuplicate +import org.enso.projectmanager.service.{ + ProjectServiceApi, + ProjectServiceFailure +} + +import scala.concurrent.duration.FiniteDuration + +/** A request handler for `project/duplicate` commands. + * + * @param projectService a project service + * @param requestTimeout a request timeout + * @param timeoutRetries a number of timeouts to wait until a failure is reported + */ +class ProjectDuplicateHandler[ + F[+_, +_]: Exec: CovariantFlatMap: Sync +]( + projectService: ProjectServiceApi[F], + requestTimeout: FiniteDuration, + timeoutRetries: Int +) extends RequestHandler[ + F, + ProjectServiceFailure, + ProjectDuplicate.type, + ProjectDuplicate.Params, + ProjectDuplicate.Result + ]( + ProjectDuplicate, + Some(requestTimeout), + timeoutRetries + ) { + + override def handleRequest: ProjectDuplicate.Params => F[ + ProjectServiceFailure, + ProjectDuplicate.Result + ] = { params => + for { + projectsDirectory <- Sync[F].effect( + params.projectsDirectory.map(Files.getAbsoluteFile) + ) + project <- projectService.duplicateUserProject( + projectId = params.projectId, + projectsDirectory = projectsDirectory + ) + _ = logger.trace( + "Duplicated project [{}] with the new name [{}].", + params.projectId, + project.name + ) + } yield ProjectDuplicate.Result(project.id, project.name, project.module) + } +} + +object ProjectDuplicateHandler { + + /** Creates a configuration object used to create a [[ProjectDuplicateHandler]]. + * + * @param projectService a project service + * @param requestTimeout a request timeout + * @param timeoutRetries a number of timeouts to wait until a failure is reported + * @return a configuration object + */ + def props[F[+_, +_]: Exec: CovariantFlatMap: Sync]( + projectService: ProjectServiceApi[F], + requestTimeout: FiniteDuration, + timeoutRetries: Int + ): Props = + Props( + new ProjectDuplicateHandler( + projectService, + requestTimeout, + timeoutRetries + ) + ) + +} diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/service/MoveProjectDirCmd.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/service/MoveProjectDirCmd.scala index 9c5e73583b8d..75179447701d 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/service/MoveProjectDirCmd.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/service/MoveProjectDirCmd.scala @@ -32,7 +32,7 @@ class MoveProjectDirCmd[F[+_, +_]: CovariantFlatMap: ErrorChannel]( def go() = for { _ <- log.debug("Moving project [{}] to [{}].", projectId, newName) - dir <- repo.moveProjectToTargetDir(projectId, newName) + dir <- repo.moveProject(projectId, newName) _ <- log.info("Project [{}] moved to [{}].", projectId, dir) } yield () diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/service/ProjectService.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/service/ProjectService.scala index e7ab908e3fe0..b4acc7874eed 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/service/ProjectService.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/service/ProjectService.scala @@ -33,6 +33,7 @@ import org.enso.projectmanager.infrastructure.repository.{ ProjectRepositoryFailure } import org.enso.projectmanager.infrastructure.time.Clock +import org.enso.projectmanager.model import org.enso.projectmanager.model.Project import org.enso.projectmanager.model.ProjectKinds.UserProject import org.enso.projectmanager.service.ProjectServiceFailure._ @@ -96,7 +97,7 @@ class ProjectService[ projectsDirectory ) repo = projectRepositoryFactory.getProjectRepository(projectsDirectory) - name <- getNameForNewProject(projectName, projectTemplate, repo) + name <- getNameForNewProject(projectName, repo) _ <- log.info("Created project with actual name [{}].", name) _ <- validateProjectName(name) _ <- checkIfNameExists(name, repo) @@ -373,6 +374,33 @@ class ProjectService[ } } + /** @inheritdoc */ + override def duplicateUserProject( + projectId: UUID, + projectsDirectory: Option[File] + ): F[ProjectServiceFailure, Project] = + for { + _ <- log.debug("Duplicating project [{}].", projectId) + repo = projectRepositoryFactory.getProjectRepository(projectsDirectory) + project <- getUserProject(projectId, repo) + suggestedProjectName = getNameForDuplicatedProject(project.name) + newName <- getNameForNewProject(suggestedProjectName, repo) + _ <- validateProjectName(newName) + _ <- log.debug("Validated new project name [{}]", newName) + repo = projectRepositoryFactory.getProjectRepository(projectsDirectory) + createdTime <- clock.nowInUtc() + newMetadata = model.ProjectMetadata( + id = UUID.randomUUID(), + kind = project.kind, + created = createdTime, + lastOpened = None + ) + newProject <- repo + .copyProject(project, newName, newMetadata) + .mapError(toServiceFailure) + _ <- log.info("Project copied [{}].", newProject) + } yield newProject + /** @inheritdoc */ override def listProjects( maybeSize: Option[Int] @@ -474,7 +502,6 @@ class ProjectService[ private def getNameForNewProject( projectName: String, - projectTemplate: Option[String], projectRepository: ProjectRepository[F] ): F[ProjectServiceFailure, String] = { def mkName(name: String, suffix: Int): String = @@ -490,20 +517,17 @@ class ProjectService[ ) } - projectTemplate match { - case Some(_) => - CovariantFlatMap[F] - .ifM(projectRepository.exists(projectName))( - ifTrue = findAvailableName(projectName, 1), - ifFalse = CovariantFlatMap[F].pure(projectName) - ) - .mapError(toServiceFailure) - case None => - CovariantFlatMap[F].pure(projectName) - } - + CovariantFlatMap[F] + .ifM(projectRepository.exists(projectName))( + ifTrue = findAvailableName(projectName, 1), + ifFalse = CovariantFlatMap[F].pure(projectName) + ) + .mapError(toServiceFailure) } + private def getNameForDuplicatedProject(projectName: String): String = + s"$projectName (copy)" + /** Retrieve project info. * * @param clientId the requester id diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/service/ProjectServiceApi.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/service/ProjectServiceApi.scala index a75394f052f0..6c7ddd6b43b9 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/service/ProjectServiceApi.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/service/ProjectServiceApi.scala @@ -97,6 +97,17 @@ trait ProjectServiceApi[F[+_, +_]] { projectId: UUID ): F[ProjectServiceFailure, Unit] + /** Duplicate an existing project. + * + * @param projectId the project to copy + * @param projectsDirectory the path to the projects directory + * @return the new duplicated project + */ + def duplicateUserProject( + projectId: UUID, + projectsDirectory: Option[File] + ): F[ProjectServiceFailure, Project] + /** Lists the user's most recently opened projects.. * * @param maybeSize the size of result set diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/service/filesystem/FileSystemService.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/service/filesystem/FileSystemService.scala index 3e3cceb9d525..fb7646ab1917 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/service/filesystem/FileSystemService.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/service/filesystem/FileSystemService.scala @@ -56,6 +56,12 @@ class FileSystemService[F[+_, +_]: Applicative: CovariantFlatMap: ErrorChannel]( .move(from, to) .mapError(_ => FileSystemServiceFailure.FileSystem("Failed to move path")) + /** @inheritdoc */ + override def copy(from: File, to: File): F[FileSystemServiceFailure, Unit] = + fileSystem + .copy(from, to) + .mapError(_ => FileSystemServiceFailure.FileSystem("Failed to copy path")) + /** @inheritdoc */ override def write( path: File, diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/service/filesystem/FileSystemServiceApi.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/service/filesystem/FileSystemServiceApi.scala index 039f8d72b2af..4806f4be2d85 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/service/filesystem/FileSystemServiceApi.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/service/filesystem/FileSystemServiceApi.scala @@ -30,6 +30,13 @@ trait FileSystemServiceApi[F[+_, +_]] { */ def move(from: File, to: File): F[FileSystemServiceFailure, Unit] + /** Copy a file or directory recursively. + * + * @param from the target path + * @param to the destination path + */ + def copy(from: File, to: File): F[FileSystemServiceFailure, Unit] + /** Writes a file * * @param path the file path to write diff --git a/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/protocol/ProjectManagementApiSpec.scala b/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/protocol/ProjectManagementApiSpec.scala index 22dce8833a69..77106d773c92 100644 --- a/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/protocol/ProjectManagementApiSpec.scala +++ b/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/protocol/ProjectManagementApiSpec.scala @@ -116,53 +116,6 @@ class ProjectManagementApiSpec """) } - "fail when the project with the same name exists" in { - implicit val client: WsTestClient = new WsTestClient(address) - client.send(json""" - { "jsonrpc": "2.0", - "method": "project/create", - "id": 1, - "params": { - "name": "Foo" - } - } - """) - val projectId = getGeneratedUUID - client.expectJson(json""" - { - "jsonrpc" : "2.0", - "id" : 1, - "result" : { - "projectId" : $projectId, - "projectName" : "Foo", - "projectNormalizedName": "Foo" - } - } - """) - client.send(json""" - { "jsonrpc": "2.0", - "method": "project/create", - "id": 2, - "params": { - "name": "Foo" - } - } - """) - client.expectJson(json""" - { - "jsonrpc":"2.0", - "id":2, - "error":{ - "code":4003, - "message":"Project with the provided name exists" - } - } - """) - - //teardown - deleteProject(projectId) - } - "create project structure" in { val projectName = "Foo" @@ -276,6 +229,27 @@ class ProjectManagementApiSpec deleteProject(projectId) } + "find a name when project with the same name exists" in { + val projectName = "Foo" + + implicit val client: WsTestClient = new WsTestClient(address) + + val projectId1 = createProject(projectName) + val projectId2 = createProject( + projectName, + nameSuffix = Some(1) + ) + + val projectDir = new File(userProjectDir, "Foo_1") + val packageFile = new File(projectDir, "package.yaml") + + Files.readAllLines(packageFile.toPath) contains "name: Foo_1" + + //teardown + deleteProject(projectId1) + deleteProject(projectId2) + } + "find a name when project is created from template" in { val projectName = "Foo" @@ -307,7 +281,7 @@ class ProjectManagementApiSpec "id": 1, "params": { "name": "Foo", - "version": ${CurrentVersion.version.toString()} + "version": ${CurrentVersion.version.toString} } } """) @@ -582,13 +556,14 @@ class ProjectManagementApiSpec "fail when project's edition could not be resolved" in { pending - implicit val client = new WsTestClient(address) - val projectId = createProject("Foo") + implicit val client: WsTestClient = new WsTestClient(address) + //given + val projectId = createProject("Foo") setProjectParentEdition( "Foo", "some_weird_edition_name_that-surely-does-not-exist" ) - + //when client.send(json""" { "jsonrpc": "2.0", "method": "project/open", @@ -598,6 +573,7 @@ class ProjectManagementApiSpec } } """) + //then client.expectJson(json""" { "jsonrpc":"2.0", @@ -608,7 +584,7 @@ class ProjectManagementApiSpec } } """) - + //teardown deleteProject(projectId) } @@ -1347,4 +1323,130 @@ class ProjectManagementApiSpec deleteProject(projectId) } } + + "project/duplicate" must { + + "duplicate a project" in { + implicit val client: WsTestClient = new WsTestClient(address) + //given + val projectName = "Project To Copy" + val projectId = createProject(projectName) + //when + client.send(json""" + { "jsonrpc": "2.0", + "method": "project/duplicate", + "id": 0, + "params": { + "projectId": $projectId + } + } + """) + //then + val newProjectName = "Project To Copy (copy)" + val duplicateReply = client.fuzzyExpectJson(json""" + { + "jsonrpc": "2.0", + "id": 0, + "result": { + "projectId": "*", + "projectName": $newProjectName, + "projectNormalizedName": "ProjectToCopycopy" + } + } + """) + + val Some(duplicatedProjectId) = for { + reply <- duplicateReply.asObject + resultJson <- reply("result") + result <- resultJson.asObject + projectIdJson <- result("projectId") + projectId <- projectIdJson.asString + } yield UUID.fromString(projectId) + + { + val projectDir = new File(userProjectDir, "ProjectToCopycopy") + val packageFile = new File(projectDir, "package.yaml") + val buffer = Source.fromFile(packageFile) + try { + val lines = buffer.getLines() + lines.contains(s"name: $newProjectName") shouldBe true + } finally { + buffer.close() + } + } + + //when + client.send(json""" + { "jsonrpc": "2.0", + "method": "project/duplicate", + "id": 0, + "params": { + "projectId": $projectId + } + } + """) + //then + val newProjectName1 = "Project To Copy (copy)_1" + val duplicateReply1 = client.fuzzyExpectJson(json""" + { + "jsonrpc": "2.0", + "id": 0, + "result": { + "projectId": "*", + "projectName": $newProjectName1, + "projectNormalizedName": "ProjectToCopycopy_1" + } + } + """) + + val Some(duplicatedProjectId1) = for { + reply <- duplicateReply1.asObject + resultJson <- reply("result") + result <- resultJson.asObject + projectIdJson <- result("projectId") + projectId <- projectIdJson.asString + } yield UUID.fromString(projectId) + + { + val projectDir = new File(userProjectDir, "ProjectToCopycopy_1") + val packageFile = new File(projectDir, "package.yaml") + val buffer = Source.fromFile(packageFile) + try { + val lines = buffer.getLines() + lines.contains(s"name: $newProjectName1") shouldBe true + } finally { + buffer.close() + } + } + + //teardown + deleteProject(duplicatedProjectId) + deleteProject(duplicatedProjectId1) + deleteProject(projectId) + } + + "fail when project doesn't exist" in { + val client = new WsTestClient(address) + client.send(json""" + { "jsonrpc": "2.0", + "method": "project/duplicate", + "id": 1, + "params": { + "projectId": ${UUID.randomUUID()} + } + } + """) + client.expectJson(json""" + { + "jsonrpc":"2.0", + "id":1, + "error":{ + "code":4004, + "message":"Project with the provided id does not exist" + } + } + """) + } + + } } diff --git a/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/service/filesystem/FileSystemServiceSpec.scala b/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/service/filesystem/FileSystemServiceSpec.scala index 8c0d67a6a853..bcd078362054 100644 --- a/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/service/filesystem/FileSystemServiceSpec.scala +++ b/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/service/filesystem/FileSystemServiceSpec.scala @@ -183,6 +183,52 @@ class FileSystemServiceSpec FileUtils.deleteQuietly(targetPath) } + "copy file" in { + val testDir = testStorageConfig.userProjectsPath + + val targetFileName = "target_copy_file.txt" + val destinationFileName = "destination_copy_file.txt" + val targetFilePath = new File(testDir, targetFileName) + val destinationFilePath = new File(testDir, destinationFileName) + + FileUtils.forceMkdirParent(targetFilePath) + FileUtils.touch(targetFilePath) + + fileSystemService + .copy(targetFilePath, destinationFilePath) + .unsafeRunSync() + + Files.exists(targetFilePath.toPath) shouldEqual true + Files.exists(destinationFilePath.toPath) shouldEqual true + + // cleanup + FileUtils.deleteQuietly(targetFilePath) + FileUtils.deleteQuietly(destinationFilePath) + } + + "copy directory" in { + implicit val client: WsTestClient = new WsTestClient(address) + + val testDir = testStorageConfig.userProjectsPath + + val projectName = "New_Project_To_Copy" + createProject(projectName) + + val directoryPath = new File(testDir, projectName) + val targetPath = new File(testDir, "Target_Copy_Directory") + + fileSystemService + .copy(directoryPath, targetPath) + .unsafeRunSync() + + Files.exists(directoryPath.toPath) shouldEqual true + Files.isDirectory(targetPath.toPath) shouldEqual true + + // cleanup + FileUtils.deleteQuietly(directoryPath) + FileUtils.deleteQuietly(targetPath) + } + "write path" in { val testDir = testStorageConfig.userProjectsPath