packages feed

sydtest-mutation-runtime-0.1.0.0: src/Test/Syd/Mutation/AugmentedManifest.hs

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingVia #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Test.Syd.Mutation.AugmentedManifest
  ( AugmentedMutationRecord (..),
    AugmentedMutationGroup (..),
    AugmentedManifest (..),
    mergeAugmentedManifests,
    readAndUnionCoverageDirs,
    readAndUnionBaselineDirs,
    filterAugmentedManifestByIds,
    writeAugmentedManifestFile,
    readAugmentedManifestFile,
    readAugmentedManifestFileIfExists,
    lookupAugmentedMutationRecord,
    fromMutationRecord,
    defaultTimeoutMicros,
    SurvivedMutation (..),
    TimedOutMutation (..),
    UncoveredMutation (..),
    SkippedMutation (..),
    ControlFailedMutation (..),
    MutationOutcome (..),
    MutationGroupReport (..),
    MutationTally (..),
    ControlTally (..),
    MutationRunReport (..),
    writeMutationRunReport,
    readMutationRunReport,
    MutationRunReportDecodeException (..),
    MutationProgressEvent (..),
  )
where

import Autodocodec
import Control.Exception (Exception, throwIO)
import qualified Data.Aeson as Aeson
import qualified Data.ByteString as SB
import qualified Data.ByteString.Lazy as LB
import Data.GenValidity
import Data.GenValidity.Map ()
import Data.GenValidity.Path ()
import Data.GenValidity.Text ()
import qualified Data.HashMap.Strict as HashMap
import qualified Data.Map.Strict as Map
import Data.Maybe (fromMaybe)
import qualified Data.Set as Set
import Data.Text (Text)
import GHC.Generics (Generic)
import Path
import Path.IO (ensureDir, forgivingAbsence)
import Test.Syd.Mutation.Manifest (MutationRecord (..), relFileCodec)
import Test.Syd.Mutation.Runtime (MutationId (..))
import Test.Syd.Mutation.TestBaselineMap (TestBaselineMap, readTestBaselineMapDirIfExists)
import Test.Syd.Mutation.TestId (TestId)

-- | A mutation record augmented with coverage data.
-- Unlike 'MutationRecord', covering tests are always present (never Nothing).
data AugmentedMutationRecord = AugmentedMutationRecord
  { augmentedMutationRecordId :: MutationId,
    augmentedMutationRecordOperator :: Text,
    augmentedMutationRecordOriginal :: Text,
    augmentedMutationRecordReplacement :: Text,
    augmentedMutationRecordModule :: Text,
    augmentedMutationRecordLine :: Word,
    augmentedMutationRecordEndLine :: Word,
    augmentedMutationRecordColStart :: Word,
    augmentedMutationRecordColEnd :: Word,
    augmentedMutationRecordSourceFile :: Maybe (Path Rel File),
    augmentedMutationRecordSourceLines :: [Text],
    augmentedMutationRecordMutatedLines :: [Text],
    augmentedMutationRecordContextBefore :: [Text],
    augmentedMutationRecordContextAfter :: [Text],
    -- | Tests whose execution reaches this mutation site, keyed by test suite
    -- name.  The empty string @""@ is used for anonymous\/single-suite setups
    -- (backward-compatible with the old flat list format).
    -- Always present (coverage was collected before writing this file).
    augmentedMutationRecordCoveringTests :: Map.Map Text [TestId],
    -- | Monotonic-clock timeout (in microseconds) to apply to a mutation child
    -- running this mutation.  Derived during the coverage phase as
    -- @max 30_000_000 (10 * sum baselines_of_covering_tests)@.
    augmentedMutationRecordTimeoutMicros :: Word,
    -- | Source name of the enclosing top-level binding, if known.  Carried
    -- through from 'mutRecBinding' so the report can suggest the exact
    -- @{-# ANN \<binding\> ... #-}@ disable annotation.
    augmentedMutationRecordBinding :: Maybe Text,
    -- | Optional mitigation hint, carried through from 'mutRecMitigation'.
    augmentedMutationRecordMitigation :: Maybe Text
  }
  deriving stock (Show, Eq, Generic)
  deriving (Aeson.ToJSON, Aeson.FromJSON) via (Autodocodec AugmentedMutationRecord)

-- | Codec for 'Map Text [TestId]': a JSON object keyed by suite name.
coveringTestsCodec :: JSONCodec (Map.Map Text [TestId])
coveringTestsCodec = codec

instance HasCodec AugmentedMutationRecord where
  codec =
    object "AugmentedMutationRecord" $
      AugmentedMutationRecord
        <$> requiredField' "id" .= augmentedMutationRecordId
        <*> requiredField' "operator" .= augmentedMutationRecordOperator
        <*> requiredField' "original" .= augmentedMutationRecordOriginal
        <*> requiredField' "replacement" .= augmentedMutationRecordReplacement
        <*> requiredField' "module" .= augmentedMutationRecordModule
        <*> requiredField' "line" .= augmentedMutationRecordLine
        <*> optionalFieldWithDefault' "end_line" 0 .= augmentedMutationRecordEndLine
        <*> requiredField' "col_start" .= augmentedMutationRecordColStart
        <*> requiredField' "col_end" .= augmentedMutationRecordColEnd
        <*> optionalFieldWith' "source_file" relFileCodec .= augmentedMutationRecordSourceFile
        <*> optionalFieldWithDefault' "source_lines" [] .= augmentedMutationRecordSourceLines
        <*> optionalFieldWithDefault' "mutated_lines" [] .= augmentedMutationRecordMutatedLines
        <*> optionalFieldWithDefault' "context_before" [] .= augmentedMutationRecordContextBefore
        <*> optionalFieldWithDefault' "context_after" [] .= augmentedMutationRecordContextAfter
        <*> optionalFieldWithDefaultWith' "covering_tests" coveringTestsCodec Map.empty .= augmentedMutationRecordCoveringTests
        <*> requiredField' "timeout_micros" .= augmentedMutationRecordTimeoutMicros
        <*> optionalField' "binding" .= augmentedMutationRecordBinding
        <*> optionalField' "mitigation" .= augmentedMutationRecordMitigation

instance Validity AugmentedMutationRecord

instance GenValid AugmentedMutationRecord where
  genValid = genValidStructurally
  shrinkValid = shrinkValidStructurally

-- | A group of augmented mutation records sharing one operator-at-one-location
-- origin.  The runner walks groups concurrently and walks records within a
-- group sequentially; the first failing record in a group skips the
-- remaining records (within-group fail-fast).
newtype AugmentedMutationGroup = AugmentedMutationGroup [AugmentedMutationRecord]
  deriving stock (Show, Eq, Generic)
  deriving (Aeson.ToJSON, Aeson.FromJSON) via (Autodocodec AugmentedMutationGroup)

instance Validity AugmentedMutationGroup

instance GenValid AugmentedMutationGroup where
  genValid = genValidStructurally
  shrinkValid = shrinkValidStructurally

instance HasCodec AugmentedMutationGroup where
  codec = dimapCodec AugmentedMutationGroup (\(AugmentedMutationGroup rs) -> rs) codec

newtype AugmentedManifest = AugmentedManifest [AugmentedMutationGroup]
  deriving stock (Show, Eq, Generic)
  deriving (Aeson.ToJSON, Aeson.FromJSON) via (Autodocodec AugmentedManifest)

instance Validity AugmentedManifest

instance GenValid AugmentedManifest where
  genValid = genValidStructurally
  shrinkValid = shrinkValidStructurally

instance HasCodec AugmentedManifest where
  codec = dimapCodec AugmentedManifest (\(AugmentedManifest gs) -> gs) codec

instance Semigroup AugmentedManifest where
  AugmentedManifest a <> AugmentedManifest b = AugmentedManifest (a <> b)

instance Monoid AugmentedManifest where
  mempty = AugmentedManifest []

-- | Keep only the mutation records whose id is in the given set, dropping any
-- group that ends up empty.  Group order and within-group order are
-- preserved, so the filtered manifest runs the selected mutations in the same
-- order a full run would.
filterAugmentedManifestByIds :: Set.Set MutationId -> AugmentedManifest -> AugmentedManifest
filterAugmentedManifestByIds ids (AugmentedManifest groups) =
  AugmentedManifest
    [ AugmentedMutationGroup kept
    | AugmentedMutationGroup recs <- groups,
      let kept = filter ((`Set.member` ids) . augmentedMutationRecordId) recs,
      not (null kept)
    ]

augmentedManifestRelFile :: Path Rel File
augmentedManifestRelFile = [relfile|manifest-augmented.json|]

-- | 30 seconds, used as the floor for per-mutation monotonic-clock budgets and as
-- the initial value when a record is constructed without baseline timing
-- (i.e. before the coverage phase has annotated it).  The actual budget is
-- @max defaultTimeoutMicros (10 * sum baselines_of_covering_tests)@.
defaultTimeoutMicros :: Word
defaultTimeoutMicros = 30_000_000

-- | Write to @<dir>/manifest-augmented.json@.
writeAugmentedManifestFile :: Path Abs Dir -> AugmentedManifest -> IO ()
writeAugmentedManifestFile dir manifest = do
  ensureDir dir
  LB.writeFile (fromAbsFile (dir </> augmentedManifestRelFile)) (Aeson.encode manifest)

-- | Thrown by 'readAugmentedManifestFile' when the file cannot be decoded.
newtype AugmentedManifestDecodeException
  = AugmentedManifestDecodeException FilePath
  deriving (Show)

instance Exception AugmentedManifestDecodeException

-- | Read from @<dir>/manifest-augmented.json@.
--
-- Reads strictly (via 'SB.readFile' + 'Aeson.decodeStrict') so the file
-- handle is closed before this function returns.  This is a defensive
-- measure: under heavy concurrency the original 'LB.readFile' +
-- 'Aeson.decode' path was a suspected (but unproven) contributor to a
-- non-deterministic 'BlockedIndefinitelyOnMVar' / @<<loop>>@ at the
-- coverage/mutation phase boundary on large projects.
readAugmentedManifestFile :: Path Abs Dir -> IO AugmentedManifest
readAugmentedManifestFile dir = do
  let path = dir </> augmentedManifestRelFile
  result <- Aeson.decodeStrict <$> SB.readFile (fromAbsFile path)
  case result of
    Nothing -> throwIO (AugmentedManifestDecodeException (fromAbsFile path))
    Just m -> pure m

-- | Read from @<dir>/manifest-augmented.json@, returning 'Nothing' if the file
-- does not exist.
readAugmentedManifestFileIfExists :: Path Abs Dir -> IO (Maybe AugmentedManifest)
readAugmentedManifestFileIfExists dir =
  forgivingAbsence (readAugmentedManifestFile dir)

-- | Merge two 'AugmentedManifest's, combining 'covering_tests' maps by
-- mutation id.  Records present only in one manifest are kept as-is.
-- Group structure is preserved: a record in 'new' is matched into the base
-- group whose first record (by id lookup) it shares.  Groups present only in
-- 'new' are appended.
mergeAugmentedManifests :: AugmentedManifest -> AugmentedManifest -> AugmentedManifest
mergeAugmentedManifests (AugmentedManifest base) (AugmentedManifest new) =
  AugmentedManifest (map mergeGroup base ++ newOnlyGroups)
  where
    newRecsById :: Map.Map MutationId AugmentedMutationRecord
    newRecsById =
      Map.fromList
        [ (augmentedMutationRecordId r, r)
        | AugmentedMutationGroup rs <- new,
          r <- rs
        ]
    baseIds :: Map.Map MutationId ()
    baseIds =
      Map.fromList
        [ (augmentedMutationRecordId r, ())
        | AugmentedMutationGroup rs <- base,
          r <- rs
        ]
    newOnlyGroups =
      [ AugmentedMutationGroup keptRecs
      | AugmentedMutationGroup rs <- new,
        let keptRecs =
              filter
                (\r -> Map.notMember (augmentedMutationRecordId r) baseIds)
                rs,
        not (null keptRecs)
      ]
    mergeGroup (AugmentedMutationGroup rs) =
      AugmentedMutationGroup (map mergeRecord rs)
    mergeRecord r =
      case Map.lookup (augmentedMutationRecordId r) newRecsById of
        Nothing -> r
        Just r' ->
          r
            { augmentedMutationRecordCoveringTests =
                Map.unionWith
                  mergeCoveringTests
                  (augmentedMutationRecordCoveringTests r)
                  (augmentedMutationRecordCoveringTests r'),
              -- Take the larger of the two timeouts so a generously-budgeted
              -- suite is not penalised when merged with a stricter one.
              augmentedMutationRecordTimeoutMicros =
                max
                  (augmentedMutationRecordTimeoutMicros r)
                  (augmentedMutationRecordTimeoutMicros r')
            }
    -- Concatenate covering-test lists from two manifests, but drop any
    -- 'TestId' that already appears in the base list.  Treating the lists
    -- as sets makes the merge idempotent: @mergeAugmentedManifests m m@
    -- equals @m@.
    mergeCoveringTests baseTids newTids =
      let baseSet = Set.fromList baseTids
       in baseTids ++ filter (`Set.notMember` baseSet) newTids

-- | Read and union the augmented manifests from a list of per-package coverage
-- directories.  Each directory is a @coverage@-subcommand output holding
-- @augmented/manifest-augmented.json@; unioning them with
-- 'mergeAugmentedManifests' combines the per-suite @covering_tests@ so a
-- mutation covered by a test in any package is recorded — including
-- cross-package coverage (a suite in one package covering another package's
-- mutation).  The @run@ subcommand (per-library report) and the @diff@
-- subcommand both consume the per-package coverage this way.
readAndUnionCoverageDirs :: [Path Abs Dir] -> IO AugmentedManifest
readAndUnionCoverageDirs coverageDirs =
  foldl' mergeAugmentedManifests mempty
    <$> mapM (\dir -> readAugmentedManifestFile (dir </> [reldir|augmented|])) coverageDirs

-- | Read and union the per-test baseline timings from the same per-package
-- coverage directories 'readAndUnionCoverageDirs' consumes.  Each holds an
-- @augmented/baseline.json@ (absent on older coverage output, treated as empty);
-- the union merges by taking the slowest recorded time for each test.  Used to
-- order a mutation child's covering tests cheapest-first.
--
-- The slowest-wins merge is 'TestBaselineMap'\'s 'Semigroup', chosen so a
-- per-mutation timeout is never under-budgeted.  Reusing it here means a test
-- shared across suites is ordered by its slowest recorded time, which only
-- affects ordering quality, never which tests run or the verdict.
readAndUnionBaselineDirs :: [Path Abs Dir] -> IO TestBaselineMap
readAndUnionBaselineDirs coverageDirs =
  mconcat . map (fromMaybe mempty)
    <$> mapM (\dir -> readTestBaselineMapDirIfExists (dir </> [reldir|augmented|])) coverageDirs

-- | O(n) lookup by 'MutationId' across every group.
lookupAugmentedMutationRecord :: MutationId -> AugmentedManifest -> Maybe AugmentedMutationRecord
lookupAugmentedMutationRecord mid (AugmentedManifest groups) =
  case [r | AugmentedMutationGroup rs <- groups, r <- rs, augmentedMutationRecordId r == mid] of
    (r : _) -> Just r
    [] -> Nothing

-- | A survived mutation with an optional pointer to the raw child output file.
data SurvivedMutation = SurvivedMutation
  { survivedMutationRecord :: AugmentedMutationRecord,
    -- | Path to the raw child output file, relative to the report directory.
    survivedMutationLogFile :: Maybe (Path Rel File)
  }
  deriving stock (Show, Eq, Generic)
  deriving (Aeson.ToJSON, Aeson.FromJSON) via (Autodocodec SurvivedMutation)

instance Validity SurvivedMutation

instance GenValid SurvivedMutation where
  genValid = genValidStructurally
  shrinkValid = shrinkValidStructurally

instance HasCodec SurvivedMutation where
  codec =
    object "SurvivedMutation" $
      SurvivedMutation
        <$> requiredField' "mutation" .= survivedMutationRecord
        <*> optionalFieldWith' "log_file" relFileCodec .= survivedMutationLogFile

-- | A mutation child that exceeded its monotonic-clock timeout and was killed by
-- the parent.  Treated as killed for the overall score (a hung mutation is
-- still a broken mutation), but reported separately for visibility.
data TimedOutMutation = TimedOutMutation
  { timedOutMutationRecord :: AugmentedMutationRecord,
    -- | Monotonic-clock microseconds elapsed before the parent killed the child.
    timedOutMutationElapsedMicros :: Word,
    -- | Path to the raw child output file (the bit produced before the kill),
    -- relative to the report directory.
    timedOutMutationLogFile :: Maybe (Path Rel File)
  }
  deriving stock (Show, Eq, Generic)
  deriving (Aeson.ToJSON, Aeson.FromJSON) via (Autodocodec TimedOutMutation)

instance Validity TimedOutMutation

instance GenValid TimedOutMutation where
  genValid = genValidStructurally
  shrinkValid = shrinkValidStructurally

instance HasCodec TimedOutMutation where
  codec =
    object "TimedOutMutation" $
      TimedOutMutation
        <$> requiredField' "mutation" .= timedOutMutationRecord
        <*> requiredField' "elapsed_micros" .= timedOutMutationElapsedMicros
        <*> optionalFieldWith' "log_file" relFileCodec .= timedOutMutationLogFile

-- | A mutation that was not covered by any test (never executed).
newtype UncoveredMutation = UncoveredMutation
  { uncoveredMutationRecord :: AugmentedMutationRecord
  }
  deriving stock (Show, Eq, Generic)
  deriving (Aeson.ToJSON, Aeson.FromJSON) via (Autodocodec UncoveredMutation)

instance Validity UncoveredMutation

instance GenValid UncoveredMutation where
  genValid = genValidStructurally
  shrinkValid = shrinkValidStructurally

instance HasCodec UncoveredMutation where
  codec =
    object "UncoveredMutation" $
      UncoveredMutation
        <$> requiredField' "mutation" .= uncoveredMutationRecord

-- | A mutation that was not tested because an earlier mutation in the same
-- group already failed (survived or was uncovered).  The 'skippedMutationCause'
-- points at the id of that earlier mutation.
data SkippedMutation = SkippedMutation
  { skippedMutationRecord :: AugmentedMutationRecord,
    skippedMutationCause :: MutationId
  }
  deriving stock (Show, Eq, Generic)
  deriving (Aeson.ToJSON, Aeson.FromJSON) via (Autodocodec SkippedMutation)

instance Validity SkippedMutation

instance GenValid SkippedMutation where
  genValid = genValidStructurally
  shrinkValid = shrinkValidStructurally

instance HasCodec SkippedMutation where
  codec =
    object "SkippedMutation" $
      SkippedMutation
        <$> requiredField' "mutation" .= skippedMutationRecord
        <*> requiredField' "cause" .= skippedMutationCause

-- | A control (no-op) mutation that was killed - i.e. the control /failed/.
--
-- A control changes no behaviour, so it must survive.  A killed control means
-- the mutation testing is unsound (a flaky or nondeterministic test suite, or
-- a bug in the harness itself), so it fails the run like a survivor rather than
-- being counted as a real kill.  Carries the optional child output file, like a
-- survivor, so the report can show what the suite did.
data ControlFailedMutation = ControlFailedMutation
  { controlFailedMutationRecord :: AugmentedMutationRecord,
    -- | Path to the raw child output file, relative to the report directory.
    controlFailedMutationLogFile :: Maybe (Path Rel File)
  }
  deriving stock (Show, Eq, Generic)
  deriving (Aeson.ToJSON, Aeson.FromJSON) via (Autodocodec ControlFailedMutation)

instance Validity ControlFailedMutation

instance GenValid ControlFailedMutation where
  genValid = genValidStructurally
  shrinkValid = shrinkValidStructurally

instance HasCodec ControlFailedMutation where
  codec =
    object "ControlFailedMutation" $
      ControlFailedMutation
        <$> requiredField' "mutation" .= controlFailedMutationRecord
        <*> optionalFieldWith' "log_file" relFileCodec .= controlFailedMutationLogFile

-- | One mutation's outcome within a group.
data MutationOutcome
  = OutcomeKilled AugmentedMutationRecord
  | OutcomeSurvived SurvivedMutation
  | OutcomeTimedOut TimedOutMutation
  | OutcomeUncovered UncoveredMutation
  | OutcomeSkipped SkippedMutation
  | -- | A control (no-op) mutation that survived, as it must.  The control
    -- /passed/: it confirms the harness correctly reports a non-diff as a
    -- survivor.  Excluded from the killed\/survived score.
    OutcomeControlPassed AugmentedMutationRecord
  | -- | A control (no-op) mutation that was killed - the control /failed/.
    -- Not part of the killed\/survived score, but fails the run; see
    -- 'ControlFailedMutation'.
    OutcomeControlFailed ControlFailedMutation
  deriving stock (Show, Eq, Generic)
  deriving (Aeson.ToJSON, Aeson.FromJSON) via (Autodocodec MutationOutcome)

instance Validity MutationOutcome

instance GenValid MutationOutcome where
  genValid = genValidStructurally
  shrinkValid = shrinkValidStructurally

instance HasCodec MutationOutcome where
  codec =
    object "MutationOutcome" $
      discriminatedUnionCodec
        "outcome"
        ( \case
            OutcomeKilled r -> ("killed", mapToEncoder r killedSubCodec)
            OutcomeSurvived s -> ("survived", mapToEncoder s survivedSubCodec)
            OutcomeTimedOut t -> ("timed_out", mapToEncoder t timedOutSubCodec)
            OutcomeUncovered u -> ("uncovered", mapToEncoder u uncoveredSubCodec)
            OutcomeSkipped sk -> ("skipped", mapToEncoder sk skippedSubCodec)
            OutcomeControlPassed r -> ("control_passed", mapToEncoder r controlPassedSubCodec)
            OutcomeControlFailed cf -> ("control_failed", mapToEncoder cf controlFailedSubCodec)
        )
        ( HashMap.fromList
            [ ("killed", ("OutcomeKilled", mapToDecoder OutcomeKilled killedSubCodec)),
              ("survived", ("OutcomeSurvived", mapToDecoder OutcomeSurvived survivedSubCodec)),
              ("timed_out", ("OutcomeTimedOut", mapToDecoder OutcomeTimedOut timedOutSubCodec)),
              ("uncovered", ("OutcomeUncovered", mapToDecoder OutcomeUncovered uncoveredSubCodec)),
              ("skipped", ("OutcomeSkipped", mapToDecoder OutcomeSkipped skippedSubCodec)),
              ("control_passed", ("OutcomeControlPassed", mapToDecoder OutcomeControlPassed controlPassedSubCodec)),
              ("control_failed", ("OutcomeControlFailed", mapToDecoder OutcomeControlFailed controlFailedSubCodec))
            ]
        )
    where
      killedSubCodec :: JSONObjectCodec AugmentedMutationRecord
      killedSubCodec = requiredField' "mutation"
      survivedSubCodec :: JSONObjectCodec SurvivedMutation
      survivedSubCodec =
        SurvivedMutation
          <$> requiredField' "mutation" .= survivedMutationRecord
          <*> optionalFieldWith' "log_file" relFileCodec .= survivedMutationLogFile
      timedOutSubCodec :: JSONObjectCodec TimedOutMutation
      timedOutSubCodec =
        TimedOutMutation
          <$> requiredField' "mutation" .= timedOutMutationRecord
          <*> requiredField' "elapsed_micros" .= timedOutMutationElapsedMicros
          <*> optionalFieldWith' "log_file" relFileCodec .= timedOutMutationLogFile
      uncoveredSubCodec :: JSONObjectCodec UncoveredMutation
      uncoveredSubCodec = UncoveredMutation <$> requiredField' "mutation" .= uncoveredMutationRecord
      skippedSubCodec :: JSONObjectCodec SkippedMutation
      skippedSubCodec =
        SkippedMutation
          <$> requiredField' "mutation" .= skippedMutationRecord
          <*> requiredField' "cause" .= skippedMutationCause
      controlPassedSubCodec :: JSONObjectCodec AugmentedMutationRecord
      controlPassedSubCodec = requiredField' "mutation"
      controlFailedSubCodec :: JSONObjectCodec ControlFailedMutation
      controlFailedSubCodec =
        ControlFailedMutation
          <$> requiredField' "mutation" .= controlFailedMutationRecord
          <*> optionalFieldWith' "log_file" relFileCodec .= controlFailedMutationLogFile

-- | All outcomes for the mutations of one group, in their original order.
newtype MutationGroupReport = MutationGroupReport
  { mutationGroupReportOutcomes :: [MutationOutcome]
  }
  deriving stock (Show, Eq, Generic)
  deriving (Aeson.ToJSON, Aeson.FromJSON) via (Autodocodec MutationGroupReport)

instance HasCodec MutationGroupReport where
  codec =
    dimapCodec MutationGroupReport mutationGroupReportOutcomes codec

instance Validity MutationGroupReport

instance GenValid MutationGroupReport where
  genValid = genValidStructurally
  shrinkValid = shrinkValidStructurally

-- | The score for the normal (non-control) mutations of a run.
--
-- 'mutationTallyKilled' includes timed-out mutations (a hung mutation is
-- treated as killed for scoring).  'mutationTallyTimedOut' is the count of
-- those specifically.  'mutationTallySkipped' counts mutations that were not
-- tested because an earlier mutation in the same group already failed.
data MutationTally = MutationTally
  { mutationTallyKilled :: Word,
    mutationTallySurvived :: Word,
    mutationTallyTimedOut :: Word,
    mutationTallyUncovered :: Word,
    mutationTallySkipped :: Word
  }
  deriving stock (Show, Eq, Generic)
  deriving (Aeson.ToJSON, Aeson.FromJSON) via (Autodocodec MutationTally)

instance Validity MutationTally

instance GenValid MutationTally where
  genValid = genValidStructurally
  shrinkValid = shrinkValidStructurally

instance HasCodec MutationTally where
  codec =
    object "MutationTally" $
      MutationTally
        <$> requiredField' "killed" .= mutationTallyKilled
        <*> requiredField' "survived" .= mutationTallySurvived
        <*> requiredField' "timed_out" .= mutationTallyTimedOut
        <*> requiredField' "uncovered" .= mutationTallyUncovered
        <*> requiredField' "skipped" .= mutationTallySkipped

-- | The score for the control (no-op) mutations of a run.  Controls are
-- excluded from 'MutationTally'; a passed control survived as it must, a failed
-- control was killed (the mutation testing is unsound - a flaky\/nondeterministic
-- suite or a harness bug - not a real kill, and it fails the run).
data ControlTally = ControlTally
  { controlTallyPassed :: Word,
    controlTallyFailed :: Word
  }
  deriving stock (Show, Eq, Generic)
  deriving (Aeson.ToJSON, Aeson.FromJSON) via (Autodocodec ControlTally)

instance Validity ControlTally

instance GenValid ControlTally where
  genValid = genValidStructurally
  shrinkValid = shrinkValidStructurally

instance HasCodec ControlTally where
  codec =
    object "ControlTally" $
      ControlTally
        <$> requiredField' "passed" .= controlTallyPassed
        <*> requiredField' "failed" .= controlTallyFailed

-- | Full JSON report written by the parent mutation process, in three parts:
-- the normal-mutation score ('mutationRunReportMutations'), the control-mutation
-- score ('mutationRunReportControls'), and the per-mutation detail
-- ('mutationRunReportGroups'), which mirrors the manifest's group structure.
data MutationRunReport = MutationRunReport
  { mutationRunReportMutations :: MutationTally,
    mutationRunReportControls :: ControlTally,
    mutationRunReportGroups :: [MutationGroupReport]
  }
  deriving stock (Show, Eq, Generic)
  deriving (Aeson.ToJSON, Aeson.FromJSON) via (Autodocodec MutationRunReport)

instance Validity MutationRunReport

instance GenValid MutationRunReport where
  genValid = genValidStructurally
  shrinkValid = shrinkValidStructurally

instance HasCodec MutationRunReport where
  codec =
    object "MutationRunReport" $
      MutationRunReport
        <$> requiredField' "mutations" .= mutationRunReportMutations
        <*> requiredField' "controls" .= mutationRunReportControls
        <*> requiredField' "groups" .= mutationRunReportGroups

mutationRunReportRelFile :: Path Rel File
mutationRunReportRelFile = [relfile|report.json|]

-- | Write @report.json@ to the given directory.
writeMutationRunReport :: Path Abs Dir -> MutationRunReport -> IO ()
writeMutationRunReport dir report = do
  ensureDir dir
  LB.writeFile (fromAbsFile (dir </> mutationRunReportRelFile)) (Aeson.encode report)

-- | Thrown by 'readMutationRunReport' when @report.json@ cannot be
-- decoded.  Mirrors 'AugmentedManifestDecodeException'.
newtype MutationRunReportDecodeException
  = MutationRunReportDecodeException FilePath
  deriving (Show)

instance Exception MutationRunReportDecodeException

-- | Read @report.json@ from the given directory.
--
-- Reads strictly (via 'SB.readFile' + 'Aeson.decodeStrict') so the file
-- handle is closed before this function returns.  Throws
-- 'MutationRunReportDecodeException' on a decode failure rather than
-- silently producing a 'Maybe', so a caller that depends on the report
-- shape (the @assert-score@ subcommand) fails with an attributable
-- error.
readMutationRunReport :: Path Abs Dir -> IO MutationRunReport
readMutationRunReport dir = do
  let path = dir </> mutationRunReportRelFile
  result <- Aeson.decodeStrict <$> SB.readFile (fromAbsFile path)
  case result of
    Nothing -> throwIO (MutationRunReportDecodeException (fromAbsFile path))
    Just m -> pure m

-- | Convert a 'MutationRecord' with coverage data to an 'AugmentedMutationRecord'.
-- Records with 'mutRecCoveringTests' = 'Nothing' are dropped.
fromMutationRecord :: MutationRecord -> Maybe AugmentedMutationRecord
fromMutationRecord MutationRecord {mutRecId, mutRecOperator, mutRecOriginal, mutRecReplacement, mutRecModule, mutRecLine, mutRecEndLine, mutRecColStart, mutRecColEnd, mutRecSourceFile, mutRecSourceLines, mutRecMutatedLines, mutRecContextBefore, mutRecContextAfter, mutRecCoveringTests, mutRecBinding, mutRecMitigation} =
  case mutRecCoveringTests of
    Nothing -> Nothing
    Just ts ->
      Just
        AugmentedMutationRecord
          { augmentedMutationRecordId = mutRecId,
            augmentedMutationRecordOperator = mutRecOperator,
            augmentedMutationRecordOriginal = mutRecOriginal,
            augmentedMutationRecordReplacement = mutRecReplacement,
            augmentedMutationRecordModule = mutRecModule,
            augmentedMutationRecordLine = mutRecLine,
            augmentedMutationRecordEndLine = mutRecEndLine,
            augmentedMutationRecordColStart = mutRecColStart,
            augmentedMutationRecordColEnd = mutRecColEnd,
            augmentedMutationRecordSourceFile = mutRecSourceFile,
            augmentedMutationRecordSourceLines = mutRecSourceLines,
            augmentedMutationRecordMutatedLines = mutRecMutatedLines,
            augmentedMutationRecordContextBefore = mutRecContextBefore,
            augmentedMutationRecordContextAfter = mutRecContextAfter,
            augmentedMutationRecordCoveringTests = ts,
            -- Filled in later by 'annotateRecord' in runCoverageMode; this
            -- code path constructs the record from a raw MutationRecord
            -- that has no baseline info, so we use the floor as a safe
            -- initial value.
            augmentedMutationRecordTimeoutMicros = defaultTimeoutMicros,
            augmentedMutationRecordBinding = mutRecBinding,
            augmentedMutationRecordMitigation = mutRecMitigation
          }

-- | A mutation that is about to be tested, used as the progress log event.
newtype MutationProgressEvent = MutationProgressEvent
  { mutationProgressRecord :: AugmentedMutationRecord
  }
  deriving stock (Show, Eq, Generic)

instance Validity MutationProgressEvent

instance GenValid MutationProgressEvent where
  genValid = genValidStructurally
  shrinkValid = shrinkValidStructurally