packages feed

sensei-0.8.0: src/Config.hs

{-# LANGUAGE CPP #-}
module Config (
  Config(..)
, Hook
, HookResult(..)
, defaultConfig
, loadConfig
#ifdef TEST
, ConfigFile(..)
, readConfigFilesFrom
#endif
) where

import Imports

import GHC.Generics
import Data.ByteString qualified as ByteString
import System.Directory
import System.Process
import Text.Casing
import Data.Aeson
import Data.Yaml

import Util

configFilename :: FilePath
configFilename = "sensei.yaml"

data ConfigFile = ConfigFile {
  -- | Shell command to run after a file has changed, but before reloading. Cancels the current
  -- trigger cycle when exiting with non-zero exit code.
  beforeReload :: Maybe String
  -- | Shell command to run after a successful reload, but before running the tests. Cancels the
  -- current trigger cycle when exiting with non-zero exit code.
, afterReload :: Maybe String
  -- | Shell command to run after the trigger cycle completed with failure. Has access to the result
  -- via seito.
, onFailure :: Maybe String
  -- | Shell command to run after the trigger cycle successfully completed. Has access to the result
  -- via seito.
, onSuccess :: Maybe String
} deriving (Generic, Show, Eq)

instance Semigroup ConfigFile where
  a <> b = ConfigFile {
    beforeReload = a.beforeReload <|> b.beforeReload
  , afterReload = a.afterReload <|> b.afterReload
  , onFailure = a.onFailure <|> b.onFailure
  , onSuccess = a.onSuccess <|> b.onSuccess
  }

instance Monoid ConfigFile where
  mempty = ConfigFile Nothing Nothing Nothing Nothing

instance FromJSON ConfigFile where
  parseJSON = \ case
    Null -> return mempty
    value -> genericParseJSON defaultOptions {
        fieldLabelModifier = kebab
      , rejectUnknownFields = True
      } value

type Hook = IO HookResult

data HookResult = HookSuccess | HookFailure String

data Config = Config {
  senseiHooksBeforeReload :: Hook
, senseiHooksAfterReload :: Hook
, senseiHooksOnSuccess :: Hook
, senseiHooksOnFailure :: Hook
}

tryReadFile :: FilePath -> IO (Maybe ByteString)
tryReadFile = fmap (either (const Nothing) Just) . tryJust (guard . isDoesNotExistError) . ByteString.readFile

readConfigFile :: FilePath -> IO (Either String ConfigFile)
readConfigFile path = tryReadFile path >>= \ case
  Just input -> return $ first displayException . decodeEither' $ input
  Nothing -> return $ Right mempty

readConfigFiles :: IO (Either String ConfigFile)
readConfigFiles = do
  configDir <- getXdgDirectory XdgConfig "sensei"
  readConfigFilesFrom (configDir </> configFilename) configFilename

readConfigFilesFrom :: FilePath -> FilePath -> IO (Either String ConfigFile)
readConfigFilesFrom global local = fmap mconcat . sequence <$> sequence [
    readConfigFile local
  , readConfigFile global
  ]

toConfig :: ConfigFile -> Config
toConfig ConfigFile{..} = Config {
  senseiHooksBeforeReload = maybeToHook "before-reload" beforeReload
, senseiHooksAfterReload = maybeToHook "after-reload" afterReload
, senseiHooksOnSuccess = maybeToHook "on-success" onSuccess
, senseiHooksOnFailure = maybeToHook "on-failure" onFailure
} where
    maybeToHook :: String -> Maybe String -> Hook
    maybeToHook name = maybe (return HookSuccess) (toHook name)

    toHook :: String -> String -> Hook
    toHook name command = system command >>= \ case
      ExitSuccess -> return HookSuccess
      ExitFailure code -> do
        let message = withColor Red $ name <> ": " <> command <> "  # failed with status " <> show code
        return $ HookFailure message

defaultConfig :: Config
defaultConfig = toConfig mempty

loadConfig :: IO Config
loadConfig = readConfigFiles >>= either (throwIO . ErrorCall) (return . toConfig)