packages feed

cachix-1.9.0: src/Cachix/Daemon/Progress.hs

module Cachix.Daemon.Progress
  ( UploadProgress,
    ProgressState,
    new,
    complete,
    fail,
    tick,
    update,
    newProgressState,
    displayPushEvent,
  )
where

import Cachix.Client.HumanSize (humanSize)
import Cachix.Daemon.Types (PushRetryStatus (..))
import Cachix.Daemon.Types.PushEvent (PushEvent (..), PushEventMessage (..))
import Control.Concurrent.Async qualified as Async
import Control.Concurrent.MVar
import Data.HashMap.Strict qualified as HashMap
import Data.IORef
import Data.String (String)
import Data.Text qualified as T
import Protolude
import System.Console.AsciiProgress qualified as Ascii
import System.Console.AsciiProgress.Internal qualified as Ascii.Internal
import System.Console.Pretty
import System.Environment (lookupEnv)
import System.IO (hIsTerminalDevice)

data UploadProgress
  = ProgressBar
      { path :: String,
        size :: Int64,
        progressBar :: Ascii.ProgressBar
      }
  | FallbackText
      { path :: String,
        size :: Int64
      }

-- | State for tracking multiple progress bars
type ProgressState = IORef (HashMap.HashMap FilePath (UploadProgress, PushRetryStatus))

-- | Create a new progress state for tracking multiple progress bars
newProgressState :: IO ProgressState
newProgressState = newIORef HashMap.empty

new :: Handle -> String -> Int64 -> PushRetryStatus -> IO UploadProgress
new hdl path size retryStatus = do
  isCI <- liftIO $ (== Just "true") <$> lookupEnv "CI"
  isTerminal <- liftIO $ hIsTerminalDevice hdl
  if isTerminal && not isCI
    then do
      progressBar <- newProgressBar path size retryStatus
      return $ ProgressBar {..}
    else do
      hPutStr stderr $ uploadStartFallback path size retryStatus
      return $ FallbackText {..}

complete :: UploadProgress -> IO ()
complete ProgressBar {..} = Ascii.complete progressBar
complete _ = pure ()

tick :: UploadProgress -> Int64 -> IO ()
tick ProgressBar {..} deltaBytes = Ascii.tickN progressBar (fromIntegral deltaBytes)
tick _ _ = pure ()

fail :: UploadProgress -> IO ()
fail ProgressBar {..} =
  clearAsciiWith progressBar (uploadFailed path)
fail FallbackText {path} =
  hPutStr stderr $ uploadFailed path

update :: UploadProgress -> PushRetryStatus -> IO UploadProgress
update pg@ProgressBar {..} retryStatus = do
  let opts = newProgressBarOptions path size retryStatus
  pg' <- newAsciiProgressBarInPlace progressBar opts

  return $ pg {progressBar = pg'}
update fbt _ = return fbt

newProgressBar :: String -> Int64 -> PushRetryStatus -> IO Ascii.ProgressBar
newProgressBar path size retryStatus =
  newAsciiProgressBar $ newProgressBarOptions path size retryStatus

newProgressBarOptions :: String -> Int64 -> PushRetryStatus -> Ascii.Options
newProgressBarOptions path size retryStatus = do
  let hSize = toS $ humanSize $ fromIntegral size
  let barLength =
        uploadTickBar path hSize retryStatus
          & toS
          & T.replace "[:bar]" ""
          & T.replace ":percent" "  0%"
          & T.length
  Ascii.def
    { Ascii.pgTotal = fromIntegral size,
      -- https://github.com/yamadapc/haskell-ascii-progress/issues/24
      Ascii.pgWidth = 20 + barLength,
      Ascii.pgOnCompletion = Just $ uploadComplete path size,
      Ascii.pgFormat = uploadTickBar path hSize retryStatus
    }

retryText :: PushRetryStatus -> String
retryText PushRetryStatus {retryCount} =
  if retryCount == 0
    then ""
    else color Yellow $ "retry #" <> show retryCount <> " "

uploadComplete :: String -> Int64 -> String
uploadComplete path size =
  let hSize = toS $ humanSize $ fromIntegral size
   in color Green "✓ " <> path <> " (" <> hSize <> ")"

uploadFailed :: String -> String
uploadFailed path =
  color Red "✗ " <> path

uploadTickBar :: String -> String -> PushRetryStatus -> String
uploadTickBar path hSize retryStatus =
  color Blue "[:bar] " <> retryText retryStatus <> toS path <> " (:percent of " <> hSize <> ")"

uploadStartFallback :: String -> Int64 -> PushRetryStatus -> String
uploadStartFallback path size retryStatus =
  let hSize = toS $ humanSize $ fromIntegral size
   in retryText retryStatus <> "Pushing " <> path <> " (" <> hSize <> ")\n"

-- Internal

newAsciiProgressBar :: Ascii.Options -> IO Ascii.ProgressBar
newAsciiProgressBar opts = do
  region <- Ascii.openConsoleRegion Ascii.Linear
  newAsciiProgressBarWithRegion opts region

-- | Create a new progress bar in place of an existing one, reusing the console region.
newAsciiProgressBarInPlace :: Ascii.ProgressBar -> Ascii.Options -> IO Ascii.ProgressBar
newAsciiProgressBarInPlace pg opts = do
  cancelUpdates pg
  newAsciiProgressBarWithRegion opts (Ascii.pgRegion pg)

-- | Create a new progress bar using the provided console region.
newAsciiProgressBarWithRegion :: Ascii.Options -> Ascii.ConsoleRegion -> IO Ascii.ProgressBar
newAsciiProgressBarWithRegion opts region = do
  info <- Ascii.Internal.newProgressBarInfo opts

  -- Display initial progress-bar
  pgStr <- Ascii.pgGetProgressStr opts opts <$> Ascii.Internal.getInfoStats info
  Ascii.setConsoleRegion region pgStr

  future <- Async.async $ start info
  return $ Ascii.ProgressBar info future region
  where
    start info@Ascii.Internal.ProgressBarInfo {..} = do
      c <- readMVar pgCompleted
      unlessDone c $ do
        n <- readChan pgChannel
        _ <- handleMessage info n
        unlessDone (c + n) $ start info
      where
        unlessDone c action | c < Ascii.pgTotal opts = action
        unlessDone _ _ = do
          let fmt = fromMaybe (Ascii.pgFormat opts) (Ascii.pgOnCompletion opts)
          onCompletion <- Ascii.pgGetProgressStr opts opts {Ascii.pgFormat = fmt} <$> Ascii.Internal.getInfoStats info
          Ascii.setConsoleRegion region onCompletion

    handleMessage info n = do
      -- Update the completed tick count
      modifyMVar_ (Ascii.Internal.pgCompleted info) (\c -> return (c + n))
      -- Find and update the current and first tick times:
      stats <- Ascii.Internal.getInfoStats info
      let progressStr = Ascii.Internal.pgGetProgressStr opts opts stats
      Ascii.setConsoleRegion region progressStr

-- | Cancel async updates.
cancelUpdates :: Ascii.ProgressBar -> IO ()
cancelUpdates (Ascii.ProgressBar _ future _) = Async.cancel future

-- | Cancel async updates and clear the console region with the given string.
clearAsciiWith :: Ascii.ProgressBar -> String -> IO ()
clearAsciiWith pg@(Ascii.ProgressBar _ _ region) str = do
  cancelUpdates pg
  Ascii.setConsoleRegion region str

-- | Display push events as progress bars, deduplicating by path and retry status
displayPushEvent :: ProgressState -> PushEvent -> IO ()
displayPushEvent statsRef PushEvent {eventMessage} = do
  stats <- readIORef statsRef
  case eventMessage of
    PushStorePathAttempt path pathSize retryStatus -> do
      case HashMap.lookup path stats of
        Just (progress, prevRetryStatus)
          | prevRetryStatus == retryStatus -> return ()
          | otherwise -> do
              newProgress <- update progress retryStatus
              writeIORef statsRef $ HashMap.insert path (newProgress, retryStatus) stats
        Nothing -> do
          progress <- new stderr (toS path) pathSize retryStatus
          writeIORef statsRef $ HashMap.insert path (progress, retryStatus) stats
    PushStorePathProgress path _ newBytes -> do
      case HashMap.lookup path stats of
        Nothing -> return ()
        Just (progress, _) -> tick progress newBytes
    PushStorePathDone path -> do
      mapM_ (complete . fst) (HashMap.lookup path stats)
    PushStorePathFailed path _ -> do
      mapM_ (fail . fst) (HashMap.lookup path stats)
    _ -> return ()