packages feed

swarm-0.7.0.0: src/swarm-engine/Swarm/Game/ScenarioInfo.hs

{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeFamilies #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}

-- |
-- SPDX-License-Identifier: BSD-3-Clause
--
-- Saving and loading info about scenarios (status, path, etc.) as
-- well as loading recursive scenario collections.
module Swarm.Game.ScenarioInfo (
  -- * Scenario info
  ScenarioStatus (..),
  _NotStarted,
  ScenarioInfo,
  scenarioPath,
  scenarioStatus,
  CodeSizeDeterminators (CodeSizeDeterminators),
  ScenarioWith,

  -- * Scenario collection
  ScenarioCollection (..),
  scenarioCollectionToList,
  flatten,
  scenarioItemByPath,
  normalizeScenarioPath,
  ScenarioItem (..),
  scenarioItemName,
  _SISingle,
  pathifyCollection,

  -- ** Tutorials
  tutorialsDirname,
  getTutorials,

  -- * Loading and saving scenarios
  loadScenarios,
  loadScenarioInfo,
  saveScenarioInfo,
) where

import Control.Algebra (Has)
import Control.Carrier.Lift (runM)
import Control.Carrier.Throw.Either (runThrow)
import Control.Effect.Accum (Accum, add)
import Control.Effect.Lift (Lift, sendIO)
import Control.Effect.Throw (Throw, liftEither)
import Control.Lens hiding (from, (<.>))
import Control.Monad (filterM, forM_, void, when, (<=<))
import Control.Monad.IO.Class (MonadIO (liftIO))
import Data.Char (isSpace)
import Data.Either (partitionEithers)
import Data.Either.Extra (fromRight')
import Data.List (intercalate, isPrefixOf, stripPrefix, (\\))
import Data.List.NonEmpty qualified as NE
import Data.Map (Map)
import Data.Map qualified as M
import Data.Map.Ordered (OMap)
import Data.Map.Ordered qualified as OM
import Data.Maybe (isJust)
import Data.Sequence (Seq)
import Data.Sequence qualified as Seq
import Data.Text (Text)
import Data.Yaml as Y
import Swarm.Failure
import Swarm.Game.Scenario
import Swarm.Game.Scenario.Scoring.CodeSize
import Swarm.Game.Scenario.Status
import Swarm.ResourceLoading (getDataDirSafe, getSwarmSavePath)
import Swarm.Util (lookupEither)
import Swarm.Util.Effect (warn, withThrow)
import System.Directory (canonicalizePath, doesDirectoryExist, doesFileExist, listDirectory)
import System.FilePath (pathSeparator, splitDirectories, takeBaseName, takeExtensions, (-<.>), (</>))
import System.IO (readFile')
import System.IO.Error (catchIOError)
import Witch (into)

------------------------------------------------------------

-- * Utilities

-- | Given an ordered list of keys and a map, return a partition consisting of:
-- * Left: the keys that were not present
-- * Right: the retrievable key-value pairs in corresponding order to the provided keys
lookupInOrder :: Ord k => Map k v -> [k] -> ([k], [(k, v)])
lookupInOrder m = partitionEithers . map produceKeyValuePair
 where
  produceKeyValuePair k = sequenceA (k, lookupEither k m)

-- ** Ordered Map utilities

type instance Index (OMap k a) = k
type instance IxValue (OMap k a) = a

-- | Adapted from:
-- https://hackage.haskell.org/package/lens-5.3.4/docs/src/Control.Lens.At.html#line-319
instance Ord k => Ixed (OMap k a) where
  ix k f m = case OM.lookup k m of
    Just v -> f v <&> \v' -> OM.alter (const $ Just v') k m
    Nothing -> pure m

-- | Strangely, an 'elems' function is missing from the 'OMap' API.
orderedElems :: OMap k a -> [a]
orderedElems = map snd . OM.assocs

fromMapOM :: Ord k => Map k a -> OMap k a
fromMapOM = OM.fromList . M.toList

-- ----------------------------------------------------------------------------
-- Scenario Item
-- ----------------------------------------------------------------------------

-- | A scenario item is either a specific scenario, or a collection of
--   scenarios (/e.g./ the scenarios contained in a subdirectory).
data ScenarioItem a = SISingle (ScenarioWith a) | SICollection Text (ScenarioCollection a)
  deriving (Functor)

-- | Retrieve the name of a scenario item.
scenarioItemName :: ScenarioItem a -> Text
scenarioItemName (SISingle (ScenarioWith s _ss)) = s ^. scenarioMetadata . scenarioName
scenarioItemName (SICollection name _) = name

-- | A scenario collection is a tree of scenarios, keyed by name,
--   together with an optional order.
--
--   /Invariant:/ every item in the
--   'scOrder' exists as a key in the 'scMap'.
newtype ScenarioCollection a = SC
  { scMap :: OMap FilePath (ScenarioItem a)
  }
  deriving (Functor)

pathifyCollection :: Functor f => f ScenarioInfo -> f ScenarioPath
pathifyCollection = fmap (ScenarioPath . view scenarioPath)

-- | Access and modify 'ScenarioItem's in collection based on their path.
scenarioItemByPath :: FilePath -> Traversal' (ScenarioCollection a) (ScenarioItem a)
scenarioItemByPath path = ixp ps
 where
  ps = splitDirectories path
  ixp :: (Applicative f) => [String] -> (ScenarioItem a -> f (ScenarioItem a)) -> ScenarioCollection a -> f (ScenarioCollection a)
  ixp [] _ col = pure col
  ixp [s] f (SC m) = SC <$> ix s f m
  ixp (d : xs) f (SC m) = SC <$> ix d inner m
   where
    inner si = case si of
      SISingle {} -> pure si
      SICollection n' col -> SICollection n' <$> ixp xs f col

-- | Subdirectory of the scenarios directory where tutorials are stored.
tutorialsDirname :: FilePath
tutorialsDirname = "Tutorials"

-- | Extract just the collection of tutorial scenarios from the entire
--   scenario collection.
getTutorials :: ScenarioCollection a -> ScenarioCollection a
getTutorials sc = case OM.lookup tutorialsDirname (scMap sc) of
  Just (SICollection _ c) -> c
  _ -> SC OM.empty

-- | Canonicalize a scenario path, making it usable as a unique key.
normalizeScenarioPath ::
  (MonadIO m) =>
  ScenarioCollection a ->
  FilePath ->
  m FilePath
normalizeScenarioPath col p =
  let path = p -<.> "yaml"
   in if isJust $ col ^? scenarioItemByPath path
        then return path
        else liftIO $ do
          canonPath <- canonicalizePath path
          eitherDataDir <- runM . runThrow @SystemFailure $ getDataDirSafe Scenarios "." -- no way we got this far without data directory
          d <- canonicalizePath $ fromRight' eitherDataDir
          let n =
                stripPrefix (d </> "scenarios") canonPath
                  & maybe canonPath (dropWhile (== pathSeparator))
          return n

-- | Convert a scenario collection to a list of scenario items.
scenarioCollectionToList :: ScenarioCollection a -> [ScenarioItem a]
scenarioCollectionToList (SC xs) = orderedElems xs

flatten :: ScenarioItem a -> [ScenarioWith a]
flatten (SISingle p) = [p]
flatten (SICollection _ c) = concatMap flatten $ scenarioCollectionToList c

-- | Load all the scenarios from the scenarios data directory.
loadScenarios ::
  (Has (Accum (Seq SystemFailure)) sig m, Has (Lift IO) sig m) =>
  ScenarioInputs ->
  Bool ->
  m (ScenarioCollection ScenarioInfo)
loadScenarios scenarioInputs loadTestScenarios = do
  res <- runThrow @SystemFailure $ getDataDirSafe Scenarios "scenarios"
  case res of
    Left err -> do
      warn err
      return $ SC OM.empty
    Right dataDir -> loadScenarioDir scenarioInputs loadTestScenarios dataDir

-- | The name of the special file which indicates the order of
--   scenarios in a folder.
orderFileName :: FilePath
orderFileName = "00-ORDER.txt"

testingDirectory :: FilePath
testingDirectory = "Testing"

readOrderFile :: FilePath -> IO (Maybe [String])
readOrderFile orderFile = fmap nonEmptyLines <$> readFileMaybe orderFile
 where
  nonEmptyLines :: String -> [String]
  nonEmptyLines = filter (not . null) . lines
  readFileMaybe :: FilePath -> IO (Maybe String)
  readFileMaybe path = (Just <$> readFile' path) `catchIOError` (\_ -> return Nothing)

loadScenarioDir ::
  forall m sig.
  (Has (Accum (Seq SystemFailure)) sig m, Has (Lift IO) sig m) =>
  ScenarioInputs ->
  Bool ->
  FilePath ->
  m (ScenarioCollection ScenarioInfo)
loadScenarioDir scenarioInputs loadTestScenarios dir = do
  itemPaths <- sendIO $ filterM (isYamlOrPublicDirectory dir) =<< listDirectory dir
  scenarioMap <- loadItems itemPaths
  sendIO (readOrderFile orderFile) >>= \case
    Nothing -> loadUnorderedScenarioDir scenarioMap
    Just order -> loadOrderedScenarioDir order scenarioMap
 where
  dirName, orderFile, orderFileShortPath :: FilePath
  dirName = takeBaseName dir
  orderFile = dir </> orderFileName
  orderFileShortPath = dirName </> orderFileName

  -- The function for individual directory items either warns about SystemFailure,
  -- or has thrown SystemFailure. The following code just adds that thrown failure to others.
  loadItems :: [FilePath] -> m (Map FilePath (ScenarioItem ScenarioInfo))
  loadItems items = do
    let loadItem f = runThrow @SystemFailure $ (f,) <$> loadScenarioItem scenarioInputs loadTestScenarios (dir </> f)
    (scenarioFailures, okScenarios) <- partitionEithers <$> mapM loadItem items
    add (Seq.fromList scenarioFailures)
    return $ M.fromList okScenarios

  isHiddenDir :: String -> Bool
  isHiddenDir f = not loadTestScenarios && f == testingDirectory

  -- Keep only files which are .yaml files or directories not starting with an underscore.
  -- Marked directories contain scenarios that can't be parsed (failure tests) or only script solutions.
  isYamlOrPublicDirectory :: FilePath -> String -> IO Bool
  isYamlOrPublicDirectory d f = do
    isDir <- doesDirectoryExist $ d </> f
    return $
      if isDir
        then not ("_" `isPrefixOf` f || isHiddenDir f)
        else takeExtensions f == ".yaml"

  -- warn that the ORDER file is missing
  loadUnorderedScenarioDir :: Map FilePath (ScenarioItem a) -> m (ScenarioCollection a)
  loadUnorderedScenarioDir scenarioMap = do
    when (dirName /= testingDirectory) (warn $ OrderFileWarning orderFileShortPath NoOrderFile)
    pure . SC $ fromMapOM scenarioMap

  -- warn if the ORDER file does not match directory contents
  loadOrderedScenarioDir :: [String] -> Map FilePath (ScenarioItem a) -> m (ScenarioCollection a)
  loadOrderedScenarioDir order scenarioMap = do
    let missing = M.keys scenarioMap \\ order
        (notPresent, loaded) = lookupInOrder scenarioMap order
        dangling = filter (not . isHiddenDir) notPresent

    forM_ (NE.nonEmpty missing) (warn . OrderFileWarning orderFileShortPath . MissingFiles)
    forM_ (NE.nonEmpty dangling) (warn . OrderFileWarning orderFileShortPath . DanglingFiles)

    pure $ SC $ OM.fromList loaded

-- | How to transform scenario path to save path.
scenarioPathToSavePath :: FilePath -> FilePath -> FilePath
scenarioPathToSavePath path swarmData = swarmData </> Data.List.intercalate "_" (splitDirectories path)

-- | Load saved info about played scenario from XDG data directory.
loadScenarioInfo ::
  (Has (Throw SystemFailure) sig m, Has (Lift IO) sig m) =>
  FilePath ->
  m ScenarioInfo
loadScenarioInfo p = do
  path <- sendIO $ normalizeScenarioPath (SC OM.empty) p
  infoPath <- sendIO $ scenarioPathToSavePath path <$> getSwarmSavePath False
  hasInfo <- sendIO $ doesFileExist infoPath
  if not hasInfo
    then do
      return $
        ScenarioInfo path NotStarted
    else do
      si <-
        withThrow (AssetNotLoaded (Data Scenarios) infoPath . CanNotParseYaml)
          . (liftEither <=< sendIO)
          $ decodeFileEither infoPath
      -- We overwrite the (void) path that was saved inside the yaml file, so that there
      -- is only a single authoritative path "key": the original scenario path.
      return $ path <$ (si :: ScenarioInfoT ())

-- | Save info about played scenario to XDG data directory.
saveScenarioInfo ::
  FilePath ->
  ScenarioInfo ->
  IO ()
saveScenarioInfo path si = do
  infoPath <- scenarioPathToSavePath path <$> getSwarmSavePath True
  -- We do not store the path in the save file (see #2390).
  encodeFile infoPath $ void si

-- | Load a scenario item (either a scenario, or a subdirectory
--   containing a collection of scenarios) from a particular path.
loadScenarioItem ::
  ( Has (Throw SystemFailure) sig m
  , Has (Accum (Seq SystemFailure)) sig m
  , Has (Lift IO) sig m
  ) =>
  ScenarioInputs ->
  Bool ->
  FilePath ->
  m (ScenarioItem ScenarioInfo)
loadScenarioItem scenarioInputs loadTestScenarios path = do
  isDir <- sendIO $ doesDirectoryExist path
  let collectionName = into @Text . dropWhile isSpace . takeBaseName $ path
  case isDir of
    True -> SICollection collectionName <$> loadScenarioDir scenarioInputs loadTestScenarios path
    False -> do
      s <- loadScenarioFile scenarioInputs path
      eitherSi <- runThrow @SystemFailure (loadScenarioInfo path)
      case eitherSi of
        Right si -> return $ SISingle $ ScenarioWith s si
        Left warning -> do
          warn warning
          return . SISingle . ScenarioWith s $ ScenarioInfo path NotStarted

------------------------------------------------------------
-- Some lenses + prisms
------------------------------------------------------------

makePrisms ''ScenarioItem
makePrisms ''ScenarioStatus