All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.lunatech.cmt.Helpers.scala Maven / Gradle / Ivy

The newest version!
package com.lunatech.cmt

/** Copyright 2022 - Eric Loots - [email protected] / Trevor Burton-McCreadie - [email protected]
  *
  * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
  * the License. You may obtain a copy of the License at
  *
  * http://www.apache.org/licenses/LICENSE-2.0
  *
  * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
  * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  *
  * See the License for the specific language governing permissions and limitations under the License.
  */

import com.lunatech.cmt.ProcessDSL.ProcessCmd
import com.lunatech.cmt.core.GeneratorInfo
import com.lunatech.cmt.core.command.Package.*
import com.typesafe.config.{ConfigFactory, ConfigRenderOptions}
import sbt.io.IO as sbtio
import sbt.io.syntax.*

import scala.jdk.CollectionConverters.*

final case class StudentifiedSkelFolders(solutionsFolder: File)
object Helpers:

  def fileList(base: File): Vector[File] =
    @scala.annotation.tailrec
    def fileList(filesSoFar: Vector[File], folders: Vector[File]): Vector[File] =
      val subs = folders.foldLeft(Vector.empty[File]) { case (tally, folder) =>
        tally ++ sbtio.listFiles(folder)
      }
      subs.partition(_.isDirectory) match
        case (rem, result) if rem.isEmpty => filesSoFar ++ result
        case (rem, tally)                 => fileList(filesSoFar ++ tally, rem)

    if base.isFile then Vector(base)
    else
      val (seedFolders, seedFiles) =
        sbtio.listFiles(base).partition(_.isDirectory)
      fileList(seedFiles.toVector, seedFolders.toVector)
  end fileList

  def resolveMainRepoPath(mainRepo: File): Either[CmtError, File] = {
    for {
      rp <- getRepoPathFromGit(mainRepo)
    } yield new File(rp)
  }

  private def getRepoPathFromGit(repo: File): Either[CmtError, String] = {
    import ProcessDSL.*
    "git rev-parse --show-toplevel".toProcessCmd(workingDir = repo).runAndReadOutput()
  }

  def exitIfGitIndexOrWorkspaceIsntClean(mainRepo: File): Either[CmtError, Unit] =
    import ProcessDSL.toProcessCmd
    val workspaceIsUnclean = "git status --porcelain"
      .toProcessCmd(workingDir = mainRepo)
      .runAndReadOutput()
      .map(str => str.split("\n").toSeq.map(_.trim).filter(_ != ""))
      .map(_.length)

    workspaceIsUnclean match {
      case Right(cnt) if cnt > 0 =>
        Left("Main repository isn't clean. Commit changes and try again".toExecuteCommandErrorMessage)
      case Right(_)  => Right(())
      case Left(msg) => Left(msg)
    }

  def checkpreExistingAndCreateArtifactRepo(
      artifactBaseDirectory: File,
      artifactRootFolder: File,
      forceDeleteDestinationDirectory: Boolean): Either[CmtError, String] =
    (artifactRootFolder.exists, forceDeleteDestinationDirectory) match
      case (true, true) =>
        if artifactBaseDirectory.canWrite then
          sbtio.delete(artifactRootFolder)
          sbtio.createDirectory(artifactRootFolder)
          Right("Created artifact folder")
        else Left(FailedToExecuteCommand(ErrorMessage(s"${artifactBaseDirectory.getPath} isn't writeable")))

      case (true, false) =>
        Left(FailedToExecuteCommand(ErrorMessage(s"$artifactRootFolder exists already")))

      case (false, _) =>
        if artifactBaseDirectory.canWrite then
          sbtio.createDirectory(artifactRootFolder)
          Right("Created artifact folder")
        else Left(FailedToExecuteCommand(ErrorMessage(s"${artifactBaseDirectory.getPath} isn't writeable")))
  end checkpreExistingAndCreateArtifactRepo

  def addFirstExercise(cleanedMainRepo: File, firstExercise: String, studentifiedRootFolder: File)(
      config: CMTaConfig): Unit =
    sbtio.copyDirectory(
      cleanedMainRepo / config.mainRepoExerciseFolder / firstExercise,
      studentifiedRootFolder / config.studentifiedRepoActiveExerciseFolder)
  end addFirstExercise

  final case class ExercisesMetadata(exercisePrefix: String, exercises: Vector[String], exerciseNumbers: Vector[Int])

  def getExerciseMetadata(mainRepo: File)(config: CMTaConfig): Either[CmtError, ExercisesMetadata] =
    val PrefixSpec = raw"(.*)_\d{3}_\w+$$".r
    val matchedNames =
      sbtio.listFiles(isExerciseFolder())(mainRepo / config.mainRepoExerciseFolder).map(_.getName).to(List)
    val prefixes = matchedNames.map { case PrefixSpec(n) => n }.to(Set)
    sbtio.listFiles(isExerciseFolder())(mainRepo / config.mainRepoExerciseFolder).map(_.getName).to(Vector).sorted match
      case Vector() =>
        Left("No exercises found. Check your configuration".toExecuteCommandErrorMessage)
      case exercises =>
        prefixes.size match
          case 0 => Left("No exercises found".toExecuteCommandErrorMessage)
          case 1 =>
            val exerciseNumbers = exercises.map(extractExerciseNr)
            if exerciseNumbers.size == exerciseNumbers.to(Set).size then
              Right(ExercisesMetadata(prefixes.head, exercises, exerciseNumbers))
            else Left("Duplicate exercise numbers found".toExecuteCommandErrorMessage)
          case _ => Left(s"Multiple exercise prefixes (${prefixes.mkString(", ")}) found".toExecuteCommandErrorMessage)
  end getExerciseMetadata

  def validatePrefixes(prefixes: Set[String]): Unit =
    if prefixes.size > 1 then printErrorAndExit(s"Multiple exercise prefixes (${prefixes.mkString(", ")}) found")

  def zipAndDeleteOriginal(baseFolder: File, zipToFolder: File, exercise: String, time: Option[Long] = None): Unit =
    val filesToZip = fileList(baseFolder / exercise).map(f => (f, sbtio.relativize(baseFolder, f))).collect {
      case (f, Some(s)) => (f, s)
    }
    val zipFile = zipToFolder / s"${exercise}.zip"
    sbtio.zip(filesToZip, zipFile, time)
    sbtio.delete(baseFolder / exercise)
  end zipAndDeleteOriginal

  def extractUniquePaths(paths: Seq[String]): (Seq[String], Seq[String]) =

    @scala.annotation.tailrec
    def fmsp(
        paths: Seq[String],
        prefix: String,
        unique: Seq[String],
        redundant: Seq[String]): (Seq[String], Seq[String]) =
      paths match {
        case Nil =>
          (unique, redundant)
        case p +: remainder =>
          if (p.startsWith(prefix))
            fmsp(remainder, prefix, unique, p +: redundant)
          else
            fmsp(remainder, p, p +: unique, redundant)
      }
    end fmsp

    if (paths.isEmpty) (paths, Seq.empty)
    else {
      val pathsSorted = paths.sorted
      fmsp(pathsSorted.tail, pathsSorted.head, List(pathsSorted.head), List.empty)
    }
  end extractUniquePaths

  def hideExercises(cleanedMainRepo: File, solutionsFolder: File, exercises: Vector[String])(config: CMTaConfig): Unit =
    val now: Option[Long] = Some(java.time.Instant.now().toEpochMilli())
    for (exercise <- exercises)
      print(s".")
      zipAndDeleteOriginal(cleanedMainRepo / config.mainRepoExerciseFolder, solutionsFolder, exercise, now)
  end hideExercises

  def dumpStringToFile(string: String, file: File): Unit =
    import java.nio.charset.StandardCharsets
    import java.nio.file.Files
    val _ = Files.write(file.toPath, string.getBytes(StandardCharsets.UTF_8))

  private def dontTouchExtraFiles(config: CMTaConfig): List[String] =
    if config.studentifiedRepoActiveExerciseFolder == "." then List(".cmt", ".git", ".gitignore")
    else List.empty[String]

  private def dontTouchFilesAdjust(config: CMTaConfig, path: String): String =
    if config.studentifiedRepoActiveExerciseFolder == "." then path
    else s"${config.studentifiedRepoActiveExerciseFolder}/${path}"

  def writeStudentifiedCMTConfig(configFile: File, exercises: Seq[String])(
      config: CMTaConfig,
      generatorInfo: GeneratorInfo): Unit =
    val configMap = Map(
      "generator-info" -> Map(
        "generator-name" -> generatorInfo.generatorName,
        "generator-version" -> generatorInfo.generatorVersion).asJava,
      "studentified-repo-solutions-folder" -> config.studentifiedRepoSolutionsFolder,
      "studentified-saved-states-folder" -> config.studentifiedSavedStatesFolder,
      "studentified-repo-bookmark-file" -> config.studentifiedRepoBookmarkFile,
      "test-code-size-and-checksums" -> config.testCodeSizeAndChecksums,
      "code-size-and-checksums" -> config.codeSizeAndChecksums,
      "active-exercise-folder" -> config.studentifiedRepoActiveExerciseFolder,
      "test-code-folders" -> config.testCodeFolders.asJava,
      "read-me-files" -> config.readMeFiles.asJava,
      "exercises" -> exercises.asJava,
      "cmt-studentified-dont-touch" -> (config.cmtStudentifiedDontTouch.map(path =>
        dontTouchFilesAdjust(config, path)) ++ dontTouchExtraFiles(config)).asJava)
    val cmtConfig =
      ConfigFactory.parseMap(configMap.asJava).root().render(ConfigRenderOptions.concise().setFormatted(true))
    dumpStringToFile(cmtConfig, configFile)

  def writeStudentifiedCMTBookmark(bookmarkFile: File, firstExercise: String): Unit =
    dumpStringToFile(firstExercise, bookmarkFile)

  def withZipFile(solutionsFolder: File, exerciseID: String)(
      code: File => Either[CmtError, String]): Either[CmtError, String] =
    val archive = solutionsFolder / s"$exerciseID.zip"
    sbtio.unzip(archive, solutionsFolder)
    val retVal = code(solutionsFolder / exerciseID)
    sbtio.delete(solutionsFolder / exerciseID)
    retVal
  end withZipFile

  def deleteFileIfExists(file: File): Unit =
    if (file.exists()) sbtio.delete(file)
  def initializeGitRepo(linearizedProject: File): Either[CmtError, Unit] =
    import ProcessDSL.toProcessCmd
    s"git init"
      .toProcessCmd(workingDir = linearizedProject)
      .runWithStatus(toConsoleRed(s"Failed to initialize linearized git repository in ${linearizedProject.getPath}"))
  end initializeGitRepo

  def setGitConfig(linearizedProject: File): Either[CmtError, Unit] =
    import ProcessDSL.toProcessCmd
    s"git config --local init.defaultBranch main"
      .toProcessCmd(workingDir = linearizedProject)
      .runWithStatus(toConsoleRed(s"Failed to default branch name in ${linearizedProject.getPath}"))
    s"""git config --local user.email "[email protected]""""
      .toProcessCmd(workingDir = linearizedProject)
      .runWithStatus(toConsoleRed(s"Failed to set 'user.mail' in git configuration"))
    s"""git config --local user.name "Eric Loots""""
      .toProcessCmd(workingDir = linearizedProject)
      .runWithStatus(toConsoleRed(s"Failed to set 'user.name' in git configuration"))
  end setGitConfig

  def commitToGit(commitMessage: String, projectFolder: File): Either[CmtError, Unit] =
    import ProcessDSL.toProcessCmd

    for {
      _ <- s"git add -A"
        .toProcessCmd(workingDir = projectFolder)
        .runWithStatus(toConsoleRed(s"Failed to add first exercise files"))
      result <- s"""git commit -m "$commitMessage""""
        .toProcessCmd(workingDir = projectFolder)
        .runWithStatus(toConsoleRed(s"Failed to commit files for $commitMessage"))
    } yield result
  end commitToGit

  private val ExerciseNumberSpec = raw".*_(\d{3})_.*".r

  def extractExerciseNr(exercise: String): Int = {
    val ExerciseNumberSpec(d) = exercise: @unchecked
    d.toInt
  }

  val ignoreProcessStdOutStdErr: sys.process.ProcessLogger =
    sys.process.ProcessLogger(_ => (), _ => ())
  def copyCleanViaGit(mainRepo: File, tmpDir: File, repoName: String): Either[CmtError, Unit] =

    import ProcessDSL.*

    import java.util.UUID
    val initBranch = UUID.randomUUID.toString
    val tmpRemoteBranch = s"CMT-${UUID.randomUUID.toString}"
    val bareRepoFolder = tmpDir / s"${repoName}.git"
    // @formatter:off
    val script = List(
      (s"${tmpDir.getPath}",
        List(s"git init --bare ${repoName}.git")
      ),
      (
        s"${mainRepo.getPath}",
        List(
          s"git remote add ${tmpRemoteBranch} ${tmpDir.getPath}/${repoName}.git",
          s"git push ${tmpRemoteBranch} HEAD:refs/heads/${initBranch}")
      ),
      (s"${tmpDir.getPath}",
        List(s"git clone -b ${initBranch} ${tmpDir.getPath}/${repoName}.git")
      ),
      (s"${mainRepo.getPath}",
        List(s"git remote remove ${tmpRemoteBranch}")
      )
    )
    // @formatter:on
    val commands = for {
      (workingDir, commands) <- script
      command <- commands
    } yield command.toProcessCmd(new File(workingDir))
    sbtio.delete(bareRepoFolder)
    for {
      result <- runCommands(commands)
    } yield result

  end copyCleanViaGit

  @scala.annotation.tailrec
  private def runCommands(commands: Seq[ProcessCmd]): Either[CmtError, Unit] =
    import ProcessDSL.*

    commands match
      case (command @ ProcessCmd(cmds, wd)) +: remainingCommands =>
        val commandAsString = cmds.mkString(" ")
        command.runWithStatus(commandAsString) match
          case r @ Right(_)  => runCommands(remainingCommands)
          case l @ Left(msg) => l
      case Nil => Right(())

  private val separatorChar: Char = java.io.File.separatorChar

  def adaptToNixSeparatorChar(path: String): String =
    separatorChar match
      case '\\' =>
        path.replaceAll("""\\""", "/")
      case '/' =>
        path
      case _ =>
        path.replaceAll(s"/", s"$separatorChar")

  def adaptToOSSeparatorChar(path: String): String =
    separatorChar match
      case '\\' =>
        path.replaceAll("/", """\\""")
      case '/' =>
        path
      case _ =>
        path.replaceAll(s"/", s"$separatorChar")

  import org.apache.commons.codec.binary.Hex

  import java.nio.file.Files
  import java.security.MessageDigest
  def fileSize(f: File): Long =
    Files.size(f.toPath)

  def fileSha256Hex(f: File): String =
    val fileContents = Files.readAllBytes(f.toPath)
    val digest = MessageDigest.getInstance("SHA-256").digest(fileContents)
    Hex.encodeHexString(digest)

  def exerciseFileHasBeenModified(
      activeExerciseFolder: File,
      file: String,
      fileMetadata: Map[String, FileMetadata]): Boolean =
    fileSize(activeExerciseFolder / file) != fileMetadata(file).size || fileSha256Hex(
      activeExerciseFolder / file) != fileMetadata(file).sha256

  def getFilesToCopyAndDelete(
      currentExerciseId: String,
      toExerciseId: String,
      config: CMTcConfig): (Set[String], Set[String], Set[String]) =
    val currentReadmeFiles = config.readmeFilesMetaData(currentExerciseId).keys.to(Set)
    val nextReadmeFiles = config.readmeFilesMetaData(toExerciseId).keys.to(Set)
    val nextTestCodeFiles = config.testCodeMetaData(toExerciseId).keys.to(Set)

    val currentTestCodeFiles = config.testCodeMetaData(currentExerciseId).keys.to(Set)
    val readmefilesToBeDeleted = currentReadmeFiles &~ nextReadmeFiles
    val readmeFilesToBeCopied = nextReadmeFiles &~ readmefilesToBeDeleted
    val testCodeFilesToBeDeleted = currentTestCodeFiles &~ nextTestCodeFiles
    val testCodeFilesToBeCopied = nextTestCodeFiles &~ testCodeFilesToBeDeleted

    (
      currentTestCodeFiles,
      readmefilesToBeDeleted ++ testCodeFilesToBeDeleted,
      readmeFilesToBeCopied ++ testCodeFilesToBeCopied)

  def pullTestCode(
      toExerciseId: String,
      activeExerciseFolder: File,
      filesToBeDeleted: Set[String],
      filesToBeCopied: Set[String],
      config: CMTcConfig): Either[CmtError, String] =
    withZipFile(config.solutionsFolder, toExerciseId) { solution =>
      for {
        file <- filesToBeDeleted
      } deleteFileIfExists(activeExerciseFolder / file)
      for {
        file <- filesToBeCopied
      } sbtio.copyFile(solution / file, activeExerciseFolder / file)

      writeStudentifiedCMTBookmark(config.bookmarkFile, toExerciseId)

      Right(s"${toConsoleGreen("Moved to ")} " + "" + s"${toConsoleYellow(s"$toExerciseId")}")
    }
  def writeTestReadmeCodeMetadata(
      cleanedMainRepo: File,
      exercises: Vector[String],
      studentifiedRootFolder: File,
      cmtaConfig: CMTaConfig): Unit =

    val testCodeFolders = cmtaConfig.testCodeFolders.to(List)

    import scala.jdk.CollectionConverters.*

    sbtio.createDirectory(studentifiedRootFolder / cmtaConfig.cmtMetadataRootFolder)

    val testCodeFilesInExercises = (for {
      exercise <- exercises
      (srcTestCodeFiles, srcTestCodeFolders) =
        testCodeFolders
          .map(f => cleanedMainRepo / cmtaConfig.mainRepoExerciseFolder / exercise / f)
          .partition(f => f.isFile)
      allFiles =
        (srcTestCodeFiles ++ srcTestCodeFolders.flatMap(fileList))
          .map(f => (sbtio.relativizeFile(cleanedMainRepo / cmtaConfig.mainRepoExerciseFolder / exercise, f), f))
          .collect { case (Some(s), f) =>
            Map(
              s""""${adaptToNixSeparatorChar(s.getPath)}"""" -> Map(
                "size" -> fileSize(f),
                "sha256" -> fileSha256Hex(f)).asJava).asJava
          }

    } yield exercise -> allFiles.asJava).to(Map)

    val readmeFilesInExercises = (for {
      exercise <- exercises
      (srcReadmeFiles, srcReadmeFolders) =
        cmtaConfig.readMeFiles
          .map(f => cleanedMainRepo / cmtaConfig.mainRepoExerciseFolder / exercise / f)
          .partition(f => f.isFile)
      allFiles =
        (srcReadmeFiles ++ srcReadmeFolders.flatMap(fileList))
          .map(f => (sbtio.relativizeFile(cleanedMainRepo / cmtaConfig.mainRepoExerciseFolder / exercise, f), f))
          .collect { case (Some(s), f) =>
            Map(
              s""""${adaptToNixSeparatorChar(s.getPath)}"""" -> Map(
                "size" -> fileSize(f),
                "sha256" -> fileSha256Hex(f)).asJava).asJava
          }

    } yield exercise -> allFiles.asJava).to(Map)

    val cfgTestCode = ConfigFactory
      .parseMap(Map("testcode-metadata" -> testCodeFilesInExercises.asJava).asJava)
      .root()
      .render(ConfigRenderOptions.concise().setJson(false).setFormatted(true))
    val cfgReadmeFiles = ConfigFactory
      .parseMap(Map("readmefiles-metadata" -> readmeFilesInExercises.asJava).asJava)
      .root()
      .render(ConfigRenderOptions.concise().setJson(false).setFormatted(true))
    val metadataConfig = s"$cfgTestCode\n\n$cfgReadmeFiles"
    dumpStringToFile(metadataConfig, studentifiedRootFolder / cmtaConfig.testCodeSizeAndChecksums)
  end writeTestReadmeCodeMetadata

  def writeCodeMetadata(
      cleanedMainRepo: File,
      exercises: Vector[String],
      studentifiedRootFolder: File,
      cmtaConfig: CMTaConfig): Unit =

    import scala.jdk.CollectionConverters.*

    val codeFilesInExercises = (for {
      exercise <- exercises
      allFiles =
        fileList(cleanedMainRepo / cmtaConfig.mainRepoExerciseFolder / exercise)
          .map(f => (sbtio.relativizeFile(cleanedMainRepo / cmtaConfig.mainRepoExerciseFolder / exercise, f), f))
          .collect { case (Some(s), f) =>
            Map(
              s""""${adaptToNixSeparatorChar(s.getPath)}"""" -> Map(
                "size" -> fileSize(f),
                "sha256" -> fileSha256Hex(f)).asJava).asJava
          }

    } yield exercise -> allFiles.asJava).to(Map)

    val cfgCode = ConfigFactory
      .parseMap(Map("code-metadata" -> codeFilesInExercises.asJava).asJava)
      .root()
      .render(ConfigRenderOptions.concise().setJson(false).setFormatted(true))
    val metadataConfig = s"$cfgCode\n"
    dumpStringToFile(metadataConfig, studentifiedRootFolder / cmtaConfig.codeSizeAndChecksums)
  end writeCodeMetadata

  extension (f: File)
    // Gets the parent folder of this folder but return this
    // folder if it's a root folder
    def getParentOrSelf: File =
      val pf = f.getParentFile()
      if (pf == null) f else pf

  private val cmtSignature1 = ".cmt/.cmt-config"
  private val cmtSignature2 = ".cmt/.bookmark"

  private def isStudentifiedRepo(folder: File): Boolean =
    (folder / cmtSignature1).exists && (folder / cmtSignature2).exists

  /** @param Path
    *   to either the root of a studentified repo or any subfolder in such repo
    * @return
    *   The root folder of the studentified repo or an error message in case the passed-in fodler wasn't pointing to a
    *   studentified repo.
    */
  def findStudentRepoRoot(path: File): Either[CmtError, File] =
    lazy val error = FailedToValidateArgument.because("s", s"$path is not a CMT student project")
    @scala.annotation.tailrec
    def findStudentRepoRootRecurse(path: File): Option[File] =
      if (path.isDirectory() && isStudentifiedRepo(path)) Some(path)
      else
        val pf = path.getParentOrSelf
        if (path == pf)
          None
        else
          findStudentRepoRootRecurse(pf)

    if (path.isDirectory())
      findStudentRepoRootRecurse(path).toRight(error)
    else
      Left(error)

  def listExercises(config: CMTcConfig): String =
    val currentExerciseId = getCurrentExerciseId(config.bookmarkFile)

    config.exercises.zipWithIndex
      .map { case (exName, index) =>
        toConsoleGreen(f"${index + 1}%3d. ${starCurrentExercise(currentExerciseId, exName)}  $exName")
      }
      .mkString("\n")

end Helpers




© 2015 - 2024 Weber Informatics LLC | Privacy Policy