packages feed

xrefcheck-0.3.0: src/Xrefcheck/Progress.hs

{- SPDX-FileCopyrightText: 2019 Serokell <https://serokell.io>
 -
 - SPDX-License-Identifier: MPL-2.0
 -}

-- | Printing progress bars.
module Xrefcheck.Progress
  ( -- * Task timestamp
    TaskTimestamp (..)

    -- * Progress
  , Progress
  , initProgress
  , initProgressWitnessed
  , reportSuccess
  , reportError
  , reportRetry
  , getTaskTimestamp
  , setTaskTimestamp
  , removeTaskTimestamp
  , checkTaskTimestamp
  , sameProgress
  , showProgress

    -- * Printing
  , Rewrite
  , allowRewrite
  , putTextRewrite
  ) where

import Universum

import Data.Ratio ((%))
import Data.Reflection (Given)
import Data.Set qualified as S
import Time (Second, Time, sec, unTime, (-:-))

import Xrefcheck.Util

-----------------------------------------------------------
-- Task timestamp
-----------------------------------------------------------

-- | Data type defining a point in time when an anonymous task had started
-- and its time to completion.
data TaskTimestamp = TaskTimestamp
  { ttTimeToCompletion :: Time Second
    -- ^ The amount of time required for the task to be completed.
  , ttStart :: Time Second
    -- ^ The timestamp of when the task had started, represented by the number of seconds
    -- since the Unix epoch.
  } deriving stock (Show)

-----------------------------------------------------------
-- Progress
-----------------------------------------------------------

-- | Processing progress of any thing, measured with type @a@, where progress units have witnesses
-- of type @w@ that can be retried.
--
-- The () type can be used as a trivial witness if the retry logic is not going to be used.
data Progress a w = Progress
  { pTotal :: !a
    -- ^ Overall amount of work.
  , pSuccess :: !a
    -- ^ How much has been completed with success.
  , pError :: !a
    -- ^ How much has been completed with error.
  , pRetrying :: !(S.Set w)
    -- ^ Witnesses of items that have been completed with error but are being retried.
  , pTaskTimestamp :: !(Maybe TaskTimestamp)
    -- ^ A timestamp of an anonymous timer task, where its time to completion is
    -- the time needed to pass for the action to be retried immediately after.
  } deriving stock (Show)

-- | Initialise null progress.
initProgress :: Num a => a -> Progress a w
initProgress a = Progress
  { pTotal = a
  , pSuccess = 0
  , pError = 0
  , pRetrying = S.empty
  , pTaskTimestamp = Nothing
  }

-- | Initialise null progress from a given list of witnesses.
--
-- This just initializes it with as many work to do as witnesses are in the list, so you can be
-- more confident regarding the progress initialization because you actualy provided data that
-- represents each unit of work to do.
initProgressWitnessed :: [w] -> Progress Int w
initProgressWitnessed ws = Progress
  { pTotal = length ws
  , pSuccess = 0
  , pError = 0
  , pRetrying = S.empty
  , pTaskTimestamp = Nothing
  }

-- | Report a unit of success with witness @item@.
reportSuccess :: (Num a, Ord w) => w -> Progress a w -> Progress a w
reportSuccess item Progress{..} = Progress
  { pSuccess = pSuccess + 1
  , pRetrying = S.delete item pRetrying
  , ..
  }

-- | Report a unit of failure with witness @item@.
reportError :: (Num a, Ord w) => w -> Progress a w -> Progress a w
reportError item Progress{..} = Progress
  { pError = pError + 1
  , pRetrying = S.delete item pRetrying
  , ..
  }

-- | Report a unit of failure and retry intention with witness @item@.
reportRetry :: Ord w => w -> Progress a w -> Progress a w
reportRetry item Progress{..} = Progress
  { pRetrying = S.insert item pRetrying
  , ..
  }

-- | Set the current `TaskTimestamp`.
--
-- It does require a witness because, although the `TaskTimestamp` is
-- anonymous, at this point an actual task should be responsible for
-- registering this timestamp.
setTaskTimestamp :: w -> Time Second -> Time Second -> Progress a w -> Progress a w
setTaskTimestamp _ ttc startTime Progress{..} = Progress
  { pTaskTimestamp = Just (TaskTimestamp ttc startTime)
  , ..
  }

-- | Get the current `TaskTimestamp`.
--
-- It does not require a witness because the `TaskTimestamp` is anonymous
-- and anyone should be able to observe it.
getTaskTimestamp :: Progress a w -> Maybe TaskTimestamp
getTaskTimestamp = pTaskTimestamp

removeTaskTimestamp :: Progress a w -> Progress a w
removeTaskTimestamp Progress{..} = Progress
  { pTaskTimestamp = Nothing
  , ..
  }

checkTaskTimestamp :: Time Second -> Progress a w -> Progress a w
checkTaskTimestamp posixTime p@Progress{..} =
  case pTaskTimestamp of
    Nothing -> p
    Just TaskTimestamp{..} ->
      if ttTimeToCompletion >= posixTime -:- ttStart
      then p
      else removeTaskTimestamp p

-- | Check whether the two @Progress@ values are equal up to similarity of their essential
-- components, ignoring the comparison of @pTaskTimestamp@s, which is done to prevent test
-- failures when comparing the resulting progress, gotten from running the link
-- verification algorithm, with the expected one, where @pTaskTimestamp@ is hardcoded
-- as @Nothing@.
sameProgress :: (Eq a, Eq w) => Progress a w -> Progress a w -> Bool
sameProgress p1 p2 = and
  [ ((==) `on` pTotal) p1 p2
  , ((==) `on` pSuccess) p1 p2
  , ((==) `on` pError) p1 p2
  , ((==) `on` pRetrying) p1 p2
  ]

-- | Visualise progress bar.
showProgress :: Given ColorMode => Text -> Int -> Color -> Time Second -> Progress Int w -> Text
showProgress name width col posixTime Progress{..} = mconcat
  [ colorIfNeeded col (name <> ": [")
  , toText bar
  , timer
  , colorIfNeeded col "]"
  , status
  ]
  where
    -- Each of the following values represents the number of the progress bar cells
    -- corresponding to the respective "class" of processed references: the valid ones,
    -- the ones containing an unfixable error (a.k.a. the invalid ones), and the ones
    -- containing a fixable error.
    --
    -- The current overall number of proccessed errors.
    done = floor $ (current % pTotal) * fromIntegral @Int @(Ratio Int) width

    -- The current number of the invalid references.
    errsU = ceiling $ (pError % pTotal) * fromIntegral @Int @(Ratio Int) width

    -- The current number of (fixable) errors that may be eliminated during further
    -- verification.
    -- Notice!
    --   1. Both this and the previous values use @ceiling@ as the rounding function.
    --      This is done to ensure that as soon as at least 1 faulty reference occurs during
    --      the verification, the cell of its respective color is mathematically guaranteed
    --      to be visible in the progress bar visualization.
    --   2. @errsF@ is bounded from above by @width - errsU@ to prevent an overflow in the
    --      number of the progress bar cells that could be caused by the two @ceilings@s.
    errsF = min (width - errsU) . ceiling $ (fixable % pTotal) *
      fromIntegral @Int @(Ratio Int) width

    -- The number of valid references.
    -- The value is bounded from below by 0 to ensure the number never gets negative.
    -- This situation is plausible due to the different rounding functions used for each value:
    -- @floor@ for the minuend @done@, @ceiling@ for the two subtrahends @errsU@ & @errsF@.
    successful = max 0 $ done - errsU - errsF

    -- The remaining number of references to be verified.
    remaining = width - successful - errsU - errsF

    bar
      | pTotal == 0 = replicate width '-'
      | otherwise = mconcat
          [ colorIfNeeded Blue $ replicate errsF '■'
          , colorIfNeeded Red $ replicate errsU '■'
          , colorIfNeeded col $ replicate successful '■'
          , colorIfNeeded col $ replicate remaining ' '
          , " "
          ]
    timer = case pTaskTimestamp of
      Nothing -> ""
      Just TaskTimestamp{..} -> mconcat
        [ colorIfNeeded col "|"
        , colorIfNeeded Blue . show . timeSecondCeiling
        $ ttTimeToCompletion -:- (posixTime -:- ttStart)
        ]
    status = mconcat
      [ if current == pTotal && fixable == 0 && pError == 0
        then styleIfNeeded Faint $ colorIfNeeded White "✓"
        else ""
      , if fixable /= 0 then colorIfNeeded Blue "!" else ""
      , if pError /= 0 then colorIfNeeded Red "!" else ""
      ]

    timeSecondCeiling :: Time Second -> Time Second
    timeSecondCeiling = sec . fromInteger . ceiling . unTime

    fixable :: Int
    fixable = S.size pRetrying

    current :: Int
    current = pSuccess + pError + fixable

-----------------------------------------------------------
-- Rewritable output
-----------------------------------------------------------

-- | Rewrites state.
data RewriteCtx = RewriteCtx
  { rMaxPrintedSize :: IORef Int
  }

-- | Passing this object allows returning caret and replace text in line.
-- Only functions which has this thing can do that because being
-- interleaved with 'putTextLn' printing caret symbol produced garbage.
data Rewrite
  = Rewrite RewriteCtx
    -- ^ Default value.
  | RewriteDisabled
    -- ^ Do not print anything which will be rewritten.
    -- Useful when terminal does not interpret caret returns as expected.

-- | Provide context for rewrite operations.
allowRewrite :: (MonadIO m, MonadMask m) => Bool -> (Rewrite -> m a) -> m a
allowRewrite enabled = bracket prepare erase
  where
    prepare
      | enabled = do
          rMaxPrintedSize <- newIORef 0
          return $ Rewrite RewriteCtx{..}
      | otherwise = pure RewriteDisabled
    erase (Rewrite RewriteCtx{..}) = liftIO $ do
      maxPrintedSize <- readIORef rMaxPrintedSize
      hPutStr stderr $ '\r' : replicate maxPrintedSize ' ' ++ "\r"
    erase RewriteDisabled = pass

-- | Return caret and print the given text.
putTextRewrite :: MonadIO m => Rewrite -> Text -> m ()
putTextRewrite RewriteDisabled _ = pass
putTextRewrite (Rewrite RewriteCtx{..}) msg = do
  liftIO $ hPutStr stderr ('\r' : toString msg ++ fill)
  atomicModifyIORef' rMaxPrintedSize $ \maxPrinted ->
    (max maxPrinted (length msg), ())
  where
    -- The maximum possible difference between two progress text representations,
    -- including the timer & the status, is 9 characters. This is a temporary
    -- solution to the problem of re-printing a smaller string on top of another
    -- that'll leave some of the trailing characters in the original string
    -- untouched, and is most likely going to be either replaced by an adequate
    -- workaround or by another way to form a text representation of a progress and
    -- its respective rewriting logic.
    fill = replicate 9 ' '