packages feed

sydtest-0.27.2.0: src/Test/Syd/MutationMode/Common.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}

-- | Utilities and types shared by the in-tree mutation child entry points
-- ('Test.Syd.MutationMode.Single', 'Test.Syd.MutationMode.SingleCoverage')
-- and the out-of-tree mutation driver (@sydtest-mutation-driver@).
--
-- Everything in this module is parent-vs-child neutral: it lives in
-- @sydtest@ because both the child entry points and the driver need it.
module Test.Syd.MutationMode.Common
  ( -- * Mutation outcomes
    MutationResult (..),
    SuiteOutcome (..),
    OutcomeTally (..),
    emptyOutcomeTally,
    tallyGroups,
    classifySyncExceptionAsKilled,
    retryingIO,
    isMutationFailure,
    mutationResultId,
    resultToOutcome,
    runOneGroup,

    -- * Exceptions
    MutationFailFast (..),
    CoverageFailFast (..),

    -- * Reporting
    renderMutationRunReport,
    renderMutationProgressEvent,
    renderUnifiedDiff,
    formatMutationLog,
    survivorMitigationLines,

    -- * Coverage progress events
    CoverageProgressEvent (..),
    CoverageProgressTestEvent (..),
    CoverageProgressPhase (..),
    CoverageProgressSkipReason (..),
    renderCoverageProgressEvent,
    emitCoverageEvent,

    -- * Timing utilities
    diffMonotonicMicros,
  )
where

import Control.Exception (Exception)
import qualified Control.Exception as Exception
import Data.List (intercalate)
import qualified Data.Text as T
import Data.Word (Word64)
import Path
import System.IO (stderr)
import Test.Syd.Mutation.AugmentedManifest
  ( AugmentedMutationRecord (..),
    ControlFailedMutation (..),
    ControlTally (..),
    MutationGroupReport (..),
    MutationOutcome (..),
    MutationProgressEvent (..),
    MutationRunReport (..),
    MutationTally (..),
    SkippedMutation (..),
    SurvivedMutation (..),
    TimedOutMutation (..),
    UncoveredMutation (..),
    mutationGroupReportOutcomes,
  )
import Test.Syd.Mutation.Manifest.Render (renderUnifiedDiff)
import Test.Syd.Mutation.Runtime (MutationId (..), renderMutationId)
import Test.Syd.Mutation.TestId (TestId, renderTestId)
import Test.Syd.OptParse (Settings, settingTerminalCapabilities)
import Text.Colour (Chunk, chunk, cyan, fore, green, hPutChunksUtf8With, red, unlinesChunks, yellow)

-- | Difference of two 'getMonotonicTimeNSec' readings expressed in
-- microseconds.  The monotonic clock is not affected by NTP slew or
-- step, so timing comparisons here are robust against system-clock
-- changes that 'getCurrentTime' would have observed.
diffMonotonicMicros :: Word64 -> Word64 -> Word
diffMonotonicMicros end start =
  -- 'getMonotonicTimeNSec' is monotonically non-decreasing, so @end >=
  -- start@ holds whenever they were measured in this order.  Guard
  -- with a defensive max anyway, in case a future change captures the
  -- two times across an unexpected boundary.
  fromIntegral ((max end start - start) `div` 1000)

data MutationResult
  = MutationUncovered UncoveredMutation
  | MutationKilled AugmentedMutationRecord
  | -- | At least one suite's child exceeded its monotonic-clock timeout. The
    -- mutation is counted as killed in the overall score but also recorded
    -- separately in the report for visibility.
    MutationTimedOut TimedOutMutation
  | MutationSurvived SurvivedMutation
  | -- | The mutation was not tested because an earlier mutation in the same
    -- group already failed (survived or was uncovered).  Within-group
    -- fail-fast records every remaining alternative as 'MutationSkipped'
    -- without spawning a child.
    MutationSkipped SkippedMutation
  | -- | A control (no-op) mutation survived, as it must: the control passed.
    -- A control changes no behaviour, so its survival confirms the harness
    -- reports a non-diff correctly.  Not counted in the killed\/survived score.
    MutationControlPassed AugmentedMutationRecord
  | -- | A control (no-op) mutation was killed: the control failed.  A no-op
    -- cannot be legitimately killed, so this means the mutation testing is
    -- unsound (a flaky\/nondeterministic suite or a harness bug).  Treated as a
    -- failure of the whole run, like a survivor.
    MutationControlFailed ControlFailedMutation
  deriving (Eq, Show)

-- | Per-suite outcome of running a single mutation child.
data SuiteOutcome
  = -- | Child exited non-zero — mutation killed by this suite.
    SuiteKilled
  | -- | Child exited zero — mutation survived in this suite. The optional
    -- log path points at the captured stdout/stderr (when a report dir is
    -- configured).
    SuiteSurvived (Maybe (Path Rel File))
  | -- | Child exceeded its monotonic-clock timeout and was terminated by the
    -- parent.  Carries the elapsed microseconds and (optionally) the
    -- log path.
    SuiteTimedOut Word (Maybe (Path Rel File))
  deriving (Eq, Show)

-- | Wrap a per-suite runner so that any synchronous exception escaping it is
-- treated as 'SuiteKilled'.  A mutation that makes the child process
-- unrunnable (e.g. 'BlockedIndefinitelyOnMVar' / @<<loop>>@ propagating from
-- 'waitExitCode', an 'IOException' from log-file IO, a parse failure on the
-- child's output) is conceptually indistinguishable from a mutation that
-- makes the child crash — both mean the test detected the mutation.
--
-- 'SomeAsyncException' is re-thrown so Ctrl-C and other cancellations still
-- propagate out of the runner.
classifySyncExceptionAsKilled :: IO SuiteOutcome -> IO SuiteOutcome
classifySyncExceptionAsKilled action =
  Exception.handle
    ( \(e :: Exception.SomeException) -> case Exception.fromException e of
        Just (_ :: Exception.SomeAsyncException) -> Exception.throwIO e
        Nothing -> pure SuiteKilled
    )
    action

-- | Retry an 'IO' action that produces a tagged failure reason.
--
-- @retryingIO retriesLeft onRetry action@ runs @action@; on 'Left' it calls
-- @onRetry@ with the reason and the number of retries remaining, then runs
-- @action@ again with a decremented counter.  When @retriesLeft@ reaches
-- zero, the final 'Left' is returned unchanged (so the caller can decide
-- how to surface the failure).
--
-- A @retriesLeft@ of 0 means "no retries, one attempt".  A @retriesLeft@ of
-- 3 means "up to 4 total attempts".
--
-- The action itself decides what counts as a transient failure: returning
-- 'Right' bypasses retry, returning 'Left' triggers it.
--
-- Synchronous exceptions thrown by @action@ are not caught here — they are
-- not, in the coverage-child use case, the kind of failure we want to
-- retry through.  Use 'Exception.handle' inside @action@ if you need to
-- convert exceptions to 'Left'.
retryingIO ::
  -- | Initial number of retries (0 = no retries; one attempt total).
  Word ->
  -- | Called once per retry, with the reason and the number of retries
  -- still remaining after the current attempt.
  (String -> Word -> IO ()) ->
  -- | The action to run.
  IO (Either String a) ->
  IO (Either String a)
retryingIO retriesLeft onRetry action = do
  result <- action
  case result of
    Right v -> pure (Right v)
    Left reason
      | retriesLeft > 0 -> do
          onRetry reason (retriesLeft - 1)
          retryingIO (retriesLeft - 1) onRetry action
      | otherwise -> pure (Left reason)

-- | Thrown inside a 'mapConcurrently' worker to abort the mutation run when
-- fail-fast is on and a surviving or uncovered mutation is observed.  The
-- sibling workers are cancelled by 'mapConcurrently' on the first exception.
data MutationFailFast = MutationFailFast
  deriving (Show)

instance Exception MutationFailFast

-- | Thrown inside a coverage 'mapConcurrently' worker to abort the run when
-- a coverage child reports that its baseline test failed (exit code 2) and
-- fail-fast is on.  Distinct from 'MutationFailFast' because the cause is
-- different — the suite was already red before any mutation ran, so the
-- mutation scores would be meaningless.
data CoverageFailFast = CoverageFailFast
  deriving (Show)

instance Exception CoverageFailFast

-- | A mutation result that should trip within-group fail-fast: the test
-- suite did not detect the mutation (survivor) or no test reaches the
-- mutation site (uncovered).  Timeouts count as killed, so they do not
-- trip; 'MutationSkipped' is itself a consequence of a prior failure and
-- does not trip again.
isMutationFailure :: MutationResult -> Bool
isMutationFailure = \case
  MutationSurvived _ -> True
  MutationUncovered _ -> True
  MutationKilled _ -> False
  MutationTimedOut _ -> False
  MutationSkipped _ -> False
  -- A passing control is the expected outcome, so it is not a failure (and must
  -- not trip fail-fast).  A failed control IS a failure: a no-op cannot be
  -- legitimately killed, and flaky tests are already retried, so it means the
  -- mutation testing is unsound - fail like a survivor.
  MutationControlPassed _ -> False
  MutationControlFailed _ -> True

-- | The 'MutationId' of the mutation that produced a failing result, used as
-- the @cause@ on 'SkippedMutation' entries for subsequent group members.
mutationResultId :: MutationResult -> Maybe MutationId
mutationResultId = \case
  MutationSurvived sm -> Just (augmentedMutationRecordId (survivedMutationRecord sm))
  MutationUncovered um -> Just (augmentedMutationRecordId (uncoveredMutationRecord um))
  _ -> Nothing

resultToOutcome :: MutationResult -> MutationOutcome
resultToOutcome = \case
  MutationKilled r -> OutcomeKilled r
  MutationSurvived sm -> OutcomeSurvived sm
  MutationTimedOut tm -> OutcomeTimedOut tm
  MutationUncovered um -> OutcomeUncovered um
  MutationSkipped sk -> OutcomeSkipped sk
  MutationControlPassed r -> OutcomeControlPassed r
  MutationControlFailed cf -> OutcomeControlFailed cf

-- | Per-outcome-kind counts produced by 'tallyGroups'.  Each 'OutcomeTimedOut'
-- bumps both 'tallyKilled' and 'tallyTimedOut' because a timed-out mutation is
-- treated as killed for scoring; 'tallyTimedOut' carries the separate count
-- for visibility.
data OutcomeTally = OutcomeTally
  { tallyKilled :: !Word,
    tallySurvived :: !Word,
    tallyTimedOut :: !Word,
    tallyUncovered :: !Word,
    tallySkipped :: !Word,
    -- | Control (no-op) mutations that survived as they must (controls passed).
    tallyControlPassed :: !Word,
    -- | Control (no-op) mutations that were killed (controls failed) - the
    -- mutation testing is unsound, and the run fails.
    tallyControlFailed :: !Word
  }

emptyOutcomeTally :: OutcomeTally
emptyOutcomeTally =
  OutcomeTally
    { tallyKilled = 0,
      tallySurvived = 0,
      tallyTimedOut = 0,
      tallyUncovered = 0,
      tallySkipped = 0,
      tallyControlPassed = 0,
      tallyControlFailed = 0
    }

tallyGroups :: [MutationGroupReport] -> OutcomeTally
tallyGroups = foldr (\(MutationGroupReport os) acc -> foldr step acc os) emptyOutcomeTally
  where
    step = \case
      OutcomeKilled _ -> \t -> t {tallyKilled = tallyKilled t + 1}
      OutcomeTimedOut _ -> \t -> t {tallyKilled = tallyKilled t + 1, tallyTimedOut = tallyTimedOut t + 1}
      OutcomeSurvived _ -> \t -> t {tallySurvived = tallySurvived t + 1}
      OutcomeUncovered _ -> \t -> t {tallyUncovered = tallyUncovered t + 1}
      OutcomeSkipped _ -> \t -> t {tallySkipped = tallySkipped t + 1}
      OutcomeControlPassed _ -> \t -> t {tallyControlPassed = tallyControlPassed t + 1}
      OutcomeControlFailed _ -> \t -> t {tallyControlFailed = tallyControlFailed t + 1}

-- | Run one mutation group: walk records sequentially, calling the supplied
-- @run@ for each record, and once one record's result is 'isMutationFailure'
-- record every remaining record as 'MutationSkipped' instead of running it.
--
-- Returns the results in source order.  When @globalFailFast@ is 'True' and
-- a non-skipped result is a failure, 'MutationFailFast' is thrown after the
-- group's results have been written to the accumulator so a partial report
-- can still be assembled by the caller.
runOneGroup ::
  -- | Whether to throw 'MutationFailFast' after the first failing result.
  Bool ->
  -- | How to run one mutation.  Receives the record being tested.
  (AugmentedMutationRecord -> IO MutationResult) ->
  -- | Called once per produced 'MutationResult' (in source order) so the
  -- caller can stream results into an accumulator.
  (MutationResult -> IO ()) ->
  -- | Records of this group in source order.
  [AugmentedMutationRecord] ->
  IO ()
runOneGroup globalFailFast runOne onResult = loop Nothing
  where
    loop _ [] = pure ()
    loop (Just causeMid) (rec : rest) = do
      let r = MutationSkipped (SkippedMutation rec causeMid)
      onResult r
      loop (Just causeMid) rest
    loop Nothing (rec : rest) = do
      r <- runOne rec
      onResult r
      let failFastNow = globalFailFast && isMutationFailure r
      if failFastNow
        then do
          -- Record every remaining record in this group as skipped so the
          -- partial report (assembled by the caller after catching
          -- 'MutationFailFast') reflects the entire group, not just the
          -- records processed before the abort.
          loop (mutationResultId r) rest
          Exception.throwIO MutationFailFast
        else loop (mutationResultId r) rest

renderMutationRunReport :: MutationRunReport -> [[Chunk]]
renderMutationRunReport MutationRunReport {..} =
  let MutationTally {..} = mutationRunReportMutations
      ControlTally {..} = mutationRunReportControls
   in [ [chunk "Killed: ", fore green (chunk (T.pack (show mutationTallyKilled)))],
        [chunk "  (of which timed out: ", fore yellow (chunk (T.pack (show mutationTallyTimedOut))), chunk ")"],
        [chunk "Survived: ", fore red (chunk (T.pack (show mutationTallySurvived)))],
        [chunk "Uncovered: ", fore yellow (chunk (T.pack (show mutationTallyUncovered)))],
        [chunk "Skipped: ", fore yellow (chunk (T.pack (show mutationTallySkipped)))]
      ]
        -- Control (no-op) lines only appear when controls ran, so runs without
        -- any control mutation render exactly as before.
        ++ ( if controlTallyPassed == 0 && controlTallyFailed == 0
               then []
               else
                 [ [chunk "Controls passed: ", fore green (chunk (T.pack (show controlTallyPassed)))],
                   [ chunk "Controls failed: ",
                     fore
                       (if controlTallyFailed == 0 then green else red)
                       (chunk (T.pack (show controlTallyFailed)))
                   ]
                 ]
           )
        ++ ( if null controlFaileds
               then []
               else
                 [[], [fore red (chunk "Failed controls (mutation testing may be unsound):")]]
                   ++ concatMap renderControlFailed controlFaileds
                   ++ [[], remediationHeader "A failed control is a no-op mutation that was killed.  It means:"]
                   ++ remediationControlBody
           )
        ++ ( if null timedOuts
               then []
               else
                 [[], [chunk "Timed-out mutations:"]]
                   ++ concatMap renderTimedOut timedOuts
           )
        ++ ( if null survivors
               then []
               else
                 [[], [chunk "Surviving mutations:"]]
                   ++ concatMap renderSurvivor survivors
                   ++ [[], remediationHeader "To resolve a surviving mutation:"]
                   ++ remediationSurvivorBody
           )
        ++ ( if null uncovereds
               then []
               else
                 [[], [chunk "Uncovered mutations:"]]
                   ++ concatMap renderUncovered uncovereds
                   ++ [[], remediationHeader "To resolve an uncovered mutation:"]
                   ++ remediationUncoveredBody
           )
        ++ ( if null skippeds
               then []
               else
                 [[], [chunk "Skipped mutations:"]]
                   ++ concatMap renderSkipped skippeds
           )
  where
    allOutcomes = concatMap mutationGroupReportOutcomes mutationRunReportGroups
    survivors = [s | OutcomeSurvived s <- allOutcomes]
    timedOuts = [t | OutcomeTimedOut t <- allOutcomes]
    uncovereds = [u | OutcomeUncovered u <- allOutcomes]
    skippeds = [sk | OutcomeSkipped sk <- allOutcomes]
    controlFaileds = [cf | OutcomeControlFailed cf <- allOutcomes]
    renderControlFailed cf =
      let rec = controlFailedMutationRecord cf
          mid = augmentedMutationRecordId rec
       in [] : formatMutationLog mid rec
    renderSurvivor sm =
      let rec = survivedMutationRecord sm
          mid = augmentedMutationRecordId rec
       in ([] : formatMutationLog mid rec) ++ survivorMitigationLines rec
    renderTimedOut tm =
      let rec = timedOutMutationRecord tm
          mid = augmentedMutationRecordId rec
          secs = fromIntegral (timedOutMutationElapsedMicros tm) / (1000000 :: Double)
          header =
            [ chunk "[timed out after ",
              fore yellow (chunk (T.pack (show secs))),
              chunk "s]"
            ]
       in [] : header : formatMutationLog mid rec
    renderUncovered um =
      let rec = uncoveredMutationRecord um
          mid = augmentedMutationRecordId rec
       in [] : formatMutationLog mid rec
    renderSkipped sk =
      let rec = skippedMutationRecord sk
          mid = augmentedMutationRecordId rec
          cause = skippedMutationCause sk
          header =
            [ chunk "[skipped - failed in same group: ",
              fore yellow (chunk (T.pack (renderMutationId cause))),
              chunk "]"
            ]
       in [] : header : formatMutationLog mid rec
    remediationHeader t = [fore cyan (chunk t)]
    -- Kept in sync with the disable-annotation syntax in
    -- sydtest-mutation-plugin (Test.Syd.Mutation.Plugin.Instrument:
    -- 'parseFunMutationAnns') and the global
    -- 'mutationPluginConfigDisabledMutations' / 'exceptions' fields in
    -- Test.Syd.Mutation.Plugin.OptParse.
    remediationSurvivorBody =
      [ [chunk "  1. Kill it: add or strengthen a test so the mutation causes a test failure."],
        [chunk "  2. Disable it on this binding:"],
        [chunk "       {-# ANN funName (\"DisableMutation: <Operator>\" :: String) #-}"],
        [chunk "     or for every operator on a binding:"],
        [chunk "       {-# ANN funName (\"DisableMutations\" :: String) #-}"],
        [chunk "     or for the whole module:"],
        [chunk "       {-# ANN module (\"DisableMutations\" :: String) #-}"],
        [chunk "     or globally in the plugin config (sydtest-mutation-plugin.yaml):"],
        [chunk "       disabled-mutations: [<Operator>]"]
      ]
    remediationUncoveredBody =
      [ [chunk "  1. Cover it: add a test that exercises the mutation site so the coverage phase records a covering test."],
        [chunk "  2. Disable it: same annotations and config keys as for survivors above."]
      ]
    remediationControlBody =
      [ [chunk "  - a test is flaky or nondeterministic (depends on time, ordering, randomness, or shared state), or"],
        [chunk "  - the mutation harness itself has a bug."],
        [chunk "  Flaky tests are already retried, so this fails the run like a survivor: fix the test or the harness."]
      ]

-- | Render the per-mutation progress line emitted as each mutation is
-- tested.  @index@\/@total@ are this mutation's 1-based position in the run
-- and the total mutation count, shown as a coloured @[X\/Y]@ counter.  The
-- @verbose@ flag controls detail: in concise mode (the default) this is the
-- single locating line @[X\/Y] Testing mutation \<operator\> at
-- \<file\>:\<line\>:\<cols\>@, so a long run shows steady progress — including
-- how far along it is — without flooding the log; in verbose\/debug mode the
-- full source diff of the mutation follows — the same block the report prints
-- for a survivor.
renderMutationProgressEvent :: Bool -> Int -> Int -> MutationProgressEvent -> [[Chunk]]
renderMutationProgressEvent verbose index total (MutationProgressEvent rec) =
  let logLines = formatMutationLog (augmentedMutationRecordId rec) rec
      -- A coloured @[X/Y]@ counter so the run shows its progress. X is
      -- right-padded to the width of Y so the "Testing mutation" text stays
      -- in a steady column as the count climbs.
      indexStr = show index
      totalStr = show total
      paddedIndex = replicate (length totalStr - length indexStr) ' ' ++ indexStr
      counter = fore cyan (chunk (T.pack (concat ["[", paddedIndex, "/", totalStr, "] "])))
      withPrefix = case logLines of
        [] -> [[counter, chunk "Testing mutation"]]
        (firstLine : rest) -> (counter : chunk "Testing mutation " : firstLine) : rest
   in if verbose then withPrefix else take 1 withPrefix

-- | Progress event for the coverage phase.
data CoverageProgressEvent
  = -- | Per-test event: emitted once when a coverage child for a test is
    -- about to run, and once when it has finished.
    CoverageProgressTest !CoverageProgressTestEvent
  | -- | Suite-level event: emitted once when the whole coverage phase is
    -- skipped because there is nothing to do.
    CoverageProgressSkipped !CoverageProgressSkipReason

data CoverageProgressTestEvent = CoverageProgressTestEvent
  { coverageProgressIndex :: !Int,
    coverageProgressTotal :: !Int,
    coverageProgressTestId :: !TestId,
    coverageProgressTestPhase :: !CoverageProgressPhase
  }

data CoverageProgressPhase
  = CoverageProgressStarting
  | -- | Number of mutations covered by this test.
    CoverageProgressDone !Int

-- | Why the coverage phase was skipped.
data CoverageProgressSkipReason
  = -- | The mutation manifest contained no records (every instrumentable
    -- module was disabled, e.g. via @{-# ANN module ("DisableMutations" ...) #-}@).
    CoverageSkipNoMutations
  | -- | The test spec produced no leaf tests, so there is nothing to run
    -- coverage on.
    CoverageSkipNoTests

renderCoverageProgressEvent :: CoverageProgressEvent -> [[Chunk]]
renderCoverageProgressEvent = \case
  CoverageProgressTest CoverageProgressTestEvent {coverageProgressIndex, coverageProgressTotal, coverageProgressTestId, coverageProgressTestPhase} ->
    let prefix =
          [ chunk "coverage (",
            chunk (T.pack (show coverageProgressIndex)),
            chunk "/",
            chunk (T.pack (show coverageProgressTotal)),
            chunk "): "
          ]
        tidChunk = chunk (renderTestId coverageProgressTestId)
     in case coverageProgressTestPhase of
          CoverageProgressStarting ->
            [prefix ++ [fore cyan (chunk "running "), tidChunk]]
          CoverageProgressDone n ->
            [ prefix
                ++ [ fore green (chunk "done "),
                     tidChunk,
                     chunk " (",
                     chunk (T.pack (show n)),
                     chunk " mutations)"
                   ]
            ]
  CoverageProgressSkipped reason ->
    [ [ fore yellow (chunk "coverage: skipped "),
        chunk $ case reason of
          CoverageSkipNoMutations -> "(no mutations in manifest)"
          CoverageSkipNoTests -> "(no tests in spec)"
      ]
    ]

emitCoverageEvent :: Settings -> CoverageProgressEvent -> IO ()
emitCoverageEvent settings ev =
  hPutChunksUtf8With
    (settingTerminalCapabilities settings)
    stderr
    (unlinesChunks (renderCoverageProgressEvent ev))

-- | Per-survivor mitigation guidance, appended under a surviving mutation in
-- the report: the exact disable annotation for this mutation (using its
-- operator and, when known, its enclosing binding) and any mitigation hint the
-- operator attached (e.g. an equivalent-mutant suppression).
survivorMitigationLines :: AugmentedMutationRecord -> [[Chunk]]
survivorMitigationLines rec = disableLine : mitigationLines
  where
    op = augmentedMutationRecordOperator rec
    disableLine = case augmentedMutationRecordBinding rec of
      Just b ->
        [ chunk "  disable: ",
          fore yellow (chunk (T.pack (concat ["{-# ANN ", T.unpack b, " (\"DisableMutation: ", T.unpack op, "\" :: String) #-}"])))
        ]
      Nothing ->
        [ chunk "  disable: add ",
          fore yellow (chunk op),
          chunk " to disabled-mutations in the plugin config"
        ]
    mitigationLines = case augmentedMutationRecordMitigation rec of
      Nothing -> []
      Just m -> [[chunk "  mitigation: ", chunk m]]

formatMutationLog :: MutationId -> AugmentedMutationRecord -> [[Chunk]]
formatMutationLog (MutationId parts) AugmentedMutationRecord {augmentedMutationRecordOperator, augmentedMutationRecordOriginal, augmentedMutationRecordReplacement, augmentedMutationRecordSourceLines, augmentedMutationRecordMutatedLines, augmentedMutationRecordSourceFile, augmentedMutationRecordLine, augmentedMutationRecordContextBefore, augmentedMutationRecordContextAfter} =
  case parts of
    (modName : _op : lineStr : colStartStr : colEndStr : _) ->
      let filePath = case augmentedMutationRecordSourceFile of
            Just p -> fromRelFile p
            Nothing -> moduleToFilePath modName
          -- Append "#<index>" so identical replStr alternatives (e.g. ListLit's
          -- drop-first and drop-last on a 3-element list, both "2 elements")
          -- are distinguishable in the human-readable header line.
          variantSuffix = case parts of
            [_, _, _, _, _, _, altIdx] -> " #" ++ altIdx
            _ -> ""
          headerText = T.pack $ T.unpack augmentedMutationRecordOperator ++ " at " ++ filePath ++ ":" ++ lineStr ++ ":" ++ colStartStr ++ "-" ++ colEndStr ++ variantSuffix
          headerLine = [chunk headerText]
       in case augmentedMutationRecordSourceLines of
            [] ->
              [ headerLine,
                [fore red (chunk ("    - " <> augmentedMutationRecordOriginal))],
                [fore green (chunk ("    + " <> augmentedMutationRecordReplacement))]
              ]
            _ ->
              headerLine : renderUnifiedDiff (fromIntegral augmentedMutationRecordLine) augmentedMutationRecordContextBefore augmentedMutationRecordSourceLines augmentedMutationRecordMutatedLines augmentedMutationRecordContextAfter
    _ ->
      [[chunk (T.pack $ intercalate "/" parts)]]
  where
    moduleToFilePath m = map (\c -> if c == '.' then '/' else c) m ++ ".hs"

-- 'renderUnifiedDiff' moved to 'Test.Syd.Mutation.Manifest.Render' so it
-- is shared with the plugin's @.txt@ manifest writer.