packages feed

hurl-workbench-cli-0.1.0.0: src/HurlWorkbench/Cli/Command/Run.hs

module HurlWorkbench.Cli.Command.Run
  ( runExecute,
    runExecuteWith,
    buildExecutionInputs,
    buildRuntimeInputs,
  )
where

import Data.Bifunctor (first)
import Data.Generics.Labels ()
import Data.List (sort)
import Data.Map.Strict qualified as Map
import Data.Text qualified as Text
import HurlWorkbench.Cli.Error (cliErrors, cliFailure)
import HurlWorkbench.Cli.Options
import HurlWorkbench.Cli.Output (CommandResult (..))
import HurlWorkbench.Cli.Workspace (loadValidatedWorkspace)
import HurlWorkbench.Hurl.Capabilities
import HurlWorkbench.Hurl.Format
import HurlWorkbench.Hurl.Run
import HurlWorkbench.Parameter.Resolve
import HurlWorkbench.Prelude
import HurlWorkbench.Run.FileRoot
import HurlWorkbench.Run.Prepare
import HurlWorkbench.Run.Selection
import HurlWorkbench.Workflow.Render
import HurlWorkbench.Workspace.Context (validatedRoot)
import HurlWorkbench.Workspace.Types
import HurlWorkbench.Workspace.Validate (isValidEnvironmentName, isValidParameterName)

runExecute :: GlobalOptions -> FilePath -> HurlRunMode -> ExecuteOptions -> IO CommandResult
runExecute = runExecuteWith detectHurlCapabilities validateRenderedWorkflow

runExecuteWith ::
  IO (Either DependencyError HurlCapabilities) ->
  (HurlfmtCapabilities -> RenderedWorkflow -> IO (Either HurlfmtError ())) ->
  GlobalOptions ->
  FilePath ->
  HurlRunMode ->
  ExecuteOptions ->
  IO CommandResult
runExecuteWith detectCapabilities validateRendered global currentDirectory mode executeOptions =
  loadValidatedWorkspace global currentDirectory >>= \case
    Left errors -> pure (cliFailure 2 errors)
    Right validated -> case buildExecutionInputs executeOptions of
      Left errors -> pure (cliErrors 2 errors)
      Right (bindingInput, hurlOptions) ->
        let requested = executeOptions ^. #hurl . #fileRoot
            base = maybe (unWorkspaceRoot (validatedRoot validated)) (const currentDirectory) requested
         in validateFileRoot base requested >>= \case
              Left err -> pure (cliFailure 2 [renderFileRootError err])
              Right root ->
                checkFileRootOutputs root (maybe [] pure (hurlOptions ^. #curlExportPath)) >>= \case
                  Left err -> pure (cliFailure 2 [renderFileRootError err])
                  Right () ->
                    detectCapabilities >>= \case
                      Left err -> pure (cliFailure 3 [renderDependencyError err])
                      Right capabilities ->
                        prepareSelectionAtFileRootWith
                          root
                          validateRendered
                          (capabilities ^. #hurlfmt)
                          validated
                          bindingInput
                          hurlOptions
                          (executeOptions ^. #selection)
                          >>= \case
                            Left errors ->
                              pure
                                ( cliErrors
                                    (preparationExitCode errors)
                                    (map (("preflight: " <>) . renderPreparationError) (toList errors))
                                )
                            Right (prepared :| [])
                              | mutatingDenied (executeOptions ^. #allowMutating) (prepared ^. #safety) ->
                                  pure (cliFailure 2 ["mutating recipe requires --allow-mutating"])
                              | otherwise -> do
                                  executed <-
                                    runHurl
                                      (mkHurlRunner capabilities)
                                      RunRequest
                                        { renderedWorkflow = prepared ^. #renderedWorkflow,
                                          fileRoot = root,
                                          mode,
                                          bindings = prepared ^. #bindings,
                                          options = prepared ^. #options,
                                          outputPolicy = InheritRunOutput,
                                          reportTargets = []
                                        }
                                  pure $ case executed of
                                    Left err ->
                                      cliFailure
                                        (runStartExitCode err)
                                        [renderRunStartError err]
                                    Right result ->
                                      CommandResult
                                        { stdoutLines = [],
                                          stderrLines = hurlCapabilityWarnings capabilities,
                                          exitCode = result ^. #exitCode
                                        }
                            Right _ -> pure (cliFailure 2 ["run and test accept one workflow or recipe, not a matrix"])

buildExecutionInputs :: ExecuteOptions -> Either [Text] (BindingInput, HurlOptions)
buildExecutionInputs executeOptions = buildRuntimeInputs (executeOptions ^. #bindings) (executeOptions ^. #hurl)

buildRuntimeInputs :: BindingOptions -> CliHurlOptions -> Either [Text] (BindingInput, HurlOptions)
buildRuntimeInputs bindingOptions cli = do
  plain <- parseUnique "--variable" parsePlain (bindingOptions ^. #variables)
  secretEnvironments <- parseUnique "--secret-env" parseSecretEnvironment (bindingOptions ^. #secretEnvironments)
  additional <- collect (map (first renderCliHurlOptionError . mkAllowedHurlArgument) (cli ^. #additionalArguments))
  pure
    ( BindingInput
        { plainOverrides = plain,
          secretEnvironmentOverrides = secretEnvironments,
          variableFiles = bindingOptions ^. #variableFiles,
          secretFiles = bindingOptions ^. #secretFiles
        },
      HurlOptions
        { connectTimeoutSeconds = cli ^. #connectTimeoutSeconds,
          maxTimeSeconds = cli ^. #maxTimeSeconds,
          retryCount = cli ^. #retryCount,
          retryIntervalMilliseconds = cli ^. #retryIntervalMilliseconds,
          insecureTls = cli ^. #insecureTls,
          includeHeaders = cli ^. #includeHeaders,
          jsonOutput = cli ^. #jsonOutput,
          verbosity = cli ^. #verbosity,
          curlExportPath = cli ^. #curlExportPath,
          additionalArguments = additional
        }
    )

parsePlain :: Text -> Either Text (ParameterName, HurlValueLiteral)
parsePlain raw = do
  (name, value) <- splitAssignment "--variable" raw
  validateParameterName "--variable" name
  literal <- first (\err -> "--variable " <> name <> ": " <> renderHurlValueLiteralError err) (mkHurlValueLiteral value)
  pure (ParameterName name, literal)

parseSecretEnvironment :: Text -> Either Text (ParameterName, Text)
parseSecretEnvironment raw = do
  (name, environment) <- splitAssignment "--secret-env" raw
  validateParameterName "--secret-env" name
  unlessEither (isValidEnvironmentName environment) ("--secret-env " <> name <> " names invalid environment variable " <> quote environment)
  pure (ParameterName name, environment)

splitAssignment :: Text -> Text -> Either Text (Text, Text)
splitAssignment option raw = case Text.breakOn "=" raw of
  (name, rest)
    | Text.null rest || Text.null name -> Left (option <> " expects NAME=VALUE")
    | otherwise -> Right (name, Text.drop 1 rest)

validateParameterName :: Text -> Text -> Either Text ()
validateParameterName option name =
  unlessEither (isValidParameterName name) (option <> " has invalid parameter name " <> quote name)

parseUnique :: (Ord key) => Text -> (value -> Either Text (key, parsed)) -> [value] -> Either [Text] (Map key parsed)
parseUnique option parser values = do
  parsed <- collect (map parser values)
  let duplicateKeys = duplicates (map fst parsed)
  case duplicateKeys of
    [] -> Right (Map.fromList parsed)
    _ -> Left [option <> " defines the same parameter more than once"]

collect :: [Either Text value] -> Either [Text] [value]
collect values = case [err | Left err <- values] of
  [] -> Right [value | Right value <- values]
  errors -> Left errors

duplicates :: (Ord value) => [value] -> [value]
duplicates values = [value | value : _duplicate : _rest <- groupSorted (sort values)]
  where
    groupSorted [] = []
    groupSorted (value : remaining) =
      let (same, rest) = span (== value) remaining
       in (value : same) : groupSorted rest

unlessEither :: Bool -> err -> Either err ()
unlessEither condition err = if condition then Right () else Left err

hurlfmtExitCode :: HurlfmtError -> Int
hurlfmtExitCode = \case
  InvalidHurl {} -> 2
  HurlfmtNotFound {} -> 3
  HurlfmtFailed {} -> 3

preparationExitCode :: NonEmpty PreparationError -> Int
preparationExitCode errors =
  maximum (map one (toList errors))
  where
    one = \case
      RunFormatFailed _ err -> hurlfmtExitCode err
      _ -> 2

mutatingDenied :: Bool -> SafetyDisposition -> Bool
mutatingDenied allowed = \case
  Classified Mutating -> not allowed
  _ -> False

runStartExitCode :: RunStartError -> Int
runStartExitCode = \case
  InvalidFileRootLayout {} -> 2
  HurlSpawnFailed {} -> 3
  InvalidRunOptions {} -> 2
  SecureRunFileError {} -> 2

quote :: Text -> Text
quote value = "\"" <> value <> "\""

renderCliHurlOptionError :: HurlOptionError -> Text
renderCliHurlOptionError = \case
  UnsupportedHurlArgument rawArgument -> "unsupported --hurl-arg " <> rawArgument
  NegativeOption name value -> name <> " must not be negative, got " <> Text.pack (show value)
  DuplicateReportFormat format -> "duplicate " <> format <> " report target"