packages feed

seihou-cli-0.10.0.0: test/Seihou/CLI/RecordedReleaseSpec.hs

module Seihou.CLI.RecordedReleaseSpec
  ( tests,
    ReleaseHistory (..),
    publishHistory,
    gitHead,
  )
where

import Control.Lens ((^.))
import Control.Monad (forM_)
import Data.Generics.Labels ()
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import GHC.Generics (Generic)
import Seihou.CLI.RecordedRelease
import System.Directory (createDirectoryIfMissing, listDirectory, removePathForcibly)
import System.Environment (getEnvironment)
import System.FilePath ((</>))
import System.IO.Temp (withSystemTempDirectory)
import System.Process (CreateProcess (..), proc, readCreateProcess)
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)

tests :: IO TestTree
tests = testSpec "Seihou.CLI.RecordedRelease" spec

-- | One commit of a published history: every file it writes, relative to
-- the repository root, and its message.
data ReleaseHistory = ReleaseHistory
  { message :: !Text,
    files :: ![(FilePath, Text)]
  }
  deriving stock (Generic)

-- | Commit each step of a history, in order, into a fresh repository.
publishHistory :: FilePath -> [ReleaseHistory] -> IO ()
publishHistory repository history = do
  removePathForcibly repository
  createDirectoryIfMissing True repository
  runGit repository ["init", "-q"]
  forM_ history $ \step -> do
    forM_ (step ^. #files) $ \(path, content) -> do
      createDirectoryIfMissing True (repository </> takeDirectoryOf path)
      TIO.writeFile (repository </> path) content
    runGit repository ["add", "-A"]
    runGit repository ["-c", "user.name=Seihou Test", "-c", "user.email=test@example.com", "commit", "-qm", T.unpack (step ^. #message)]
  where
    takeDirectoryOf path = reverse (drop 1 (dropWhile (/= '/') (reverse path)))

-- | The full id of a repository's current commit.
gitHead :: FilePath -> IO Text
gitHead repository = T.strip . T.pack <$> readGit repository ["rev-parse", "HEAD"]

runGit :: FilePath -> [String] -> IO ()
runGit repository arguments = () <$ readGit repository arguments

readGit :: FilePath -> [String] -> IO String
readGit repository arguments = do
  inherited <- getEnvironment
  readCreateProcess ((proc "git" (["-C", repository] <> arguments)) {env = Just inherited}) ""

spec :: Spec
spec = do
  describe "chooseRevision" $ do
    it "takes the first revision, newest first, that declares the version" $
      chooseRevision "1.0.0" [("c3", Just "1.1.0"), ("c2", Just "1.0.0"), ("c1", Just "1.0.0")]
        `shouldBe` Just "c2"
    it "ignores revisions without the artifact or without a version" $
      chooseRevision "1.0.0" [("c3", Nothing), ("c2", Just "1.0.0")] `shouldBe` Just "c2"
    it "returns nothing when no revision declares the version" $
      chooseRevision "0.9.0" [("c2", Just "1.1.0"), ("c1", Just "1.0.0")] `shouldBe` Nothing

  describe "planRevisionSearch" $ do
    it "walks recent history when the literal never appears" $
      planRevisionSearch "tip" [] `shouldBe` WalkRecentHistory
    it "searches each hit, each hit's parent, and the tip, once each" $
      planRevisionSearch "tip" [("c3", Just "c2"), ("c1", Nothing), ("tip", Just "c3")]
        `shouldBe` SearchRevisions ["tip", "c3", "c2", "c1"]

  describe "locateRecordedRelease" $ do
    it "returns the newest commit declaring the version, not the one that introduced it" $
      withRegistryHistory $ \session repository -> do
        secondCommit <- readGit repository ["rev-parse", "HEAD~1"]
        located <- locateRecordedRelease session (T.pack repository) "beta" "module.dhall" "1.0.0"
        case located of
          Left err -> expectationFailure (T.unpack (renderRecordedReleaseError err))
          Right release -> do
            release ^. #revision `shouldBe` T.take 7 (T.pack secondCommit)
            TIO.readFile (release ^. #directory </> "files" </> "beta.tmpl") `shouldReturn` "beta second\n"

    it "returns the tip for the version that is still current" $
      withRegistryHistory $ \session repository -> do
        tip <- gitHead repository
        located <- locateRecordedRelease session (T.pack repository) "beta" "module.dhall" "1.1.0"
        fmap (^. #revision) located `shouldBe` Right (T.take 7 tip)

    it "reports a version no commit declares, naming the origin, the name, and the version" $
      withRegistryHistory $ \session repository -> do
        located <- locateRecordedRelease session (T.pack repository) "beta" "module.dhall" "0.9.0"
        case located of
          Left err@(RecordedReleaseNotFound url name version _) -> do
            (url, name, version) `shouldBe` (T.pack repository, "beta", "0.9.0")
            renderRecordedReleaseError err `shouldSatisfy` T.isInfixOf ("no commit of " <> T.pack repository <> " declares beta 0.9.0")
          other -> expectationFailure ("expected not found, got " <> show other)

    it "reports an artifact the repository never held as not found" $
      withRegistryHistory $ \session repository -> do
        located <- locateRecordedRelease session (T.pack repository) "gamma" "module.dhall" "1.0.0"
        case located of
          Left RecordedReleaseNotFound {} -> pure ()
          other -> expectationFailure ("expected not found, got " <> show other)

    it "reports an origin that cannot be cloned as an error, not an exception" $
      withSystemTempDirectory "seihou-recorded-release" $ \root -> do
        let missing = root </> "no-such-repository"
        located <- locateRecordedRelease (root </> "session") (T.pack missing) "beta" "module.dhall" "1.0.0"
        case located of
          Left (RecordedReleaseCloneFailed url _) -> url `shouldBe` T.pack missing
          other -> expectationFailure ("expected a clone failure, got " <> show other)

    it "finds a computed version by walking the history" $
      withSystemTempDirectory "seihou-recorded-release" $ \root -> do
        let repository = root </> "computed"
        publishHistory
          repository
          [ ReleaseHistory "computed 1.0.0" [("module.dhall", betaModule "\"1.0\" ++ \".0\""), ("files/beta.tmpl", "computed\n")],
            ReleaseHistory "1.1.0" [("module.dhall", betaModule "\"1.1.0\"")]
          ]
        first <- readGit repository ["rev-parse", "HEAD~1"]
        located <- locateRecordedRelease (root </> "session") (T.pack repository) "beta" "module.dhall" "1.0.0"
        fmap (^. #revision) located `shouldBe` Right (T.take 7 (T.pack first))

    it "reuses one clone for every lookup of the same origin in a session" $
      withRegistryHistory $ \session repository -> do
        _ <- locateRecordedRelease session (T.pack repository) "beta" "module.dhall" "1.0.0"
        _ <- locateRecordedRelease session (T.pack (repository <> "/")) "beta" "module.dhall" "1.1.0"
        clones <- filter (T.isPrefixOf "clone-" . T.pack) <$> listDirectory (session </> "recorded-releases")
        length clones `shouldBe` 1

    it "answers repeated lookups that visit the same revisions in one session" $
      withRegistryHistory $ \session repository -> do
        first <- locateRecordedRelease session (T.pack repository) "beta" "module.dhall" "0.9.0"
        again <- locateRecordedRelease session (T.pack repository) "beta" "module.dhall" "0.9.0"
        found <- locateRecordedRelease session (T.pack repository) "beta" "module.dhall" "1.0.0"
        case (first, again, found) of
          (Left RecordedReleaseNotFound {}, Left RecordedReleaseNotFound {}, Right _) -> pure ()
          other -> expectationFailure ("expected not found twice, then found; got " <> show other)

-- | A registry repository holding @beta@ at @modules/beta@: 1.0.0, then a
-- template change that keeps 1.0.0, then 1.1.0.
withRegistryHistory :: (FilePath -> FilePath -> IO a) -> IO a
withRegistryHistory action =
  withSystemTempDirectory "seihou-recorded-release" $ \root -> do
    let repository = root </> "registry"
    publishHistory
      repository
      [ ReleaseHistory
          "beta 1.0.0"
          [ ("seihou-registry.dhall", registry),
            ("modules/beta/module.dhall", betaModule "\"1.0.0\""),
            ("modules/beta/files/beta.tmpl", "beta first\n")
          ],
        ReleaseHistory "beta template" [("modules/beta/files/beta.tmpl", "beta second\n")],
        ReleaseHistory "beta 1.1.0" [("modules/beta/module.dhall", betaModule "\"1.1.0\"")]
      ]
    action (root </> "session") repository
  where
    registry =
      T.unlines
        [ "{ repoName = \"Test\"",
          ", repoDescription = None Text",
          ", modules = [ { name = \"beta\", version = None Text, path = \"modules/beta\", description = None Text, tags = [] : List Text } ]",
          "}"
        ]

-- | A module named @beta@ whose version is the given Dhall expression.
betaModule :: Text -> Text
betaModule versionExpression =
  T.unlines
    [ "{ name = \"beta\"",
      ", version = Some (" <> versionExpression <> ")",
      ", description = None Text",
      ", vars = [] : List { name : Text, type : Text, default : Optional Text, description : Optional Text, required : Bool, validation : Optional Text }",
      ", exports = [] : List { var : Text, alias : Optional Text }",
      ", prompts = [] : List { var : Text, text : Text, when : Optional Text, choices : Optional (List Text) }",
      ", steps = [{ strategy = \"template\", src = \"beta.tmpl\", dest = \"beta.txt\", when = None Text, patch = None Text }]",
      ", commands = [] : List { run : Text, workDir : Optional Text, when : Optional Text }",
      ", dependencies = [] : List Text",
      ", removal = None { steps : List { action : Text, dest : Text, src : Optional Text }, commands : List { run : Text, workDir : Optional Text, when : Optional Text } }",
      "}"
    ]