packages feed

tricorder-0.2.0.0: src/Tricorder/CLI/Operations.hs

module Tricorder.CLI.Operations
    ( showLog
    , showSource
    , showStatus
    , showTests
    , showEvalComments
    ) where

import Atelier.Effects.Clock (Clock, currentTimeZone)
import Atelier.Effects.Console (Console)
import Atelier.Effects.Delay (Delay)
import Atelier.Effects.Exit (Exit, exitFailure)
import Atelier.Effects.File (File)
import Atelier.Effects.FileSystem (FileSystem, doesFileExist, followFile, readFileLbs)
import Data.Aeson (encode)
import Data.Time.Format (defaultTimeLocale, formatTime)
import Data.Time.LocalTime (utcToLocalTime)
import Effectful.Reader.Static (Reader, ask)

import Atelier.Effects.Console qualified as Console
import Data.ByteString.Lazy qualified as BSL
import Data.Map.Strict qualified as Map
import Data.Text qualified as T

import Tricorder.Build (BuildState (..), Severity (..))
import Tricorder.Build.Test (Suites (..))
import Tricorder.CLI.Arguments
    ( EvalCommentsOptions (..)
    , FollowMode (..)
    , OutputFormat (..)
    , StatusOptions (..)
    , TestOptions (..)
    , Verbosity (..)
    , WaitMode (..)
    )
import Tricorder.CLI.Render
    ( diagnosticLineIndexed
    , formatDuration
    , renderSourceResults
    )
import Tricorder.Runtime (SocketPath (..))
import Tricorder.Session.TestTarget (renderTestTarget)
import Tricorder.Socket.Client (querySource, queryStatus, queryStatusWait)
import Tricorder.Socket.UnixSocket (UnixSocket)
import Tricorder.SourceLookup (SourceQuery)
import Tricorder.TestOutput (stripGhciNoise)

import Tricorder.Build qualified as Build
import Tricorder.Build.EvalComment qualified as Eval
import Tricorder.Build.Test qualified as Test
import Tricorder.Build.Test qualified as Tests


-- | Print a build-command failure message and exit non-zero.
reportBuildFailed :: (Console :> es, Exit :> es) => Text -> Eff es a
reportBuildFailed msg = do
    Console.putTextLn "Build command failed:"
    Console.putTextLn msg
    exitFailure


showStatus
    :: ( Clock :> es
       , Console :> es
       , Exit :> es
       , File :> es
       , Reader SocketPath :> es
       , UnixSocket :> es
       )
    => StatusOptions -> Eff es ()
showStatus opts = do
    when (opts.wait == WaitForBuild && opts.format == TextOutput) do
        displayPendingBuildStatus
    result <- awaitBuildStatus opts.wait
    case result of
        Left err -> Console.putTextLn $ "Error: " <> err
        Right state ->
            case opts.format of
                JsonOutput -> do
                    Console.putStr $ BSL.toStrict $ encode state
                    Console.putStrLn ""
                TextOutput ->
                    renderText opts.verbosity opts.expand state
  where
    renderText verbosity expand state = case state.phase of
        Build.Starting -> Console.putStrLn "Building..."
        Build.Building _ _ -> Console.putStrLn "Building..."
        Build.Failed msg -> reportBuildFailed msg
        Build.PostBuilding _ postBuild ->
            let
                testsRunning = Test.anyRunningTests postBuild.testSuites
                commentsEvaluating = Eval.phasePending postBuild.evalComments
            in
                if
                    | testsRunning && commentsEvaluating ->
                        Console.putStrLn "Testing and evaluating comments..."
                    | testsRunning ->
                        Console.putStrLn "Testing..."
                    | commentsEvaluating ->
                        Console.putStrLn "Evaluating comments..."
                    | otherwise ->
                        Console.putStrLn "Post-procesing..."
        Build.Finished result postBuild -> do
            tz <- currentTimeZone
            case expand of
                Just n ->
                    case result.diagnostics !!? (n - 1) of
                        Nothing ->
                            Console.putTextLn
                                $ "No diagnostic #"
                                    <> show n
                                    <> " (current build has "
                                    <> show (length result.diagnostics)
                                    <> ")"
                        Just d -> do
                            Console.putTextLn $ diagnosticLineIndexed n d
                            Console.putText d.text
                Nothing -> do
                    let printDiag (i, d) = case verbosity of
                            Verbose -> do
                                Console.putTextLn $ diagnosticLineIndexed i d
                                Console.putText d.text
                            Concise ->
                                Console.putTextLn $ diagnosticLineIndexed i d
                    mapM_ printDiag (zip [1 ..] result.diagnostics)
                    Console.putTextLn $ buildSummary tz result
                    mapM_ (uncurry (printTestRun verbosity)) $ Map.toList postBuild.testSuites.getSuites
                    when (buildHasErrors result || Test.hasFailedTests postBuild.testSuites) exitFailure

    printTestRun verbosity tgt tr = do
        Console.putTextLn $ case tr of
            Test.SuiteRunning Nothing -> t <> "  running..."
            Test.SuiteRunning (Just p) -> t <> "  running... (" <> show p.compiled <> "/" <> show p.total <> ")"
            Test.SuiteErrored e -> t <> "  error: " <> e.message
            Test.SuiteCompleted c -> t <> "  " <> completionSummary c
        when (verbosity == Verbose) $ case tr of
            Test.SuiteCompleted c ->
                mapM_ (Console.putTextLn . ("  " <>)) (stripGhciNoise (T.lines c.output))
            _ -> pure ()
      where
        t = renderTestTarget tgt

    buildHasErrors r = any ((== SError) . (.severity)) r.diagnostics
    buildSummary tz r =
        let errs = length $ filter ((== SError) . (.severity)) r.diagnostics
            warns = length $ filter ((== SWarning) . (.severity)) r.diagnostics
            ts = toText $ "— " <> formatTime defaultTimeLocale "%H:%M:%S" (utcToLocalTime tz r.completedAt)
            stats = toText $ "(" <> show r.moduleCount <> " modules, " <> formatDuration r.duration <> ")"
        in  if null r.diagnostics then
                "All good. " <> stats <> " " <> ts
            else
                show errs <> " error(s), " <> show warns <> " warning(s) " <> stats <> " " <> ts


completionSummary :: Test.SuiteCompletion -> Text
completionSummary c = statusText <> maybe "" (\d -> " (" <> formatDuration d <> ")") c.duration
  where
    statusText
        | null c.testCases = if c.passed then "passed" else "failed"
        | otherwise =
            let total = length c.testCases
                failedCount = length $ filter isFailedCase c.testCases
            in  if failedCount == 0 then
                    "passed (" <> show total <> ")"
                else
                    show failedCount <> "/" <> show total <> " failed"
    isFailedCase (Test.Case _ (Test.Failed _)) = True
    isFailedCase _ = False


showLog
    :: ( Console :> es
       , Delay :> es
       , FileSystem :> es
       )
    => FilePath -> FollowMode -> Eff es ()
showLog path followMode = do
    exists <- doesFileExist path
    if not exists then
        Console.putTextLn $ "Log file does not exist yet: " <> toText path
    else case followMode of
        Follow -> followFile path Console.putStr
        NoFollow -> readFileLbs path >>= Console.putStr . BSL.toStrict


showTests
    :: ( Console :> es
       , Exit :> es
       , File :> es
       , Reader SocketPath :> es
       , UnixSocket :> es
       )
    => TestOptions -> Eff es ()
showTests opts = do
    result <- awaitBuildStatus opts.wait
    case result of
        Left err -> Console.putTextLn $ "Error: " <> err
        Right state ->
            case state.phase of
                Build.Starting -> Console.putStrLn "Daemon starting, no test results yet."
                Build.Building _ _ -> Console.putStrLn "Build in progress, no test results yet."
                Build.PostBuilding _ postBuild -> renderTestRuns postBuild.testSuites.getSuites
                Build.Finished _ postBuild -> renderTestRuns postBuild.testSuites.getSuites
                Build.Failed msg -> reportBuildFailed msg
  where
    renderTestRuns suites
        | Map.null suites = Console.putStrLn "No test results."
        | Map.null filteredSuites = do
            Console.putStrLn "All passed."
            mapM_ (Console.putTextLn . ("  " <>) . renderTestTarget) $ Map.keys suites
        | otherwise = do
            mapM_ (uncurry printTestOutput) $ Map.toList filteredSuites
            when (any Test.isFailedRun filteredSuites) exitFailure
      where
        filteredSuites =
            if opts.failedOnly then
                Map.filter Test.isFailedRun suites
            else
                suites

    printTestOutput tgt tr = case tr of
        Test.SuiteRunning Nothing ->
            Console.putTextLn $ t <> "running..."
        Test.SuiteRunning (Just p) ->
            Console.putTextLn $ t <> "running... (" <> show p.compiled <> "/" <> show p.total <> ")"
        Test.SuiteErrored e ->
            Console.putTextLn $ t <> "error: " <> e.message
        Test.SuiteCompleted c -> do
            Console.putTextLn $ t <> completionSummary c
            if opts.failedOnly then
                if null c.testCases then do
                    Console.putTextLn "  (unrecognised test runner format — showing full output)"
                    mapM_ (Console.putTextLn . ("  " <>)) (stripGhciNoise (lines c.output))
                else
                    mapM_ printFailedCase (filter Test.caseFailed c.testCases)
            else
                mapM_ (Console.putTextLn . ("  " <>)) (stripGhciNoise (lines c.output))
      where
        t = renderTestTarget tgt <> "  "

    printFailedCase tc = do
        Console.putTextLn $ "  " <> tc.description
        case tc.outcome of
            Test.Failed details ->
                mapM_ (Console.putTextLn . ("    " <>)) (T.lines details)
            Test.Passed -> pure ()


showSource
    :: ( Console :> es
       , File :> es
       , Reader SocketPath :> es
       , UnixSocket :> es
       )
    => [SourceQuery]
    -> Eff es ()
showSource queries = do
    SocketPath sockPath <- ask
    result <- querySource sockPath queries
    case result of
        Left err -> Console.putTextLn $ "Error: " <> err
        Right results -> renderSourceResults results


showEvalComments
    :: ( Console :> es
       , Exit :> es
       , File :> es
       , Reader SocketPath :> es
       , UnixSocket :> es
       )
    => EvalCommentsOptions -> Eff es ()
showEvalComments opts = do
    when (opts.wait == WaitForBuild && opts.format == TextOutput) do
        displayPendingBuildStatus
    result <- awaitBuildStatus opts.wait
    case opts.format of
        TextOutput -> displayTextOutput result
        JsonOutput -> displayJsonOutput result


displayTextOutput
    :: ( Console :> es
       , Exit :> es
       )
    => Either Text BuildState -> Eff es ()
displayTextOutput = \case
    Left err -> do
        Console.putTextLn $ "Error: " <> err
        exitFailure
    Right (BuildState _ phase _) -> case phase of
        Build.Starting -> Console.putStrLn "Starting..."
        Build.Building _ _ -> Console.putStrLn "Building..."
        Build.PostBuilding _ postBuild -> txtEvalComments postBuild
        Build.Finished _ postBuild -> txtEvalComments postBuild
        Build.Failed err -> do
            Console.putTextLn $ "Error: " <> err
            exitFailure
  where
    txtEvalComments postBuild =
        let evalutatingComments = Eval.phasePending postBuild.evalComments
        in  if
                | Tests.anyRunningTests postBuild.testSuites && evalutatingComments ->
                    Console.putStrLn "Testing and evaluating comments..."
                | Tests.anyRunningTests postBuild.testSuites -> Console.putStrLn "Testing..."
                | evalutatingComments -> Console.putStrLn "Evaluating comments..."
                | otherwise -> case postBuild.evalComments of
                    Eval.Looking -> Console.putStrLn "Looking for eval comments..."
                    Eval.NoneFound -> Console.putStrLn "No eval comments found"
                    Eval.Found comments
                        | Eval.anyRunningComments comments -> Console.putStrLn "Evaluating comments..."
                        | otherwise ->
                            Console.putTextLn
                                . T.intercalate "\n\n"
                                . toList
                                $ showEvalRun <$> comments.getComments
    showEvalRun run =
        T.intercalate
            "\n"
            [ showEvalRunInfo run
            , showEvalState run
            ]
    showEvalRunInfo evaluation =
        T.intercalate
            "\n"
            [ toText evaluation.file <> ":" <> show evaluation.comment.lineNumber
            , "Expression:"
            , evaluation.comment.expression
            ]
    showEvalState evaluation =
        case evaluation.state of
            Eval.Pending -> "Pending..."
            Eval.Completed output ->
                T.intercalate
                    "\n"
                    [ "Output:"
                    , output
                    ]


displayJsonOutput :: (Console :> es, Exit :> es) => Either Text BuildState -> Eff es ()
displayJsonOutput = \case
    Left err -> do
        putJson $ Eval.Failed err
        exitFailure
    Right (BuildState _ phase _) -> case phase of
        Build.Starting -> putJson Eval.Starting
        Build.Building _ _ -> putJson Eval.Building
        Build.Failed msg -> do
            putJson $ Eval.Failed msg
            exitFailure
        Build.PostBuilding _ postBuild -> jsonEvalComments postBuild
        Build.Finished _ postBuild -> jsonEvalComments postBuild
  where
    jsonEvalComments postBuild = case postBuild.evalComments of
        Eval.Looking -> putJson Eval.Evaluating
        Eval.NoneFound -> putJson $ Eval.NoEvalCommentsFound
        Eval.Found comments
            | Eval.anyRunningComments comments -> putJson Eval.Evaluating
            | otherwise -> putJson $ Eval.Done comments
    putJson = Console.putStrLn . toStrict . encode


displayPendingBuildStatus
    :: ( Console :> es
       , File :> es
       , Reader SocketPath :> es
       , UnixSocket :> es
       )
    => Eff es ()
displayPendingBuildStatus = do
    SocketPath sockPath <- ask
    current <- queryStatus sockPath
    case current of
        Right BuildState {phase = Build.Starting} -> Console.putStrLn "Starting..."
        Right BuildState {phase = Build.Building _ _} -> Console.putStrLn "Building..."
        Right BuildState {phase = Build.Finished _ postBuild} ->
            displayPostBuildStatus postBuild
        Right BuildState {phase = Build.PostBuilding _ postBuild} ->
            displayPostBuildStatus postBuild
        Right BuildState {phase = Build.Failed _} -> pure ()
        Left _ -> pure ()
  where
    displayPostBuildStatus postBuild
        | Tests.anyRunningTests postBuild.testSuites && evaluatingComments =
            Console.putStrLn "Testing and evaluating comments..."
        | Tests.anyRunningTests postBuild.testSuites =
            Console.putStrLn "Testing..."
        | evaluatingComments =
            Console.putStrLn "Evaluating comments..."
        | otherwise = pure ()
      where
        evaluatingComments = Eval.phasePending postBuild.evalComments


awaitBuildStatus
    :: ( File :> es
       , Reader SocketPath :> es
       , UnixSocket :> es
       )
    => WaitMode -> Eff es (Either Text BuildState)
awaitBuildStatus wait = do
    SocketPath sockPath <- ask
    case wait of
        WaitForBuild -> queryStatusWait sockPath
        ShowCurrent -> queryStatus sockPath