packages feed

porcupine-core-0.1.0.0: src/System/TaskPipeline/CLI.hs

{-# LANGUAGE ExistentialQuantification #-}
{-# LANGUAGE GADTs                     #-}
{-# LANGUAGE LambdaCase                #-}
{-# LANGUAGE OverloadedStrings         #-}
{-# LANGUAGE RankNTypes                #-}
{-# LANGUAGE TemplateHaskell           #-}
{-# LANGUAGE TupleSections             #-}
{-# OPTIONS_GHC -Wall          #-}

module System.TaskPipeline.CLI
  ( module System.TaskPipeline.ConfigurationReader
  , PipelineCommand(..)
  , PipelineConfigMethod(..)
  , BaseInputConfig(..)
  , PostParsingAction(..)
  , PreRun(..)
  , ConfigFileSource(..)
  , tryReadConfigFileSource
  , cliYamlParser
  , execCliParser
  , withCliParser
  , pipelineCliParser

  , pipelineConfigMethodProgName
  , pipelineConfigMethodDefRoot
  , pipelineConfigMethodConfigFile
  , pipelineConfigMethodDefReturnVal
  , withConfigFileSourceFromCLI
  ) where

import           Control.Lens                            hiding (argument)
import           Control.Monad.IO.Class
import           Data.Aeson                              as A
import qualified Data.Aeson.Encode.Pretty                as A
import qualified Data.ByteString                         as BS
import qualified Data.ByteString.Lazy                    as LBS
import           Data.Char                               (toLower)
import qualified Data.HashMap.Lazy                       as HashMap
import           Data.Locations
import           Data.Maybe
import           Data.Representable
import qualified Data.Text                               as T
import qualified Data.Text.Encoding                      as T
import qualified Data.Yaml                               as Y
import           Katip
import           Options.Applicative
import           System.Directory
import           System.Environment                      (getArgs, withArgs)
import           System.IO                               (stdin)
import           System.TaskPipeline.ConfigurationReader
import           System.TaskPipeline.Logger
import           System.TaskPipeline.PorcupineTree       (PhysicalFileNodeShowOpts(..))


-- | The command to parse from the CLI
data PipelineCommand
  = RunPipeline
  | ShowTree LocationTreePath PhysicalFileNodeShowOpts

-- | Tells whether and how command-line options should be used. @r@ is the type
-- of the result of the PTask that this PipelineConfigMethod will be use for
-- (see @runPipelineTask@). _pipelineConfigMethodDefReturnVal will be used as a
-- return value for the pipeline eg. when the user just wants to write the
-- config template, and not run the pipeline.
data PipelineConfigMethod r
  = NoConfig { _pipelineConfigMethodProgName :: String
             , _pipelineConfigMethodDefRoot  :: Loc }
    -- ^ No CLI and no config file are used, in which case we require a program
    -- name (for logs) and the root directory.
  | ConfigFileOnly { _pipelineConfigMethodProgName   :: String
                   , _pipelineConfigMethodConfigFile :: LocalFilePath
                   , _pipelineConfigMethodDefRoot    :: Loc }
    -- ^ Config file is read and loaded if it exists. No CLI is used.
  | FullConfig { _pipelineConfigMethodProgName     :: String
               , _pipelineConfigMethodConfigFile   :: LocalFilePath
               , _pipelineConfigMethodDefRoot      :: Loc
               , _pipelineConfigMethodDefReturnVal :: r }
    -- ^ Both config file and CLI will be used. We require a name (for the help
    -- page in the CLI), the config file path, the default 'LocationTree' root
    -- mapping, and a default value to return when the CLI is called with a
    -- command other than 'run' (in which case the pipeline will not run).

makeLenses ''PipelineConfigMethod

-- | Runs the new ModelCLI unless a Yaml or Json config file is given on the
-- command line
withCliParser
  :: String
  -> String
  -> Parser (Maybe (a, cmd), LoggerScribeParams, [PostParsingAction])
  -> r
  -> (a -> cmd -> LoggerScribeParams -> PreRun -> IO r)
  -> IO r
withCliParser progName progDesc_ cliParser defRetVal f = do
  (mbArgs, lsp, actions) <- execCliParser progName progDesc_ cliParser
  case mbArgs of
    Just (cfg, cmd) ->
      f cfg cmd lsp $ PreRun $ mapM_ processAction actions
    Nothing         -> runLogger progName lsp $ do
      mapM_ processAction actions
      return defRetVal
 where
   processAction (PostParsingLog s l) = logFM s l
   processAction (PostParsingWrite configFile cfg) = do
     let rawFile = configFile ^. pathWithExtensionAsRawFilePath
     case configFile of
       PathWithExtension "-" _ ->
         error "Config was read from stdin, cannot overwrite it"
       PathWithExtension _ e | e `elem` ["yaml","yml"] ->
         liftIO $ Y.encodeFile rawFile cfg
       PathWithExtension _ "json" ->
         liftIO $ LBS.writeFile rawFile $ A.encodePretty cfg
       _ -> error $ "Config file has unknown format"
     logFM NoticeS $ logStr $ "Wrote file '" ++ rawFile ++ "'"

data ConfigFileSource = YAMLStdin | ConfigFileURL Loc

-- | Tries to read a yaml filepath on CLI, then a JSON path, then command line
-- args as expected by the @callParser@ argument.
withConfigFileSourceFromCLI
  :: (Maybe ConfigFileSource -> IO b)  -- If a filepath has been read as first argument
  -> IO b
withConfigFileSourceFromCLI f = do
  cliArgs <- liftIO getArgs
  case cliArgs of
    [] -> f Nothing
    filename : rest -> do
      case parseURL filename of
        Left _ -> f Nothing
        Right (LocalFile (PathWithExtension "-" ext)) ->
          if ext == "" || allowedExt ext
          then withArgs rest $ f $ Just YAMLStdin
          else error $ filename ++ ": Only JSON or YAML config can be read from stdin"
        Right loc | getLocType loc == "" -> f Nothing
                        -- No extension, therefore probably not a filepath
                  | allowedExt (getLocType loc) -> withArgs rest $ f $ Just $ ConfigFileURL loc
                  | otherwise -> error $ filename ++ ": Only JSON or YAML cofiles allowed"
  where
    allowedExt = (`elem` ["yaml","yml","json"]) . map toLower

tryReadConfigFileSource :: (FromJSON cfg)
                        => ConfigFileSource -> (Loc -> IO cfg) -> IO (Maybe cfg)
tryReadConfigFileSource configFileSource ifRemote =
  case configFileSource of
    YAMLStdin ->
      Just <$> (BS.hGetContents stdin >>= Y.decodeThrow)
    ConfigFileURL (LocalFile lfp) -> do
      let p = lfp ^. pathWithExtensionAsRawFilePath
      yamlFound <- doesFileExist p
      if yamlFound
        then Just <$> Y.decodeFileThrow p
        else return Nothing
    ConfigFileURL remoteLoc -> -- If config is remote, it must always be present
                               -- for now
      Just <$> ifRemote remoteLoc

data BaseInputConfig cfg = BaseInputConfig
  { bicSourceFile    :: Maybe LocalFilePath
  , bicLoadedConfig  :: Maybe Y.Value
  , bicDefaultConfig :: cfg
  }

-- | Creates a command line parser that will return an action returning the
-- configuration and the chosen subcommand or Nothing if the user simply asked
-- to save some overrides and that the program should stop. It _does not_ mean
-- that an error has occured, just that the program should not continue.
cliYamlParser
  :: (ToJSON cfg)
  => String                   -- ^ The program name
  -> BaseInputConfig cfg      -- ^ Default configuration
  -> ConfigurationReader cfg overrides
  -> [(Parser cmd, String, String)]  -- ^ [(Parser cmd, Command repr, Command help string)]
  -> cmd                      -- ^ Default command
  -> IO (Parser (Maybe (cfg, cmd), LoggerScribeParams, [PostParsingAction]))
cliYamlParser progName baseInputCfg inputParsing cmds defCmd = do
  return $ pureCliParser progName baseInputCfg inputParsing cmds defCmd

-- | A shortcut to run a parser and defining the program help strings
execCliParser
  :: String
  -> String
  -> Parser a
  -> IO a
execCliParser header_ progDesc_ parser_ = do
  let opts = info (helper <*> parser_)
        ( fullDesc
          <> header header_
          <> progDesc progDesc_ )
  execParser opts

pureCliParser
  :: (ToJSON cfg)
  => String                   -- ^ The program name
  -> BaseInputConfig cfg      -- ^ The base configuration we read
  -> ConfigurationReader cfg overrides
  -> [(Parser cmd, String, String)]  -- ^ [(Parser cmd, Command repr, Command help string)]
  -> cmd                      -- ^ Default command
  -> Parser (Maybe (cfg, cmd), LoggerScribeParams, [PostParsingAction])
                              -- ^ (Config and command, actions to run to
                              -- override the yaml file)
pureCliParser progName baseInputConfig cfgCLIParsing cmds defCmd =
  (case bicSourceFile baseInputConfig of
     Nothing -> empty
     Just f  -> subparser $
       command "write-config-template"
        (info
         (pure (Nothing, maxVerbosityLoggerScribeParams
               ,[PostParsingWrite f (bicDefaultConfig baseInputConfig)]))
         (progDesc $ "Write a default configuration file in " <> (f^.pathWithExtensionAsRawFilePath))))
  <|>
  handleOptions progName baseInputConfig cliOverriding
    <$> ((subparser $
           (case bicSourceFile baseInputConfig of
              Nothing -> mempty
              Just f ->
                command "save" $
                  info (pure Nothing) $
                       progDesc $ "Just save the command line overrides in " <> (f^.pathWithExtensionAsRawFilePath))
           <>
           foldMap
             (\(cmdParser, cmdShown, cmdInfo) ->
                 command cmdShown $
                   info (Just . (,cmdShown) <$> cmdParser) $
                        progDesc cmdInfo)
             cmds)
         <|>
         pure (Just (defCmd, "")))
    <*> (case bicSourceFile baseInputConfig of
           Nothing -> pure False
           Just f  ->
             switch ( long "save"
                   <> short 's'
                   <> help ("Save overrides in the " <> (f^.pathWithExtensionAsRawFilePath) <> " before running.") ))
    <*> overridesParser cliOverriding
  where
    cliOverriding = addScribeParamsParsing cfgCLIParsing

severityShortcuts :: Parser Severity
severityShortcuts =
  numToSeverity <$> liftA2 (-)
    (length <$>
      (many
        (flag' ()
          (  short 'q'
          <> long "quiet"
          <> help "Print only warning (-q) or error (-qq) messages. Cancels out with -v."))))
    (length <$>
      (many
        (flag' ()
          (  long "verbose"
          <> short 'v'
          <> help "Print info (-v) and debug (-vv) messages. Cancels out with -q."))))
  where
    numToSeverity (-1) = InfoS
    numToSeverity 0 = NoticeS
    numToSeverity 1 = WarningS
    numToSeverity 2 = ErrorS
    numToSeverity 3 = CriticalS
    numToSeverity 4 = AlertS
    numToSeverity x | x>0 = EmergencyS
                    | otherwise = DebugS

-- | Parses the CLI options that will be given to Katip's logger scribe
parseScribeParams :: Parser LoggerScribeParams
parseScribeParams = LoggerScribeParams
  <$> ((option (eitherReader severityParser)
          (  long "severity"
          <> help "Control exactly which minimal severity level will be logged (used instead of -q or -v)"))
       <|>
       severityShortcuts)
  <*> (numToVerbosity <$>
       option auto
         (  long "context-verb"
         <> short 'c'
         <> help "A number from 0 to 3 (default: 0). Controls the amount of context to show per log line"
         <> value (0 :: Int)))
  <*> (option (eitherReader loggerFormatParser)
        (  long "log-format"
        <> help "Selects a format for the log: 'pretty' (default, only for human consumption), 'compact' (pretty but more compact), 'json' or 'bracket'"
        <> value PrettyLog))
  where
    severityParser = \case
        "debug" -> Right DebugS
        "info" -> Right InfoS
        "notice" -> Right NoticeS
        "warning" -> Right WarningS
        "error" -> Right ErrorS
        "critical" -> Right CriticalS
        "alert" -> Right AlertS
        "emergency" -> Right EmergencyS
        s -> Left $ s ++ " isn't a valid severity level"
    numToVerbosity 0 = V0
    numToVerbosity 1 = V1
    numToVerbosity 2 = V2
    numToVerbosity _ = V3
    loggerFormatParser "pretty"  = Right PrettyLog
    loggerFormatParser "compact" = Right CompactLog
    loggerFormatParser "json"    = Right JSONLog
    loggerFormatParser "bracket" = Right BracketLog
    loggerFormatParser s         = Left $ s ++ " isn't a valid log format"

-- | Modifies a CLI parsing so it features verbosity and severity flags
addScribeParamsParsing :: ConfigurationReader cfg ovs -> ConfigurationReader (LoggerScribeParams, cfg) (LoggerScribeParams, ovs)
addScribeParamsParsing super = ConfigurationReader
  { overridesParser = (,) <$> parseScribeParams <*> overridesParser super
  , nullOverrides = \(_, ovs) -> nullOverrides super ovs
  , overrideCfgFromYamlFile = \yaml (scribeParams, ovs) ->
      let (warns, res) = overrideCfgFromYamlFile super yaml ovs
      in (warns, (scribeParams,) <$> res)
  }

-- | Some action to be carried out after the parser is done. Writing the config
-- file is done here, as is the logging of config.
data PostParsingAction
  = PostParsingLog Severity LogStr  -- ^ Log a message
  | forall a. (ToJSON a) => PostParsingWrite LocalFilePath a
          -- ^ Write to a file and log a message about it

-- | Wraps the actions to override the config file
newtype PreRun = PreRun {unPreRun :: forall m. (KatipContext m, MonadIO m) => m ()}

handleOptions
  :: forall cfg cmd overrides.
     (ToJSON cfg)
  => String -- ^ Program name
  -> BaseInputConfig cfg
  -> ConfigurationReader (LoggerScribeParams, cfg) (LoggerScribeParams, overrides)
  -> Maybe (cmd, String) -- ^ Command to run (and a name/description for it). If
                         -- Nothing, means we should just save the config
  -> Bool -- ^ Whether to save the overrides
  -> (LoggerScribeParams, overrides) -- ^ overrides
  -> (Maybe (cfg, cmd), LoggerScribeParams, [PostParsingAction])
                          -- ^ (Config and command, actions to run to override
                          -- the yaml file)
handleOptions progName (BaseInputConfig _ Nothing _) _ Nothing _ _ = error $
  "No config found and nothing to save. Please run `" ++ progName ++ " write-config-template' first."
handleOptions progName (BaseInputConfig mbCfgFile mbCfg defCfg) cliOverriding mbCmd saveOverridesAlong overrides =
  let defaultCfg = toJSON defCfg
      (cfgWarnings, cfg) = case mbCfg of
        Just c -> mergeWithDefault [] defaultCfg c
        Nothing -> ([PostParsingLog DebugS $ logStr $
                      "Config file" ++ configFile' ++ " is not found. Treated as empty."]
                   ,defaultCfg)
      (overrideWarnings, mbScribeParamsAndCfgOverriden) =
        overrideCfgFromYamlFile cliOverriding cfg overrides
      allWarnings = cfgWarnings ++ map (PostParsingLog WarningS . logStr) overrideWarnings
  in case mbScribeParamsAndCfgOverriden of
    Right (lsp, cfgOverriden) ->
      case mbCmd of
        Nothing -> (Nothing, lsp, allWarnings ++
                                  [PostParsingWrite (fromJust mbCfgFile) cfgOverriden])
        Just (cmd, _cmdShown) ->
            let actions =
                  allWarnings ++
                  (if saveOverridesAlong
                     then [PostParsingWrite (fromJust mbCfgFile) cfgOverriden]
                     else []) ++
                  [PostParsingLog DebugS $ logStr $ "Running `" <> T.pack progName
                      <> "' with the following config:\n"
                      <> T.decodeUtf8 (Y.encode cfgOverriden)]
            in (Just (cfgOverriden, cmd), lsp, actions)
    Left err -> dispErr err
  where
    configFile' = case mbCfgFile of Nothing -> ""
                                    Just f -> " " ++ f ^. pathWithExtensionAsRawFilePath
    dispErr err = error $
      (if nullOverrides cliOverriding overrides
       then "C"
       else "Overriden c") ++ "onfig" <> shownFile <> " is not valid:\n  " ++ err
      where
        shownFile = case mbCfgFile of
          Just f  -> " from " <> show f
          Nothing -> ""

mergeWithDefault :: [T.Text] -> Y.Value -> Y.Value -> ([PostParsingAction], Y.Value)
mergeWithDefault path (Object o1) (Object o2) =
  let newKeys = HashMap.keys $ o2 `HashMap.difference` o1
      warnings = map (\key -> PostParsingLog DebugS $ logStr $
                       "The key '" <> T.intercalate "." (reverse $ key:path) <>
                       "' isn't present in the default configuration. " <>
                       "Please make sure it isn't a typo.")
                     newKeys
      (subWarnings, merged) =
        sequenceA $ HashMap.unionWithKey
                      (\key (_,v1) (_,v2) -> mergeWithDefault (key:path) v1 v2)
                      (fmap pure o1)
                      (fmap pure o2)
  in (warnings ++ subWarnings, Object merged)
mergeWithDefault _ _ v = pure v

parseShowTree :: Parser PipelineCommand
parseShowTree = ShowTree <$> parseRoot <*> parseShowOpts
  where
    parseRoot = argument (eitherReader (fromTextRepr . T.pack))
                (help "Path from which to display the porcupine tree" <> value (LTP []))
    parseShowOpts = PhysicalFileNodeShowOpts
      <$> flag False True
          (long "mappings"
           <> short 'm'
           <> help "Show mappings of virtual files")
      <*> flag True False
          (long "no-serials"
           <> short 'S'
           <> help "Don't show if the virtual file can be used as a source or a sink")
      <*> flag True False
          (long "no-fields"
           <> short 'F'
           <> help "Don't show the option fields and their docstrings")
      <*> flag False True
          (long "types"
           <> short 't'
           <> help "Show types written to virtual files")
      <*> flag False True
          (long "accesses"
           <> short 'a'
           <> help "Show how virtual files will be accessed")
      <*> flag True False
          (long "no-extensions"
           <> short 'E'
           <> help "Don't show the possible extensions for physical files")
      <*> option auto
          (long "num-chars"
           <> short 'c'
           <> help "The number of characters to show for the type (default: 60)"
           <> value (60 :: Int))

pipelineCliParser
  :: (ToJSON cfg)
  => (cfg -> ConfigurationReader cfg overrides)
  -> String
  -> BaseInputConfig cfg
  -> IO (Parser (Maybe (cfg, PipelineCommand), LoggerScribeParams, [PostParsingAction]))
pipelineCliParser getCliOverriding progName baseInputConfig =
  cliYamlParser progName baseInputConfig (getCliOverriding $ bicDefaultConfig baseInputConfig)
  [(pure RunPipeline, "run", "Run the pipeline")
  ,(parseShowTree, "show-tree", "Show the porcupine tree of the pipeline")]
  RunPipeline