packages feed

hurl-workbench-core-0.1.0.0: src/HurlWorkbench/Suite/Resolve.hs

-- | Complete suite preflight. Every run, safety decision, report target, and
--   optional managed service is resolved before execution can start.
module HurlWorkbench.Suite.Resolve
  ( ReportFormat (..),
    SuiteOptions (..),
    SuiteRequest (..),
    SuitePreflightIssue (..),
    SuitePreflightError (..),
    PreparedSuite (..),
    PreparedStep (..),
    SuiteMetadata (..),
    ScenarioMetadata (..),
    suiteMetadata,
    reserveSuiteSummary,
    suitePreflightReasonCode,
    suitePreflightSetupFailure,
    preflightFileRoot,
    preflightScenarioInputs,
    prepareSuite,
    prepareSuiteWith,
    renderSuitePreflightIssue,
    renderSuitePreflightError,
  )
where

import Control.Exception (IOException, displayException, try)
import Data.Bits ((.&.))
import Data.Generics.Labels ()
import Data.List.NonEmpty qualified as NonEmpty
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text qualified as Text
import Data.Time.Clock (NominalDiffTime)
import HurlWorkbench.Check.Resolve
import HurlWorkbench.Hurl.Format (HurlfmtCapabilities, HurlfmtError, validateRenderedWorkflow)
import HurlWorkbench.Hurl.Run
import HurlWorkbench.Parameter.Resolve
import HurlWorkbench.Prelude
import HurlWorkbench.Run.Batch
import HurlWorkbench.Run.FileRoot
import HurlWorkbench.Run.Prepare
import HurlWorkbench.Run.Selection
import HurlWorkbench.Service.Resolve
import HurlWorkbench.Service.Run (freezeService, prepareCommand)
import HurlWorkbench.Suite.Isolation
import HurlWorkbench.Suite.Scenario
import HurlWorkbench.Workflow.Render (RenderedWorkflow)
import HurlWorkbench.Workspace.Context
import HurlWorkbench.Workspace.Types
import System.Directory
  ( canonicalizePath,
    createDirectory,
    createDirectoryIfMissing,
    doesPathExist,
    removePathForcibly,
  )
import System.Environment (getEnvironment)
import System.FilePath (normalise, splitDirectories, (</>))
import System.IO.Error (isDoesNotExistError)
import System.Info (os)
import System.Posix.Files
  ( accessModes,
    fileMode,
    getFileStatus,
    getSymbolicLinkStatus,
    isDirectory,
    isSymbolicLink,
    ownerExecuteMode,
    ownerReadMode,
    ownerWriteMode,
    setFileMode,
    unionFileModes,
  )
import System.Posix.Types (FileMode)

data ReportFormat = JUnit | Html | Json | Tap
  deriving stock (Generic, Eq, Ord, Show)

data SuiteOptions = SuiteOptions
  { jobs :: !PositiveInt,
    failFastOverride :: !(Maybe Bool),
    allowMutating :: !Bool,
    manageService :: !Bool,
    reportFormats :: !(Set ReportFormat),
    reportDirectory :: !(Maybe FilePath),
    overwriteReports :: !Bool
  }
  deriving stock (Generic, Eq, Show)

data SuiteRequest = SuiteRequest
  { suiteName :: !SuiteName,
    fileRoot :: !(Maybe FileRoot),
    scenarioInputs :: !(Maybe ScenarioInputs),
    bindingInput :: !BindingInput,
    hurlOptions :: !HurlOptions,
    suiteOptions :: !SuiteOptions
  }
  deriving stock (Generic, Eq, Show)

data SuitePreflightIssue
  = SuiteFileRootFailed !FileRootError
  | SuiteNotFound !SuiteName
  | SuiteRunPreparationFailed !PreparationError
  | SuiteDependencyUnavailable
  | SuiteScenarioFailed !ScenarioIssue
  | SuiteScenarioInputsUnexpected
  | SuiteIndependentExternalRequired
  | SuiteIsolationFailed !IsolationIssue
  | SuiteUnsafeRun !Text !SafetyDisposition
  | SuiteDuplicateArtifactStem !FilePath
  | SuiteSharedCurlExport !FilePath
  | SuiteReportDirectoryRequired
  | SuiteInvalidReportDirectory !FilePath
  | SuiteReportPathExists !FilePath
  | SuiteReportPathIsSymbolicLink !FilePath
  | SuiteReportPathIsNotDirectory !FilePath
  | SuiteReportIoError !FilePath !Text
  | SuiteExternalLifecycle
  | SuiteCheckFailed !CheckName !CheckResolutionError
  | SuiteServiceNotFound !ServiceName
  | SuiteServiceBindingFailed !BindingError
  | SuiteServiceResolutionFailed !ServiceError
  deriving stock (Generic, Eq, Show)

newtype SuitePreflightError = SuitePreflightError
  { issues :: NonEmpty SuitePreflightIssue
  }
  deriving stock (Generic, Eq, Show)

-- | Safe report data has no executable requests or provider/runtime values.
data ScenarioMetadata = ScenarioMetadata
  { name :: !Text,
    scenario :: !FilePath,
    reports :: ![FilePath]
  }
  deriving stock (Generic, Eq, Show)

data SuiteMetadata = SuiteMetadata
  { name :: !SuiteName,
    isolation :: !IsolationPolicy,
    scenarios :: ![ScenarioMetadata],
    summaryPath :: !(Maybe FilePath)
  }
  deriving stock (Generic, Eq, Show)

data PreparedStep = HurlBatch !Int !(NonEmpty BatchCase) | ServiceBarrier !ServiceStep | CheckBarrier !ResolvedCheck
  deriving stock (Generic, Show)

data PreparedSuite = PreparedSuite
  { metadata :: !SuiteMetadata,
    cases :: ![BatchCase],
    batchOptions :: !BatchOptions,
    managedServices :: ![ResolvedService],
    steps :: ![PreparedStep],
    setupCommands :: ![ResolvedCommand],
    teardownCommands :: ![ResolvedCommand],
    commandTimeout :: !NominalDiffTime,
    cleanupTimeout :: !NominalDiffTime
  }
  deriving stock (Generic, Show)

suiteMetadata :: ValidatedWorkspace -> SuiteRequest -> Either SuitePreflightError SuiteMetadata
suiteMetadata workspace request = do
  suite <- maybe (Left (SuitePreflightError (SuiteNotFound (request ^. #suiteName) :| []))) Right (lookupSuite (request ^. #suiteName) workspace)
  expanded <- either (Left . SuitePreflightError . (:| []) . SuiteRunPreparationFailed . SelectionPreparationFailed) Right (traverse (resolveSelection workspace . runSelection) (hurlReferences suite))
  let runs = concatMap toList expanded
  pure
    SuiteMetadata
      { name = suite ^. #name,
        isolation = suite ^. #isolation,
        scenarios = fmap (\run -> ScenarioMetadata (run ^. #displayName) (run ^. #artifactStem) (map reportPath (suiteReportTargets (request ^. #suiteOptions . #reportFormats) (run ^. #artifactStem)))) runs,
        summaryPath = fmap (\root -> root </> Text.unpack (unSuiteName (suite ^. #name)) </> "summary.json") (request ^. #suiteOptions . #reportDirectory)
      }
  where
    reportPath = \case
      JUnitReport path -> path
      HtmlReport path -> path
      JsonReport path -> path
      TapReport path -> path

-- | A refusal reserves only the suite directory, never fictitious Hurl reports.
reserveSuiteSummary :: ValidatedWorkspace -> SuiteRequest -> SuiteMetadata -> IO (Either SuitePreflightIssue ())
reserveSuiteSummary workspace request metadata =
  preflightFileRoot workspace request >>= \case
    Left err -> pure (Left (NonEmpty.head (err ^. #issues)))
    Right _ -> case request ^. #suiteOptions . #reportDirectory of
      Nothing -> pure (Right ())
      Just root -> prepareReportLayout root (root </> Text.unpack (unSuiteName (metadata ^. #name))) [] (request ^. #suiteOptions . #overwriteReports)

prepareSuite :: HurlfmtCapabilities -> ValidatedWorkspace -> SuiteRequest -> IO (Either SuitePreflightError PreparedSuite)
prepareSuite = prepareSuiteWith validateRenderedWorkflow

prepareSuiteWith :: (HurlfmtCapabilities -> RenderedWorkflow -> IO (Either HurlfmtError ())) -> HurlfmtCapabilities -> ValidatedWorkspace -> SuiteRequest -> IO (Either SuitePreflightError PreparedSuite)
prepareSuiteWith validateRendered capabilities validated request =
  preflightFileRoot validated request >>= \case
    Left err -> pure (Left err)
    Right root -> do
      preflight <- preflightScenarioInputs validated request
      case preflight of
        Left err -> pure (Left err)
        Right verified -> prepareVerifiedSuiteWith root verified validateRendered capabilities validated request

prepareVerifiedSuiteWith :: FileRoot -> Maybe VerifiedScenarioInputs -> (HurlfmtCapabilities -> RenderedWorkflow -> IO (Either HurlfmtError ())) -> HurlfmtCapabilities -> ValidatedWorkspace -> SuiteRequest -> IO (Either SuitePreflightError PreparedSuite)
prepareVerifiedSuiteWith root verified validateRendered capabilities validated request =
  case lookupSuite (request ^. #suiteName) validated of
    Nothing -> pure (Left (SuitePreflightError (SuiteNotFound (request ^. #suiteName) :| [])))
    Just _
      | Just problems <- nonEmpty (checkSuiteIsolationWith verified validated (request ^. #suiteName) (request ^. #suiteOptions . #jobs)) ->
          pure (Left (SuitePreflightError (fmap SuiteIsolationFailed problems)))
    Just suite -> do
      ambient <- getEnvironment
      selections <- case verified of
        Nothing -> traverse prepareReference (hurlReferences suite)
        Just inputs -> case traverse (resolveSelection validated . runSelection) (hurlReferences suite) of
          Left err -> pure [Left (SelectionPreparationFailed err :| [])]
          Right expanded -> case nonEmpty (concatMap toList expanded) of
            Nothing -> pure []
            Just runs -> case traverse (\run -> (run,) <$> scenarioBindingInput inputs run) runs of
              Nothing -> error "verified scenario assignment incomplete"
              Just assigned -> pure <$> prepareExpandedForSuiteAtFileRootWith root validateRendered capabilities validated (request ^. #hurlOptions) assigned
      serviceResult <- prepareManagedServices ambient suite
      checkResults <- traverse (\name -> fmap (either (Left . SuiteCheckFailed name) Right) (prepareCheck ambient validated (request ^. #bindingInput) name)) [name | CheckRun name <- suite ^. #runs]
      let preparationIssues =
            concat
              [ map preparationIssue (NonEmpty.toList errors)
              | Left errors <- selections
              ]
          preparedRuns = concat [NonEmpty.toList runs | Right runs <- selections]
          safetyIssues =
            [ SuiteUnsafeRun (prepared ^. #displayName) (prepared ^. #safety)
            | prepared <- preparedRuns,
              not (request ^. #suiteOptions . #allowMutating),
              requiresAuthorization (prepared ^. #safety)
            ]
          checkNames = [name | CheckRun name <- suite ^. #runs]
          duplicateIssues =
            map SuiteDuplicateArtifactStem (duplicates (map (view #artifactStem) preparedRuns))
              <> [SuiteDuplicateArtifactStem "phases" | not (null (suite ^. #setup) && null (suite ^. #teardown)), any (\run -> take 1 (splitDirectories (run ^. #artifactStem)) == ["phases"]) preparedRuns]
              <> map (SuiteDuplicateArtifactStem . ("checks" </>) . Text.unpack . unCheckName) (duplicates checkNames)
              <> [SuiteDuplicateArtifactStem "checks" | not (null checkNames), any (\run -> take 1 (splitDirectories (run ^. #artifactStem)) == ["checks"]) preparedRuns]
          curlIssues =
            [ SuiteSharedCurlExport path
            | length preparedRuns > 1,
              Just path <- [request ^. #hurlOptions . #curlExportPath]
            ]
          reportIssues =
            [SuiteReportDirectoryRequired | (not (null checkNames) || not (Set.null (request ^. #suiteOptions . #reportFormats))) && isNothing (request ^. #suiteOptions . #reportDirectory)]
          serviceIssues = either pure (const []) serviceResult
          issues = preparationIssues <> safetyIssues <> duplicateIssues <> curlIssues <> reportIssues <> serviceIssues <> [err | Left err <- checkResults]
      case nonEmpty issues of
        Just problems -> pure (Left (SuitePreflightError problems))
        Nothing -> case (preparedRuns, serviceResult) of
          (runs, Right (services, setup, teardown)) ->
            prepareReports suite runs >>= \case
              Left problem -> pure (Left (SuitePreflightError (problem :| [])))
              Right (batchCases, _summary) ->
                pure
                  ( Right
                      PreparedSuite
                        { metadata = either (error "validated suite metadata failed") id (suiteMetadata validated request),
                          cases = batchCases,
                          batchOptions =
                            BatchOptions
                              (request ^. #suiteOptions . #jobs)
                              (fromMaybe (suite ^. #failFast) (request ^. #suiteOptions . #failFastOverride)),
                          managedServices = services,
                          steps = orderedSteps validated suite batchCases [check | Right check <- checkResults],
                          setupCommands = setup,
                          teardownCommands = teardown,
                          commandTimeout = fromIntegral (suite ^. #commandTimeoutSeconds),
                          cleanupTimeout = fromIntegral (suite ^. #cleanupTimeoutSeconds)
                        }
                  )
          _ -> error "prepareSuite: validated suite produced no prepared runs"
  where
    preparationIssue RunBindingFailed {} | isJust verified = SuiteScenarioFailed ScenarioRuntimeInputFailed
    preparationIssue err = SuiteRunPreparationFailed err

    prepareReference reference =
      prepareSelectionForSuiteAtFileRootWith
        root
        validateRendered
        capabilities
        validated
        (request ^. #bindingInput)
        (request ^. #hurlOptions)
        (runSelection reference)

    prepareManagedServices ambient suite
      | not (request ^. #suiteOptions . #manageService) =
          pure $ if any isLifecycle (suite ^. #runs) then Left SuiteExternalLifecycle else Right ([], [], [])
      | otherwise = do
          services <- traverse (prepareService ambient) (suiteServices suite)
          setup <- traverse (preparePhase ambient "setup") (suite ^. #setup)
          teardown <- traverse (preparePhase ambient "teardown") (suite ^. #teardown)
          pure ((,,) <$> sequence services <*> sequence setup <*> sequence teardown)

    prepareService ambient serviceName = case lookupService serviceName validated of
      Nothing -> pure (Left (SuiteServiceNotFound serviceName))
      Just service -> case requiredServiceParameters service of
        Left err -> pure (Left (SuiteServiceResolutionFailed err))
        Right required ->
          resolveParameterSubsetInEnvironment ambient validated required (request ^. #bindingInput) >>= \case
            Left err -> pure (Left (SuiteServiceBindingFailed err))
            Right bindings -> case resolveService validated bindings service of
              Left err -> pure (Left (SuiteServiceResolutionFailed err))
              Right resolved -> fmap (either (Left . SuiteServiceResolutionFailed) Right) (freezeService ambient resolved)

    preparePhase ambient phase command =
      resolveParameterSubsetInEnvironment ambient validated (Set.fromList (map (view #parameter) (command ^. #environment))) (request ^. #bindingInput) <&> \case
        Left err -> Left (SuiteServiceBindingFailed err)
        Right bindings ->
          either
            (Left . SuiteServiceResolutionFailed)
            Right
            (resolveCommand validated bindings command >>= prepareCommand ambient (request ^. #suiteName & unSuiteName & ServiceName) phase)

    prepareReports suite runs = case request ^. #suiteOptions . #reportDirectory of
      Nothing ->
        pure
          ( Right
              ( fmap (buildBatchCase TestMode CaptureRunOutput []) runs,
                Nothing
              )
          )
      Just reportRoot -> do
        let suiteRoot = reportRoot </> Text.unpack (unSuiteName (suite ^. #name))
            runList = runs
            runRoots = map ((suiteRoot </>) . view #artifactStem) runList
            formats = request ^. #suiteOptions . #reportFormats
            batchCases =
              [ buildBatchCase TestMode CaptureRunOutput (suiteReportTargets formats runRoot) prepared
              | (prepared, runRoot) <- zip runList runRoots
              ]
            summary = suiteRoot </> "summary.json"
        prepared <- prepareReportLayout reportRoot suiteRoot (runRoots <> [suiteRoot </> "checks" </> Text.unpack (unCheckName name) | CheckRun name <- suite ^. #runs] <> [suiteRoot </> "phases" </> phase </> show ordinal | (phase, commands) <- [("setup", suite ^. #setup), ("teardown", suite ^. #teardown)], ordinal <- [0 .. length commands - 1]]) (request ^. #suiteOptions . #overwriteReports)
        pure ((,Just summary) batchCases <$ prepared)

-- | Select once before rendering, dependency probes, services, and reports.
preflightFileRoot :: ValidatedWorkspace -> SuiteRequest -> IO (Either SuitePreflightError FileRoot)
preflightFileRoot workspace request =
  selectSuiteFileRoot workspace (request ^. #suiteName) (request ^. #bindingInput) (request ^. #fileRoot) >>= \case
    Left err -> pure (Left (SuitePreflightError (SuiteFileRootFailed err :| [])))
    Right root -> do
      checked <- checkFileRootOutputs root (maybe [] pure (request ^. #suiteOptions . #reportDirectory) <> maybe [] pure (request ^. #hurlOptions . #curlExportPath))
      pure (either (Left . SuitePreflightError . (:| []) . SuiteFileRootFailed) (const (Right root)) checked)

-- | Public early boundary for core runners and CLI capability detection. Raw
-- assignments are checked in full before any syntax probe or binding preparation.
preflightScenarioInputs :: ValidatedWorkspace -> SuiteRequest -> IO (Either SuitePreflightError (Maybe VerifiedScenarioInputs))
preflightScenarioInputs workspace request = case lookupSuite (request ^. #suiteName) workspace of
  Nothing -> pure (failure (SuiteNotFound (request ^. #suiteName)))
  Just suite -> case (suite ^. #isolation, request ^. #scenarioInputs) of
    (SharedState, Just _) -> pure (failure SuiteScenarioInputsUnexpected)
    (SharedState, Nothing) -> pure $ case nonEmpty (checkSuiteIsolation workspace (request ^. #suiteName) (request ^. #suiteOptions . #jobs)) of
      Nothing -> Right Nothing
      Just issues -> Left (SuitePreflightError (fmap SuiteIsolationFailed issues))
    (IndependentState, Nothing) -> pure (failure (SuiteIsolationFailed IndependentStateHandoffRequired))
    (IndependentState, Just inputs)
      | request ^. #suiteOptions . #manageService -> pure (failure SuiteIndependentExternalRequired)
      | otherwise -> case traverse (resolveSelection workspace . runSelection) (hurlReferences suite) of
          Left err -> pure (failure (SuiteRunPreparationFailed (SelectionPreparationFailed err)))
          Right expanded -> verifyScenarioInputs workspace (concatMap toList expanded) (request ^. #bindingInput) inputs <&> either (failure . SuiteScenarioFailed) (Right . Just)
  where
    failure issue = Left (SuitePreflightError (issue :| []))

hurlReferences :: Suite -> [RunReference]
hurlReferences suite = filter (\case ServiceRun _ -> False; CheckRun _ -> False; _ -> True) (suite ^. #runs)

isLifecycle :: RunReference -> Bool
isLifecycle (ServiceRun _) = True
isLifecycle _ = False

-- | Partition adjacent Hurl references without losing global case indices.
orderedSteps :: ValidatedWorkspace -> Suite -> [BatchCase] -> [ResolvedCheck] -> [PreparedStep]
orderedSteps workspace suite cases checks = go 0 [] 0 cases (suite ^. #runs)
  where
    checkIndex = Map.fromList [(check ^. #name, check) | check <- checks]
    flush _ [] = []
    flush offset accumulated = [HurlBatch offset (NonEmpty.fromList accumulated)]
    go offset accumulated count _remaining [] = flush (offset - count) accumulated
    go offset accumulated count remaining (ServiceRun step : rest) =
      flush (offset - count) accumulated <> [ServiceBarrier step] <> go offset [] 0 remaining rest
    go offset accumulated count remaining (CheckRun name : rest) =
      let check = fromMaybe (error "preflighted check missing") (Map.lookup name checkIndex)
       in flush (offset - count) accumulated <> [CheckBarrier check] <> go offset [] 0 remaining rest
    go offset accumulated count remaining (reference : rest) =
      let size = either (const 0) length (resolveSelection workspace (runSelection reference))
          (batch, later) = splitAt size remaining
       in go (offset + size) (accumulated <> batch) (count + size) later rest

runSelection :: RunReference -> RunSelection
runSelection = \case
  WorkflowRun name -> SelectWorkflow name
  RecipeRun name -> SelectRecipe name
  MatrixRun name -> SelectMatrix name
  CheckRun _ -> error "check step is not a Hurl selection"
  ServiceRun _ -> error "lifecycle step is not a Hurl selection"

requiresAuthorization :: SafetyDisposition -> Bool
requiresAuthorization = \case
  Classified ReadOnly -> False
  Classified Mutating -> True
  UnclassifiedWorkflow -> True

suiteReportTargets :: Set ReportFormat -> FilePath -> [HurlReportTarget]
suiteReportTargets formats runRoot = map target (Set.toAscList formats)
  where
    target = \case
      JUnit -> JUnitReport (runRoot </> "junit.xml")
      Html -> HtmlReport (runRoot </> "html")
      Json -> JsonReport (runRoot </> "json")
      Tap -> TapReport (runRoot </> "report.tap")

prepareReportLayout :: FilePath -> FilePath -> [FilePath] -> Bool -> IO (Either SuitePreflightIssue ())
prepareReportLayout reportRoot suiteRoot runRoots overwrite
  | null reportRoot || reportRoot == "." || reportRoot == ".." || normalise reportRoot == "/" = pure (Left (SuiteInvalidReportDirectory reportRoot))
  | otherwise = do
      inspected <- concat <$> traverse inspectDirectory (filter (\path -> path /= "/" && path /= "") (drop 1 (scanl (</>) "" (splitDirectories (normalise suiteRoot)))))
      case inspected of
        problem : _ -> pure (Left problem)
        [] -> do
          suiteExists <- doesPathExist suiteRoot
          if suiteExists && not overwrite
            then pure (Left (SuiteReportPathExists suiteRoot))
            else do
              attempted <- try @IOException $ do
                when suiteExists (removePathForcibly suiteRoot)
                ensureOwnerDirectory reportRoot
                createDirectory suiteRoot
                secureDirectory suiteRoot
                traverse_ createRunRoot runRoots
              pure $ case attempted of
                Left err -> Left (SuiteReportIoError suiteRoot (Text.pack (displayException err)))
                Right () -> Right ()
  where
    createRunRoot path = do
      createDirectoryIfMissing True path
      secureTree suiteRoot path

inspectDirectory :: FilePath -> IO [SuitePreflightIssue]
inspectDirectory path = do
  inspected <- try @IOException (getSymbolicLinkStatus path)
  case inspected of
    Left err | isDoesNotExistError err -> pure []
    Left _ -> pure [SuiteReportIoError path "could not inspect report path"]
    Right status
      | isSymbolicLink status -> do
          -- macOS exposes these fixed OS-owned aliases for ordinary temporary
          -- directories. User-controlled symlink ancestors remain forbidden.
          canonical <- try @IOException (canonicalizePath path)
          pure [SuiteReportPathIsSymbolicLink path | not (os == "darwin" && path `elem` ["/tmp", "/var"] && either (const False) (== ("/private" <> path)) canonical)]
      | not (isDirectory status) -> pure [SuiteReportPathIsNotDirectory path]
      | otherwise -> pure []

ensureOwnerDirectory :: FilePath -> IO ()
ensureOwnerDirectory path = createDirectoryIfMissing True path >> secureDirectory path

secureTree :: FilePath -> FilePath -> IO ()
secureTree root target = go root relativeParts
  where
    rootParts = splitDirectories root
    targetParts = splitDirectories target
    relativeParts = drop (length rootParts) targetParts
    go _ [] = pure ()
    go parent (component : remaining) = do
      let path = parent </> component
      secureDirectory path
      go path remaining

secureDirectory :: FilePath -> IO ()
secureDirectory path = do
  setFileMode path secureDirectoryMode
  actual <- (.&. accessModes) . fileMode <$> getFileStatus path
  unless (actual == secureDirectoryMode) (ioError (userError ("insecure permissions on " <> path)))

secureDirectoryMode :: FileMode
secureDirectoryMode = ownerReadMode `unionFileModes` ownerWriteMode `unionFileModes` ownerExecuteMode

duplicates :: (Ord value) => [value] -> [value]
duplicates values =
  [ value
  | (value, count) <- Map.toAscList (Map.fromListWith (+) [(value, 1 :: Int) | value <- values]),
    count > 1
  ]

renderSuitePreflightError :: SuitePreflightError -> Text
renderSuitePreflightError = Text.intercalate "\n" . map renderSuitePreflightIssue . toList . view #issues

renderSuitePreflightIssue :: SuitePreflightIssue -> Text
renderSuitePreflightIssue = \case
  SuiteFileRootFailed err -> renderFileRootError err
  SuiteDependencyUnavailable -> "suite-dependency-unavailable"
  SuiteScenarioFailed issue -> renderScenarioIssue issue
  SuiteScenarioInputsUnexpected -> "scenario-inputs-unexpected: shared-state suites reject scenario inputs"
  SuiteIndependentExternalRequired -> "independent-state-external-required: use --external-service"
  SuiteIsolationFailed issue -> renderIsolationIssue issue
  SuiteExternalLifecycle -> "external-service-lifecycle: lifecycle steps require managed services"
  SuiteNotFound name -> "unknown suite " <> quote (unSuiteName name)
  SuiteRunPreparationFailed err -> renderPreparationError err
  SuiteUnsafeRun name disposition ->
    "run " <> quote name <> " requires --allow-mutating (" <> safetyLabel disposition <> ")"
  SuiteDuplicateArtifactStem stem -> "suite produces duplicate report path for " <> quote (Text.pack stem)
  SuiteSharedCurlExport path -> "--curl has one path and cannot be shared by multiple suite runs: " <> quote (Text.pack path)
  SuiteReportDirectoryRequired -> "--report-dir is required for checks or selected report formats"
  SuiteInvalidReportDirectory path -> "invalid report directory " <> quote (Text.pack path)
  SuiteReportPathExists path -> "report suite directory already exists; use --overwrite to replace " <> quote (Text.pack path)
  SuiteReportPathIsSymbolicLink path -> "report path must not be a symbolic link: " <> quote (Text.pack path)
  SuiteReportPathIsNotDirectory path -> "report path is not a directory: " <> quote (Text.pack path)
  SuiteReportIoError path message -> "could not prepare reports under " <> quote (Text.pack path) <> ": " <> message
  SuiteCheckFailed name err -> "check \"" <> unCheckName name <> "\": " <> renderCheckResolutionError err
  SuiteServiceNotFound name -> "suite refers to unknown service " <> quote (unServiceName name)
  SuiteServiceBindingFailed err -> "managed service: " <> renderBindingError err
  SuiteServiceResolutionFailed err -> "managed service: " <> renderServiceError err
  where
    safetyLabel = \case
      Classified Mutating -> "mutating recipe"
      Classified ReadOnly -> "read-only recipe"
      UnclassifiedWorkflow -> "unclassified workflow"
    quote value = "\"" <> value <> "\""

-- | Stable codes are the only diagnostic content serialized into summaries.
suitePreflightReasonCode :: SuitePreflightIssue -> Text
suitePreflightReasonCode = \case
  SuiteFileRootFailed _ -> "suite-file-root-invalid"
  SuiteIsolationFailed (SharedStateParallelUnsafe _ _) -> "shared-state-parallel-unsafe"
  SuiteIsolationFailed IndependentStateHandoffRequired -> "independent-state-handoff-required"
  SuiteScenarioFailed issue -> renderScenarioIssue issue
  SuiteScenarioInputsUnexpected -> "scenario-inputs-unexpected"
  SuiteIndependentExternalRequired -> "independent-state-external-required"
  SuiteDependencyUnavailable -> "suite-dependency-unavailable"
  SuiteExternalLifecycle -> "external-service-lifecycle"
  SuiteNotFound _ -> "suite-not-found"
  SuiteRunPreparationFailed RunBindingFailed {} -> "suite-binding-failed"
  SuiteRunPreparationFailed _ -> "suite-preparation-failed"
  SuiteUnsafeRun _ _ -> "mutation-authorization-required"
  SuiteDuplicateArtifactStem _ -> "suite-duplicate-scenario"
  SuiteSharedCurlExport _ -> "suite-shared-curl-export"
  SuiteReportDirectoryRequired -> "suite-report-directory-required"
  SuiteInvalidReportDirectory _ -> "suite-invalid-report-directory"
  SuiteReportPathExists _ -> "suite-report-path-exists"
  SuiteReportPathIsSymbolicLink _ -> "suite-report-path-symlink"
  SuiteReportPathIsNotDirectory _ -> "suite-report-path-not-directory"
  SuiteReportIoError _ _ -> "suite-report-io-error"
  SuiteCheckFailed _ _ -> "suite-check-preparation-failed"
  SuiteServiceNotFound _ -> "suite-service-not-found"
  SuiteServiceBindingFailed _ -> "suite-service-binding-failed"
  SuiteServiceResolutionFailed _ -> "suite-service-resolution-failed"

suitePreflightSetupFailure :: SuitePreflightIssue -> Bool
suitePreflightSetupFailure = \case
  SuiteDependencyUnavailable -> True
  SuiteScenarioFailed ScenarioFileUnreadable -> True
  SuiteScenarioFailed ScenarioRuntimeInputFailed -> True
  SuiteRunPreparationFailed RunBindingFailed {} -> True
  SuiteCheckFailed _ _ -> True
  SuiteServiceBindingFailed _ -> True
  _ -> False