packages feed

xrefcheck-0.2.2: 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
  , incProgress
  , incProgressUnfixableErrors
  , incProgressFixableErrors
  , decProgressFixableErrors
  , fixableToUnfixable
  , setTaskTimestamp
  , removeTaskTimestamp
  , checkTaskTimestamp
  , showProgress

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

import Universum

import Data.Ratio ((%))
import Data.Reflection (Given)
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.
data Progress a = Progress
  { pTotal         :: a
    -- ^ Overall amount of work.
  , pCurrent           :: a
    -- ^ How much has been completed.
  , pErrorsUnfixable :: !a
    -- ^ How much of the completed work finished with an unfixable error.
  , pErrorsFixable   :: !a
    -- ^ How much of the completed work finished with an error that can be
    -- eliminated upon further verification.
  , 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
initProgress a = Progress{ pTotal = a
                         , pCurrent = 0
                         , pErrorsUnfixable = 0
                         , pErrorsFixable = 0
                         , pTaskTimestamp = Nothing
                         }

-- | Increase progress amount.
incProgress :: (Num a) => Progress a -> Progress a
incProgress Progress{..} = Progress{ pCurrent = pCurrent + 1, .. }

-- | Increase the number of unfixable errors.
incProgressUnfixableErrors :: (Num a) => Progress a -> Progress a
incProgressUnfixableErrors Progress{..} = Progress{ pErrorsUnfixable = pErrorsUnfixable + 1
                                                  , ..
                                                  }

-- | Increase the number of fixable errors.
incProgressFixableErrors :: (Num a) => Progress a -> Progress a
incProgressFixableErrors Progress{..} = Progress{ pErrorsFixable = pErrorsFixable + 1
                                                , ..
                                                }

-- | Decrease the number of fixable errors. This function indicates the situation where one of
-- such errors had been successfully eliminated.
decProgressFixableErrors :: (Num a) => Progress a -> Progress a
decProgressFixableErrors Progress{..} = Progress{ pErrorsFixable = pErrorsFixable - 1
                                                , ..
                                                }

fixableToUnfixable :: (Num a) => Progress a -> Progress a
fixableToUnfixable Progress{..} = Progress{ pErrorsFixable = pErrorsFixable - 1
                                          , pErrorsUnfixable = pErrorsUnfixable + 1
                                          , ..
                                          }

setTaskTimestamp :: Time Second -> Time Second -> Progress a -> Progress a
setTaskTimestamp ttc startTime Progress{..} = Progress{ pTaskTimestamp =
                                                          Just $ TaskTimestamp ttc startTime
                                                      , ..
                                                      }

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

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

-- | Visualise progress bar.
showProgress :: Given ColorMode => Text -> Int -> Color -> Time Second -> Progress Int -> 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 $ (pCurrent % pTotal) * fromIntegral @Int @(Ratio Int) width

    -- | The current number of the invalid references.
    errsU = ceiling $ (pErrorsUnfixable % 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 $ (pErrorsFixable % 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 pCurrent == pTotal && pErrorsFixable == 0 && pErrorsUnfixable == 0
        then styleIfNeeded Faint $ colorIfNeeded White "✓"
        else ""
      , if pErrorsFixable /= 0 then colorIfNeeded Blue "!" else ""
      , if pErrorsUnfixable /= 0 then colorIfNeeded Red "!" else ""
      ]

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

-----------------------------------------------------------
-- 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 ' '