packages feed

arch-hs-0.16: rdepcheck/RDepCheck.hs

{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TypeApplications #-}

module RDepCheck (FailureCounts (..), check, checkTargets, checkReverseDep, checkReverseDepRevisions, printRdepcheckResult) where

import Control.Monad (forM)
import Data.List (partition)
import qualified Data.Map.Strict as Map
import Distribution.ArchHs.Exception
import Distribution.ArchHs.ExtraDB (versionInExtra)
import Distribution.ArchHs.Hackage (RawHackageDB)
import Distribution.ArchHs.Internal.Prelude
import Distribution.ArchHs.Name (toHackageName)
import Distribution.ArchHs.PP
import Distribution.ArchHs.RDepCheck
import Distribution.ArchHs.Types
import Distribution.Version (asVersionIntervals)
import System.Exit (exitFailure)

data FailureCounts = FailureCounts
  { newFailures :: Int,
    oldFailures :: Int
  }
  deriving stock (Eq, Show)

checkTargets ::
  Members
    [ ExtraEnv,
      RawHackageEnv,
      KnownGHCVersion,
      FlagAssignmentsEnv,
      Trace,
      DependencyRecord,
      WithMyErr,
      Embed IO
    ]
    r =>
  RawHackageDB ->
  [(PackageName, Maybe Version)] ->
  Sem r FailureCounts
checkTargets revision0 targets = do
  let candidates = Map.fromList [(target, version) | (target, Just version) <- targets, length targets > 1]
  results <- forM targets $ \(target, mVersion) -> do
    latest <- indexResults <$> reverseDependencyRangesWithCandidates candidates target
    original <- indexResults <$> local @RawHackageDB (const revision0) (reverseDependencyRangesWithCandidates candidates target)
    installed <- if Map.null candidates then pure latest else indexResults <$> reverseDependencyRangesWithSkips target
    installedOriginal <- if Map.null candidates then pure original else indexResults <$> local @RawHackageDB (const revision0) (reverseDependencyRangesWithSkips target)
    versions <- forM mVersion $ \candidate -> do
      rawVersion <- versionInExtra target
      case simpleParsec rawVersion of
        Just current -> pure (current, candidate)
        Nothing -> throw $ VersionNoParse rawVersion
    let oldSources baseline name =
          if Map.member (toHackageName name) candidates
            then Just $ case (versions, Map.lookup name baseline) of
              (Just (current, _), Just (Right dep)) -> fst <$> versionFailures (Just current) (reverseDepRanges dep)
              _ -> []
            else Nothing
        originalResult name = Map.findWithDefault (Left $ PkgNotFound name) name original
        noRanges (Right dep) = null $ reverseDepRanges dep
        noRanges (Left _) = False
    pure $
      Map.mapWithKey
        (\name latestResult -> [(target, versions, latestResult, originalResult name, (oldSources installed name, oldSources installedOriginal name))])
        (Map.filterWithKey (\name result -> not $ Map.member (toHackageName name) candidates && noRanges result && noRanges (originalResult name)) latest)
  failures <- forM (Map.toList $ Map.unionsWith (<>) results) $ \(name, deps) -> do
    let checked =
          [ if length targets == 1
              then checkReverseDepRevisions versions name latest original
              else
                checkReverseDepRevisionsWithHeader
                  (annCyan $ "Target:" <+> viaPretty target <> maybe mempty ((space <>) . viaPretty . snd) versions)
                  oldSources versions latest original
            | (target, versions, latest, original, oldSources) <- deps
          ]
        doc =
          if length targets == 1
            then vsep $ fst <$> checked
            else vsep $ reverseDepHeader name : (indent 2 . fst <$> checked)
    embed $ putDoc $ doc <> line
    pure $ FailureCounts (sum $ newFailures . snd <$> checked) (sum $ oldFailures . snd <$> checked)
  pure $ FailureCounts (sum $ newFailures <$> failures) (sum $ oldFailures <$> failures)

check ::
  Members
    [ ExtraEnv,
      RawHackageEnv,
      KnownGHCVersion,
      FlagAssignmentsEnv,
      Trace,
      DependencyRecord,
      WithMyErr,
      Embed IO
    ]
    r =>
  RawHackageDB ->
  Maybe Version ->
  PackageName ->
  Sem r FailureCounts
check revision0 mVersion target = checkTargets revision0 [(target, mVersion)]

indexResults :: ([ReverseDep], [SkippedReverseDep]) -> Map.Map ArchLinuxName (Either MyException ReverseDep)
indexResults (checked, skipped) =
  Map.fromList $
    [(reverseDepName dep, Right dep) | dep <- checked]
      <> [(skippedReverseDepName dep, Left $ skippedReverseDepError dep) | dep <- skipped]

checkReverseDep :: Maybe (Version, Version) -> ReverseDep -> (Doc AnsiStyle, FailureCounts)
checkReverseDep versions ReverseDep {..} =
  let (docs, counts) = checkRanges versions reverseDepRanges
   in (vsep $ reverseDepHeader reverseDepName : docs, counts)

checkReverseDepRevisions ::
  Maybe (Version, Version) ->
  ArchLinuxName ->
  Either MyException ReverseDep ->
  Either MyException ReverseDep ->
  (Doc AnsiStyle, FailureCounts)
checkReverseDepRevisions versions name latest original =
  case (latest, original) of
    (Left a, Left b) | show a == show b ->
      (annYellow $ "Skip" <+> pretty (unArchLinuxName name) <> colon <+> viaShow a, FailureCounts 0 0)
    _ -> checkReverseDepRevisionsWithHeader (reverseDepHeader name) (Nothing, Nothing) versions latest original

checkReverseDepRevisionsWithHeader ::
  Doc AnsiStyle ->
  (Maybe [DepSrc], Maybe [DepSrc]) ->
  Maybe (Version, Version) ->
  Either MyException ReverseDep ->
  Either MyException ReverseDep ->
  (Doc AnsiStyle, FailureCounts)
checkReverseDepRevisionsWithHeader header (latestOld, originalOld) versions latest original
  | sameResult latest original && latestOld == originalOld =
      case latest of
        Right dep ->
          let (docs, counts) = checkRangesWithOldFailures latestOld versions $ reverseDepRanges dep
           in (vsep $ header : docs, counts)
        Left err -> (vsep [header, indent 2 $ annYellow $ "unchecked:" <+> viaShow err], FailureCounts 0 0)
  | otherwise =
      ( vsep $
          header
            : revisionDocs annCyan "latest revision" latestOld latest
              <> revisionDocs annBlue "revision 0" originalOld original,
        snd $ resultDetails latestOld latest
      )
  where
    sameResult (Right a) (Right b) =
      [(src, asVersionIntervals range) | (src, range) <- reverseDepRanges a]
        == [(src, asVersionIntervals range) | (src, range) <- reverseDepRanges b]
    sameResult (Left a) (Left b) = show a == show b
    sameResult _ _ = False

    resultDetails oldSources (Right dep) = checkRangesWithOldFailures oldSources versions $ reverseDepRanges dep
    resultDetails _ (Left err) = ([indent 2 $ annYellow $ "unchecked:" <+> viaShow err], FailureCounts 0 0)

    revisionDocs style label oldSources result =
      let (docs, counts) = resultDetails oldSources result
          status = case (versions, result) of
            (Just _, Right _) -> space <> parens (prettyFailureCounts counts)
            _ -> mempty
       in indent 2 (style $ annBold label <> status <> colon) : fmap (indent 2) docs

reverseDepHeader :: ArchLinuxName -> Doc AnsiStyle
reverseDepHeader name = annMagneta $ "Reverse dependency" <> colon <+> annBold (pretty $ unArchLinuxName name)

checkRanges :: Maybe (Version, Version) -> [(DepSrc, VersionRange)] -> ([Doc AnsiStyle], FailureCounts)
checkRanges = checkRangesWithOldFailures Nothing

-- A coordinated upgrade compares failures with the installed dependent's ranges.
checkRangesWithOldFailures :: Maybe [DepSrc] -> Maybe (Version, Version) -> [(DepSrc, VersionRange)] -> ([Doc AnsiStyle], FailureCounts)
checkRangesWithOldFailures oldSources versions ranges =
  ( rangeDocs oldSources versions ranges <> errors,
    FailureCounts (length newRanges) (length oldRanges)
  )
  where
    (newRanges, oldRanges) =
      case versions of
        Nothing -> ([], [])
        Just (current, candidate) ->
          partition (\(src, range) -> maybe (withinRange current range) (notElem src) oldSources) $ versionFailures (Just candidate) ranges
    errors =
      case versions of
        Nothing -> []
        Just (_, candidate) ->
          versionErrors annRed "rdep:" candidate newRanges
            <> versionErrors annYellow "rdep-old:" candidate oldRanges

printRdepcheckResult :: IO (Either MyException FailureCounts) -> IO ()
printRdepcheckResult io = do
  result <- io
  case result of
    Left err -> do
      printError $ "Runtime Exception" <> colon <+> viaShow err
      exitFailure
    Right counts@FailureCounts {..}
      | newFailures > 0 -> do
          printError $ "Reverse dependency range check(s) failed:" <+> prettyFailureCounts counts
          exitFailure
      | oldFailures > 0 ->
          printWarn $ "Existing reverse dependency range failure(s):" <+> prettyFailureCounts counts
      | otherwise -> printSuccess "Success!"

prettyFailureCounts :: FailureCounts -> Doc AnsiStyle
prettyFailureCounts FailureCounts {..} =
  (if newFailures == 0 then annGreen else annRed) ("rdep=" <> pretty newFailures)
    <> comma
      <+> (if oldFailures == 0 then annGreen else annYellow) ("rdep-old=" <> pretty oldFailures)

rangeDocs :: Maybe [DepSrc] -> Maybe (Version, Version) -> [(DepSrc, VersionRange)] -> [Doc AnsiStyle]
rangeDocs oldSources versions result =
  [ indent 2 $ pretty s <> colon <+> rangeColor s r (viaPretty r)
    | (s, r) <- result
  ]
  where
    rangeColor src range =
      case versions of
        Nothing -> annBlue
        Just (current, candidate)
          | withinRange candidate range -> annGreen
          | maybe (withinRange current range) (notElem src) oldSources -> annRed
          | otherwise -> annYellow

versionErrors :: (Doc AnsiStyle -> Doc AnsiStyle) -> Doc AnsiStyle -> Version -> [(DepSrc, VersionRange)] -> [Doc AnsiStyle]
versionErrors style label version result =
  [ indent 2 $ style $
      label
        <+> annBold (viaPretty version)
        <+> "is outside"
        <+> pretty src
        <+> "range"
        <+> parens (viaPretty range)
    | (src, range) <- result
  ]