packages feed

tuispec-0.2.0.0: src/TuiSpec/Replay.hs

{-# LANGUAGE OverloadedStrings #-}

{- |
Module      : TuiSpec.Replay
Description : JSONL recording and replay primitives for server sessions.

This module isolates the JSONL event log format used by @tuispec server@
recording and replay features.
-}
module TuiSpec.Replay (
    RecordingHandle,
    RecordingEvent (..),
    RecordingDirection (..),
    ReplayFrame (..),
    ReplaySpeed (..),
    applyDelta,
    appendRecordingEvent,
    closeRecording,
    computeFrameDelta,
    extractInputLabel,
    loadReplayFrames,
    openRecording,
    streamReplayFrames,
    streamReplayRequests,
) where

import Control.Concurrent (threadDelay)
import Control.Exception (Exception, bracket, throwIO)
import Control.Monad (when)
import Data.Aeson (FromJSON (parseJSON), ToJSON (toJSON), Value (Array, Number, Object, String), eitherDecodeStrict', encode, object, withObject, (.:), (.=))
import Data.Aeson qualified as Aeson
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString.Char8 qualified as BS8
import Data.ByteString.Lazy qualified as BL
import Data.IORef (newIORef, readIORef, writeIORef)
import Data.Int (Int64)
import Data.IntMap.Strict (IntMap)
import Data.IntMap.Strict qualified as IM
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Time.Clock.POSIX (getPOSIXTime)
import System.Directory (createDirectoryIfMissing)
import System.FilePath (takeDirectory)
import System.IO (Handle, IOMode (AppendMode, ReadMode), hClose, hFlush, hIsEOF, hPutStrLn, openFile)

-- | Direction of a recorded JSON-RPC line, or a viewport frame capture.
data RecordingDirection
    = DirectionRequest
    | DirectionResponse
    | DirectionNotification
    | -- | Full viewport keyframe (line contains the complete visible text).
      DirectionFrame
    | -- | Delta frame (line contains JSON-encoded changed lines since last keyframe).
      DirectionFrameDelta
    deriving (Eq, Show)

instance ToJSON RecordingDirection where
    toJSON value =
        case value of
            DirectionRequest -> toJSON ("request" :: Text)
            DirectionResponse -> toJSON ("response" :: Text)
            DirectionNotification -> toJSON ("notification" :: Text)
            DirectionFrame -> toJSON ("frame" :: Text)
            DirectionFrameDelta -> toJSON ("frame-delta" :: Text)

instance FromJSON RecordingDirection where
    parseJSON value = do
        raw <- parseJSON value
        case (T.toLower (T.strip raw) :: Text) of
            "request" -> pure DirectionRequest
            "response" -> pure DirectionResponse
            "notification" -> pure DirectionNotification
            "frame" -> pure DirectionFrame
            "frame-delta" -> pure DirectionFrameDelta
            _ -> fail "direction must be one of: request, response, notification, frame, frame-delta"

-- | Single JSONL event written to disk.
data RecordingEvent = RecordingEvent
    { recordingTimestampMicros :: Int64
    , recordingDirection :: RecordingDirection
    , recordingLine :: Text
    }
    deriving (Eq, Show)

instance ToJSON RecordingEvent where
    toJSON event =
        object
            [ "timestampMicros" .= recordingTimestampMicros event
            , "direction" .= recordingDirection event
            , "line" .= recordingLine event
            ]

instance FromJSON RecordingEvent where
    parseJSON =
        withObject "RecordingEvent" $ \o ->
            RecordingEvent
                <$> o .: "timestampMicros"
                <*> o .: "direction"
                <*> o .: "line"

-- | Replay speed used by @streamReplayRequests@ and @streamReplayFrames@.
data ReplaySpeed
    = ReplayAsFastAsPossible
    | ReplayRealTime
    deriving (Eq, Show, Read)

-- | Handle wrapper for an active JSONL recording file.
data RecordingHandle = RecordingHandle
    { recordingPath :: FilePath
    , recordingOutputHandle :: Handle
    }

data RecordingParseError = RecordingParseError FilePath String
    deriving (Show)

instance Exception RecordingParseError

-- | Open or create a JSONL recording file in append mode.
openRecording :: FilePath -> IO RecordingHandle
openRecording path = do
    createDirectoryIfMissing True (takeDirectory path)
    handle <- openFile path AppendMode
    pure
        RecordingHandle
            { recordingPath = path
            , recordingOutputHandle = handle
            }

-- | Close an active recording handle.
closeRecording :: RecordingHandle -> IO ()
closeRecording = hClose . recordingOutputHandle

-- | Append one JSON-RPC line event to a recording file.
appendRecordingEvent :: RecordingHandle -> RecordingDirection -> Text -> IO ()
appendRecordingEvent handle direction lineValue = do
    micros <- nowMicros
    let event =
            RecordingEvent
                { recordingTimestampMicros = micros
                , recordingDirection = direction
                , recordingLine = lineValue
                }
    hPutStrLn (recordingOutputHandle handle) (toJsonLine event)
    hFlush (recordingOutputHandle handle)

{- | Stream replay of request events directly from a JSONL recording file,
avoiding loading all events into memory at once. Each request line is
parsed and dispatched to the callback one at a time. Returns the total
number of request events replayed.
-}
streamReplayRequests :: ReplaySpeed -> FilePath -> (Text -> IO ()) -> IO Int
streamReplayRequests speed path runRequest =
    streamFilteredEvents speed path DirectionRequest runRequest

{- | Stream replay of frame events from a JSONL recording file. Handles
both full keyframes (@frame@) and delta frames (@frame-delta@),
reconstructing full viewport text before dispatching to the callback.
Request events are also tracked; the callback receives the last input
label (if any) alongside each frame. Returns the total number of frames
replayed.
-}
streamReplayFrames :: ReplaySpeed -> FilePath -> (Text -> Maybe Text -> IO ()) -> IO Int
streamReplayFrames speed path showFrame =
    bracket (openFile path ReadMode) hClose $ \fileHandle -> do
        currentLinesRef <- newIORef ([] :: [Text])
        lastInputRef <- newIORef (Nothing :: Maybe Text)
        go fileHandle currentLinesRef lastInputRef (1 :: Int) Nothing 0
  where
    go fileHandle currentLinesRef lastInputRef lineNumber maybePrev !count = do
        eof <- hIsEOF fileHandle
        if eof
            then pure count
            else do
                lineValue <- BS8.hGetLine fileHandle
                let trimmed = TE.decodeUtf8 lineValue
                if T.null (T.strip trimmed)
                    then go fileHandle currentLinesRef lastInputRef (lineNumber + 1) maybePrev count
                    else case eitherDecodeStrict' lineValue of
                        Left err ->
                            throwIO
                                (RecordingParseError path ("line " <> show lineNumber <> ": " <> err))
                        Right event
                            | recordingDirection event == DirectionRequest -> do
                                let label = extractInputLabel (recordingLine event)
                                case label of
                                    Just _ -> writeIORef lastInputRef label
                                    Nothing -> pure ()
                                go fileHandle currentLinesRef lastInputRef (lineNumber + 1) maybePrev count
                            | recordingDirection event == DirectionFrame -> do
                                applyReplayDelay speed maybePrev event
                                let frameLines = T.splitOn "\n" (recordingLine event)
                                writeIORef currentLinesRef frameLines
                                lastInput <- readIORef lastInputRef
                                showFrame (recordingLine event) lastInput
                                go fileHandle currentLinesRef lastInputRef (lineNumber + 1) (Just event) (count + 1)
                            | recordingDirection event == DirectionFrameDelta -> do
                                applyReplayDelay speed maybePrev event
                                baseLines <- readIORef currentLinesRef
                                let updatedLines = applyDelta baseLines (recordingLine event)
                                writeIORef currentLinesRef updatedLines
                                lastInput <- readIORef lastInputRef
                                showFrame (T.intercalate "\n" updatedLines) lastInput
                                go fileHandle currentLinesRef lastInputRef (lineNumber + 1) (Just event) (count + 1)
                            | otherwise ->
                                go fileHandle currentLinesRef lastInputRef (lineNumber + 1) maybePrev count

-- | Internal: stream events matching a given direction from a JSONL file.
streamFilteredEvents :: ReplaySpeed -> FilePath -> RecordingDirection -> (Text -> IO ()) -> IO Int
streamFilteredEvents speed path direction callback =
    bracket (openFile path ReadMode) hClose $ \fileHandle ->
        go fileHandle (1 :: Int) Nothing 0
  where
    go fileHandle lineNumber maybePrev !count = do
        eof <- hIsEOF fileHandle
        if eof
            then pure count
            else do
                lineValue <- BS8.hGetLine fileHandle
                let trimmed = TE.decodeUtf8 lineValue
                if T.null (T.strip trimmed)
                    then go fileHandle (lineNumber + 1) maybePrev count
                    else case eitherDecodeStrict' lineValue of
                        Left err ->
                            throwIO
                                (RecordingParseError path ("line " <> show lineNumber <> ": " <> err))
                        Right event
                            | recordingDirection event == direction -> do
                                applyReplayDelay speed maybePrev event
                                callback (recordingLine event)
                                go fileHandle (lineNumber + 1) (Just event) (count + 1)
                            | otherwise ->
                                go fileHandle (lineNumber + 1) maybePrev count

-- | Apply inter-event timing delay when in real-time replay mode.
applyReplayDelay :: ReplaySpeed -> Maybe RecordingEvent -> RecordingEvent -> IO ()
applyReplayDelay speed maybePrev event =
    case speed of
        ReplayAsFastAsPossible -> pure ()
        ReplayRealTime ->
            case maybePrev of
                Nothing -> pure ()
                Just prev -> do
                    let delta = recordingTimestampMicros event - recordingTimestampMicros prev
                    when (delta > 0) $ threadDelay (fromIntegral delta)

{- | Extract a human-readable input label from a JSON-RPC request line.
Returns @Just label@ for input methods (@sendKey@, @sendText@, @sendLine@),
@Nothing@ for non-input methods.
-}
extractInputLabel :: Text -> Maybe Text
extractInputLabel rawLine =
    case Aeson.eitherDecodeStrict' (TE.encodeUtf8 rawLine) of
        Left _ -> Nothing
        Right (Object obj) ->
            case KM.lookup "method" obj of
                Just (String method) -> extractFromMethod method obj
                _ -> Nothing
        Right _ -> Nothing
  where
    extractFromMethod method obj =
        case KM.lookup "params" obj of
            Just (Object params) ->
                case method of
                    "sendKey" ->
                        case KM.lookup "key" params of
                            Just (String k) -> Just ("Key: " <> k)
                            _ -> Nothing
                    "sendText" ->
                        case KM.lookup "text" params of
                            Just (String t) -> Just ("Text: " <> showTextValue t)
                            _ -> Nothing
                    "sendLine" ->
                        case KM.lookup "text" params of
                            Just (String t) -> Just ("Line: " <> t)
                            _ -> Nothing
                    _ -> Nothing
            _ -> Nothing
    showTextValue t
        | t == " " = "<Space>"
        | t == "\t" = "<Tab>"
        | otherwise = "\"" <> t <> "\""

{- | Compute a line-level delta between two viewport texts. Returns
@Nothing@ when the frames are identical, or @Just encodedDelta@ with a
JSON-encoded array of @[lineIndex, \"new line text\"]@ pairs for each
changed line.
-}
computeFrameDelta :: Text -> Text -> Maybe Text
computeFrameDelta oldFrame newFrame
    | oldFrame == newFrame = Nothing
    | otherwise =
        let oldLines = T.splitOn "\n" oldFrame
            newLines = T.splitOn "\n" newFrame
            changes = collectChanges 0 oldLines newLines
         in if null changes
                then Nothing
                else Just (encodeDelta changes)

-- | Collect (index, newLine) pairs for lines that differ.
collectChanges :: Int -> [Text] -> [Text] -> [(Int, Text)]
collectChanges !idx olds news =
    case (olds, news) of
        ([], []) -> []
        ([], n : ns) -> (idx, n) : collectChanges (idx + 1) [] ns
        (_ : os, []) -> (idx, "") : collectChanges (idx + 1) os []
        (o : os, n : ns)
            | o == n -> collectChanges (idx + 1) os ns
            | otherwise -> (idx, n) : collectChanges (idx + 1) os ns

-- | Encode a list of (lineIndex, text) changes as a JSON array of pairs.
encodeDelta :: [(Int, Text)] -> Text
encodeDelta changes =
    TE.decodeUtf8 . BL.toStrict . Aeson.encode $
        Array (listToAesonArray (map encodePair changes))
  where
    encodePair (idx, txt) =
        Array (listToAesonArray [Number (fromIntegral idx), String txt])

-- | Apply a JSON-encoded delta to a list of lines.
applyDelta :: [Text] -> Text -> [Text]
applyDelta baseLines deltaText =
    case Aeson.eitherDecodeStrict' (TE.encodeUtf8 deltaText) of
        Left _ -> baseLines
        Right (Array arr) ->
            let patches = foldr parsePatch IM.empty (aesonArrayToList arr)
                baseMap = IM.fromList (zip [0 ..] baseLines)
                maxIdx = if IM.null patches then 0 else fst (IM.findMax patches)
                paddedMap =
                    if maxIdx >= length baseLines
                        then IM.union baseMap (IM.fromList [(i, "") | i <- [length baseLines .. maxIdx]])
                        else baseMap
                merged = IM.union patches paddedMap
             in map snd (IM.toAscList merged)
        Right _ -> baseLines
  where
    parsePatch :: Value -> IntMap Text -> IntMap Text
    parsePatch (Array pair) acc =
        case aesonArrayToList pair of
            [Number n, String txt] -> IM.insert (floor n) txt acc
            _ -> acc
    parsePatch _ acc = acc

-- | Convert a Haskell list to an Aeson Array value.
listToAesonArray :: [Value] -> Aeson.Array
listToAesonArray = foldMap (\v -> pure v)

-- | Convert an Aeson Array to a Haskell list.
aesonArrayToList :: Aeson.Array -> [Value]
aesonArrayToList = foldr (:) []

toJsonLine :: RecordingEvent -> String
toJsonLine = T.unpack . TE.decodeUtf8 . BL.toStrict . encode

-- | A single reconstructed replay frame with full viewport text.
data ReplayFrame = ReplayFrame
    { replayFrameText :: Text
    , replayFrameInput :: Maybe Text
    , replayFrameTimestampMicros :: Int64
    , replayFrameIsKeyframe :: Bool
    -- ^ Whether this frame was a full keyframe (@True@) or reconstructed from a delta (@False@).
    , replayFrameDelta :: Maybe Text
    -- ^ Raw delta payload for non-keyframe frames, 'Nothing' for keyframes.
    }
    deriving (Eq, Show)

{- | Load all replay frames from a JSONL recording file into a list.
Delta frames are reconstructed into full viewport text so every element
contains the complete frame content ready for display.
-}
loadReplayFrames :: FilePath -> IO [ReplayFrame]
loadReplayFrames path =
    bracket (openFile path ReadMode) hClose $ \fileHandle -> do
        currentLinesRef <- newIORef ([] :: [Text])
        lastInputRef <- newIORef (Nothing :: Maybe Text)
        go fileHandle currentLinesRef lastInputRef (1 :: Int) []
  where
    go fileHandle currentLinesRef lastInputRef lineNumber !acc = do
        eof <- hIsEOF fileHandle
        if eof
            then pure (reverse acc)
            else do
                lineValue <- BS8.hGetLine fileHandle
                let trimmed = TE.decodeUtf8 lineValue
                if T.null (T.strip trimmed)
                    then go fileHandle currentLinesRef lastInputRef (lineNumber + 1) acc
                    else case eitherDecodeStrict' lineValue of
                        Left err ->
                            throwIO
                                (RecordingParseError path ("line " <> show lineNumber <> ": " <> err))
                        Right event
                            | recordingDirection event == DirectionRequest -> do
                                let label = extractInputLabel (recordingLine event)
                                case label of
                                    Just _ -> writeIORef lastInputRef label
                                    Nothing -> pure ()
                                go fileHandle currentLinesRef lastInputRef (lineNumber + 1) acc
                            | recordingDirection event == DirectionFrame -> do
                                let frameLines = T.splitOn "\n" (recordingLine event)
                                writeIORef currentLinesRef frameLines
                                lastInput <- readIORef lastInputRef
                                let frame =
                                        ReplayFrame
                                            { replayFrameText = recordingLine event
                                            , replayFrameInput = lastInput
                                            , replayFrameTimestampMicros = recordingTimestampMicros event
                                            , replayFrameIsKeyframe = True
                                            , replayFrameDelta = Nothing
                                            }
                                go fileHandle currentLinesRef lastInputRef (lineNumber + 1) (frame : acc)
                            | recordingDirection event == DirectionFrameDelta -> do
                                baseLines <- readIORef currentLinesRef
                                let updatedLines = applyDelta baseLines (recordingLine event)
                                writeIORef currentLinesRef updatedLines
                                lastInput <- readIORef lastInputRef
                                let fullText = T.intercalate "\n" updatedLines
                                let frame =
                                        ReplayFrame
                                            { replayFrameText = fullText
                                            , replayFrameInput = lastInput
                                            , replayFrameTimestampMicros = recordingTimestampMicros event
                                            , replayFrameIsKeyframe = False
                                            , replayFrameDelta = Just (recordingLine event)
                                            }
                                go fileHandle currentLinesRef lastInputRef (lineNumber + 1) (frame : acc)
                            | otherwise ->
                                go fileHandle currentLinesRef lastInputRef (lineNumber + 1) acc

nowMicros :: IO Int64
nowMicros = do
    now <- getPOSIXTime
    pure (floor (now * 1000000))