packages feed

keiro-dsl-0.4.0.1: src/Keiro/Dsl/CodecCompare.hs

{-# OPTIONS_GHC -Werror=incomplete-patterns #-}

{- | Pure historical-codec comparison and migration-evidence reports.

The comparison is deliberately finite evidence over typed fixture cases and
historical JSON goldens. It never changes which codec owns the wire schema and
never upgrades an opaque declaration to a structural claim.
-}
module Keiro.Dsl.CodecCompare (
    FixtureOrigin (..),
    DecodeOutcome (..),
    JsonPointer (..),
    ComparisonDifference (..),
    HistoricalCodec (..),
    CompareObservation (..),
    FixtureVerdict (..),
    CompareInputIssue (..),
    BranchKind (..),
    DeclaredBranch (..),
    ObservedBranch (..),
    CoverageGap (..),
    BranchSchema (..),
    BranchField (..),
    BranchArm (..),
    CompareProvenance (..),
    ClassifiedObservation (..),
    CompareReport (..),
    ReportWriteError (..),
    authorityStatement,
    canonicalJsonBytes,
    classifyObservation,
    declaredBranchesFor,
    observedBranchesFor,
    compareReport,
    renderCompareReport,
    reportSucceeded,
    writeCompareReportAtomic,
) where

import Control.Exception (IOException, bracketOnError, displayException, try)
import Control.Monad (when)
import Data.Aeson (FromJSON (..), ToJSON (..), Value (..), object, withObject, withText, (.:), (.=))
import Data.Aeson qualified as Aeson
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Aeson.RFC8785 qualified as RFC8785
import Data.Aeson.Types (Parser)
import Data.ByteString (ByteString)
import Data.ByteString.Lazy qualified as LazyByteString
import Data.Foldable (toList)
import Data.List (sort)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Keiro.Dsl.TypeGraph (BindingVersion (..), CanonicalTypeId (..), QualifiedValueName (..))
import Keiro.Dsl.Validate (DiagnosticCode (..))
import System.Directory (createDirectoryIfMissing, doesFileExist, removeFile, renameFile)
import System.FilePath (takeDirectory, takeFileName)
import System.IO (Handle, hClose, openBinaryTempFile)

data FixtureOrigin = HistoricalGolden | FromBinding
    deriving stock (Eq, Ord, Show)

data DecodeOutcome
    = DecodedShape !Value
    | DecodeFailed !Text
    deriving stock (Eq, Show)

newtype JsonPointer = JsonPointer {unJsonPointer :: Text}
    deriving stock (Eq, Ord, Show)

data ComparisonDifference
    = EncodedValueDifference !JsonPointer !Value !Value
    | DecodedValueDifference !JsonPointer !Value !Value
    | GeneratedDecodeRejected !Text
    deriving stock (Eq, Show)

{- | A historical codec is an explicit value supplied by consumer-owned test
code. Its identity and version are report provenance, not dispatch keys.
-}
data HistoricalCodec a = HistoricalCodec
    { hcIdentity :: !Text
    , hcVersion :: !Text
    , hcEncode :: !(a -> Value)
    , hcDecode :: !(Value -> Either Text a)
    }

data CompareObservation
    = EncodeObservation
        { coCaseName :: !Text
        , coHistoricalValue :: !Value
        , coGeneratedValue :: !Value
        }
    | DecodeObservation
        { coFixturePath :: !FilePath
        , coInputValue :: !Value
        , coHistoricalDecode :: !DecodeOutcome
        , coGeneratedDecode :: !DecodeOutcome
        }
    deriving stock (Eq, Show)

data FixtureVerdict
    = JsonParity
    | RequiresVersionWork !ComparisonDifference
    deriving stock (Eq, Show)

data CompareInputIssue
    = HistoricalGoldenUnreadable !FilePath !Text
    | HistoricalCodecRejected !FilePath !Text
    | HistoricalCodecProvenanceInvalid !Text
    deriving stock (Eq, Show)

data BranchKind
    = UnionArm !Text
    | OptionalPresent
    | OptionalMissing
    | ExplicitNull
    deriving stock (Eq, Ord, Show)

data DeclaredBranch = DeclaredBranch
    { dbOrigin :: !FixtureOrigin
    , dbPointer :: !JsonPointer
    , dbKind :: !BranchKind
    }
    deriving stock (Eq, Ord, Show)

data ObservedBranch = ObservedBranch
    { obOrigin :: !FixtureOrigin
    , obPointer :: !JsonPointer
    , obKind :: !BranchKind
    }
    deriving stock (Eq, Ord, Show)

data CoverageGap = CoverageGap
    { cgOrigin :: !FixtureOrigin
    , cgPointer :: !JsonPointer
    , cgKind :: !BranchKind
    }
    deriving stock (Eq, Ord, Show)

{- | A codec-independent branch description embedded into generated
comparison runners. The generator constructs it through the checked type
graph's total algebras, so this module never has to interpret a consumer type.
-}
data BranchSchema
    = BranchScalar
    | BranchOptional !BranchSchema
    | BranchList !BranchSchema
    | BranchMap !BranchSchema
    | BranchRecord ![BranchField]
    | BranchUnion !Text !Text ![BranchArm]
    deriving stock (Eq, Show)

data BranchField = BranchField
    { bfWireKey :: !Text
    , bfPresenceOptional :: !Bool
    , bfSchema :: !BranchSchema
    }
    deriving stock (Eq, Show)

data BranchArm = BranchArm
    { baWireTag :: !Text
    , baPayloadSchema :: !(Maybe BranchSchema)
    }
    deriving stock (Eq, Show)

data CompareProvenance = CompareProvenance
    { cpHistoricalCodecIdentity :: !Text
    , cpHistoricalCodecVersion :: !Text
    , cpCanonicalType :: !CanonicalTypeId
    , cpBindingSymbol :: !QualifiedValueName
    , cpBindingVersion :: !BindingVersion
    , cpWireFingerprint :: !Text
    }
    deriving stock (Eq, Show)

data ClassifiedObservation = ClassifiedObservation
    { classifiedOrigin :: !FixtureOrigin
    , classifiedName :: !Text
    , classifiedVerdict :: !FixtureVerdict
    }
    deriving stock (Eq, Show)

data CompareReport = CompareReport
    { crProvenance :: !CompareProvenance
    , crObservations :: ![ClassifiedObservation]
    , crInputIssues :: ![CompareInputIssue]
    , crCoverageGaps :: ![CoverageGap]
    , crAuthority :: !Text
    }
    deriving stock (Eq, Show)

data ReportWriteError = ReportWriteError
    { reportWritePath :: !FilePath
    , reportWriteMessage :: !Text
    }
    deriving stock (Eq, Show)

authorityStatement :: Text
authorityStatement =
    "This comparison is MIGRATION EVIDENCE ONLY. After cutover the generated structural codec is the sole wire authority. This runner is never a runtime fallback and never upgrades an opaque declaration to structural. Resolve each difference with an explicit version bump and upcaster, or correct the declaration to match the historical wire contract; \"close enough\" is not an outcome."

-- | Render a JSON value in RFC 8785 canonical form.
canonicalJsonBytes :: Value -> ByteString
canonicalJsonBytes = LazyByteString.toStrict . RFC8785.encodeCanonical

classifyObservation :: CompareObservation -> Either CompareInputIssue FixtureVerdict
classifyObservation observation = case observation of
    EncodeObservation _ historical generated ->
        Right (classifyValues EncodedValueDifference historical generated)
    DecodeObservation fixturePath _ historical generated -> case historical of
        DecodeFailed reason -> Left (HistoricalCodecRejected fixturePath reason)
        DecodedShape historicalValue -> case generated of
            DecodeFailed reason -> Right (RequiresVersionWork (GeneratedDecodeRejected reason))
            DecodedShape generatedValue ->
                Right (classifyValues DecodedValueDifference historicalValue generatedValue)

classifyValues :: (JsonPointer -> Value -> Value -> ComparisonDifference) -> Value -> Value -> FixtureVerdict
classifyValues difference historical generated
    | canonicalJsonBytes historical == canonicalJsonBytes generated = JsonParity
    | otherwise = RequiresVersionWork (difference (firstDivergentPointer historical generated) historical generated)

compareReport ::
    CompareProvenance ->
    [CompareInputIssue] ->
    [CompareObservation] ->
    [DeclaredBranch] ->
    [ObservedBranch] ->
    CompareReport
compareReport provenance suppliedIssues observations declaredBranches observedBranches =
    CompareReport
        { crProvenance = provenance
        , crObservations = classified
        , crInputIssues = provenanceIssues provenance <> suppliedIssues <> classificationIssues
        , crCoverageGaps = coverageGaps declaredBranches observedBranches
        , crAuthority = authorityStatement
        }
  where
    outcomes = map classify observations
    classified = [value | Right value <- outcomes]
    classificationIssues = [issue | Left issue <- outcomes]

    classify observation = case classifyObservation observation of
        Left issue -> Left issue
        Right verdict ->
            Right
                ClassifiedObservation
                    { classifiedOrigin = observationOrigin observation
                    , classifiedName = observationName observation
                    , classifiedVerdict = verdict
                    }

observationOrigin :: CompareObservation -> FixtureOrigin
observationOrigin EncodeObservation{} = FromBinding
observationOrigin DecodeObservation{} = HistoricalGolden

observationName :: CompareObservation -> Text
observationName EncodeObservation{coCaseName = name} = name
observationName DecodeObservation{coFixturePath = path} = T.pack path

provenanceIssues :: CompareProvenance -> [CompareInputIssue]
provenanceIssues provenance =
    [ HistoricalCodecProvenanceInvalid "historical codec identity must not be blank"
    | T.null (T.strip (cpHistoricalCodecIdentity provenance))
    ]
        <> [ HistoricalCodecProvenanceInvalid "historical codec version must not be blank"
           | T.null (T.strip (cpHistoricalCodecVersion provenance))
           ]

coverageGaps :: [DeclaredBranch] -> [ObservedBranch] -> [CoverageGap]
coverageGaps declared observed =
    [ CoverageGap (dbOrigin branch) (dbPointer branch) (dbKind branch)
    | branch <- declared
    , branchKey branch `Set.notMember` observedKeys
    ]
  where
    observedKeys = Set.fromList (map observedBranchKey observed)
    branchKey branch = (dbOrigin branch, dbPointer branch, dbKind branch)
    observedBranchKey branch = (obOrigin branch, obPointer branch, obKind branch)

declaredBranchesFor :: FixtureOrigin -> BranchSchema -> [DeclaredBranch]
declaredBranchesFor origin = Set.toAscList . go ""
  where
    declared pointer kind = Set.singleton (DeclaredBranch origin (JsonPointer pointer) kind)
    go pointer schema = case schema of
        BranchScalar -> Set.empty
        BranchOptional nested ->
            declared pointer OptionalPresent
                <> declared pointer ExplicitNull
                <> go pointer nested
        BranchList nested -> go (appendPointer pointer "*") nested
        BranchMap nested -> go (appendPointer pointer "*") nested
        BranchRecord fields ->
            Set.unions
                [ presenceBranches pointer field <> go (appendPointer pointer (bfWireKey field)) (bfSchema field)
                | field <- fields
                ]
        BranchUnion _tagField contentsField arms ->
            Set.unions
                [ declared pointer (UnionArm (baWireTag arm))
                    <> maybe Set.empty (go (appendPointer pointer contentsField)) (baPayloadSchema arm)
                | arm <- arms
                ]
    presenceBranches pointer field
        | bfPresenceOptional field =
            let fieldPointer = appendPointer pointer (bfWireKey field)
             in case origin of
                    HistoricalGolden -> declared fieldPointer OptionalMissing <> declared fieldPointer OptionalPresent
                    FromBinding -> declared fieldPointer OptionalPresent
        | otherwise = Set.empty

observedBranchesFor :: FixtureOrigin -> BranchSchema -> Value -> [ObservedBranch]
observedBranchesFor origin schema = Set.toAscList . go "" schema
  where
    observed pointer kind = Set.singleton (ObservedBranch origin (JsonPointer pointer) kind)
    go pointer branchSchema value = case branchSchema of
        BranchScalar -> Set.empty
        BranchOptional nested -> case value of
            Null -> observed pointer ExplicitNull
            _ -> observed pointer OptionalPresent <> go pointer nested value
        BranchList nested -> case value of
            Array values -> Set.unions [go (appendPointer pointer "*") nested item | item <- toList values]
            _ -> Set.empty
        BranchMap nested -> case value of
            Object values -> Set.unions [go (appendPointer pointer "*") nested item | item <- KeyMap.elems values]
            _ -> Set.empty
        BranchRecord fields -> case value of
            Object values -> Set.unions (map (observeField pointer values) fields)
            _ -> Set.empty
        BranchUnion tagField contentsField arms -> case value of
            Object values -> case KeyMap.lookup (Key.fromText tagField) values of
                Just (String tag) -> case filter ((== tag) . baWireTag) arms of
                    arm : _ ->
                        observed pointer (UnionArm tag)
                            <> case (baPayloadSchema arm, KeyMap.lookup (Key.fromText contentsField) values) of
                                (Just nested, Just payload) -> go (appendPointer pointer contentsField) nested payload
                                _ -> Set.empty
                    [] -> Set.empty
                _ -> Set.empty
            _ -> Set.empty
    observeField pointer values field =
        let fieldPointer = appendPointer pointer (bfWireKey field)
         in case KeyMap.lookup (Key.fromText (bfWireKey field)) values of
                Nothing
                    | bfPresenceOptional field -> observed fieldPointer OptionalMissing
                    | otherwise -> Set.empty
                Just fieldValue ->
                    (if bfPresenceOptional field then observed fieldPointer OptionalPresent else Set.empty)
                        <> go fieldPointer (bfSchema field) fieldValue

reportSucceeded :: CompareReport -> Bool
reportSucceeded report =
    null (crInputIssues report)
        && null (crCoverageGaps report)
        && all ((== JsonParity) . classifiedVerdict) (crObservations report)

renderCompareReport :: CompareReport -> Text
renderCompareReport report =
    T.unlines
        ( [ "codec comparison: "
                <> unCanonicalTypeId (cpCanonicalType provenance)
                <> " (binding-version \""
                <> unBindingVersion (cpBindingVersion provenance)
                <> "\")"
          , "historical codec: \""
                <> cpHistoricalCodecIdentity provenance
                <> "\" version \""
                <> cpHistoricalCodecVersion provenance
                <> "\""
          , "observations: " <> tshow (length observations)
          , "  encode parity: " <> ratio FromBinding
          , "  structural decode agreement: " <> ratio HistoricalGolden
          , "requires explicit version/upcaster work: " <> tshow (length differences) <> " observations  [" <> codeText CodecCompareDifference <> "]"
          ]
            <> concatMap renderDifference differences
            <> [ "input issues: " <> tshow (length (crInputIssues report)) <> "  [" <> codeText CodecCompareInvalidInput <> "]"
               ]
            <> map ("  " <>) (map renderInputIssue (crInputIssues report))
            <> [ "coverage gaps: " <> tshow (length (crCoverageGaps report)) <> "  [" <> codeText CodecCompareCoverageGap <> "]"
               ]
            <> map ("  " <>) (map renderCoverageGap (crCoverageGaps report))
            <> [ if reportSucceeded report
                    then "result: PARITY"
                    else "result: NOT PARITY — " <> tshow (length differences) <> " differences"
               , crAuthority report
               ]
        )
  where
    provenance = crProvenance report
    observations = crObservations report
    differences = filter ((/= JsonParity) . classifiedVerdict) observations
    ratio origin =
        let matching = filter ((== origin) . classifiedOrigin) observations
            parityCount = length (filter ((== JsonParity) . classifiedVerdict) matching)
         in tshow parityCount <> "/" <> tshow (length matching) <> suffix origin
    suffix FromBinding = " (RFC 8785 canonical form)"
    suffix HistoricalGolden = ""

renderDifference :: ClassifiedObservation -> [Text]
renderDifference observation = case classifiedVerdict observation of
    JsonParity -> []
    RequiresVersionWork difference ->
        [ "  " <> classifiedName observation <> " [" <> direction <> "] at " <> pointerOf difference
        , "    " <> reasonOf difference
        ]
  where
    direction = case classifiedOrigin observation of
        FromBinding -> "encode"
        HistoricalGolden -> "decode"

renderInputIssue :: CompareInputIssue -> Text
renderInputIssue issue = case issue of
    HistoricalGoldenUnreadable path reason -> T.pack path <> ": unreadable historical golden: " <> reason
    HistoricalCodecRejected path reason -> T.pack path <> ": historical codec rejected its alleged golden: " <> reason
    HistoricalCodecProvenanceInvalid reason -> reason

renderCoverageGap :: CoverageGap -> Text
renderCoverageGap gap =
    originName (cgOrigin gap)
        <> " "
        <> renderPointer (cgPointer gap)
        <> ": "
        <> branchKindName (cgKind gap)

pointerOf :: ComparisonDifference -> Text
pointerOf difference = case difference of
    EncodedValueDifference pointer _ _ -> renderPointer pointer
    DecodedValueDifference pointer _ _ -> renderPointer pointer
    GeneratedDecodeRejected _ -> "<root>"

reasonOf :: ComparisonDifference -> Text
reasonOf difference = case difference of
    EncodedValueDifference _ historical generated ->
        "historical and generated encoders produced different JSON values: " <> valuePair historical generated
    DecodedValueDifference _ historical generated ->
        "historical and generated decoders normalized to different structural values: " <> valuePair historical generated
    GeneratedDecodeRejected reason -> "generated structural decoder rejected historical JSON: " <> reason

valuePair :: Value -> Value -> Text
valuePair historical generated = "historical=" <> tshow historical <> "; generated=" <> tshow generated

renderPointer :: JsonPointer -> Text
renderPointer (JsonPointer pointer)
    | T.null pointer = "<root>"
    | otherwise = pointer

writeCompareReportAtomic :: FilePath -> CompareReport -> IO (Either ReportWriteError ())
writeCompareReportAtomic path report = do
    let directory = takeDirectory path
        template = takeFileName path <> ".tmp"
    result <- try $ do
        createDirectoryIfMissing True directory
        bracketOnError
            (openBinaryTempFile directory template)
            cleanupTemporary
            ( \(temporary, handle) -> do
                LazyByteString.hPut handle (Aeson.encode report)
                hClose handle
                renameFile temporary path
            )
    pure $ case result of
        Left err -> Left (ReportWriteError path (T.pack (displayException (err :: IOException))))
        Right () -> Right ()

cleanupTemporary :: (FilePath, Handle) -> IO ()
cleanupTemporary (temporary, handle) = do
    _ <- try (hClose handle) :: IO (Either IOException ())
    exists <- doesFileExist temporary
    when exists (removeFile temporary)

firstDivergentPointer :: Value -> Value -> JsonPointer
firstDivergentPointer = go ""
  where
    go pointer (Object historical) (Object generated) =
        case firstDifferentKey historical generated of
            Nothing -> JsonPointer pointer
            Just key -> case (KeyMap.lookup (Key.fromText key) historical, KeyMap.lookup (Key.fromText key) generated) of
                (Just historicalValue, Just generatedValue) -> go (appendPointer pointer key) historicalValue generatedValue
                _ -> JsonPointer (appendPointer pointer key)
    go pointer (Array historical) (Array generated) =
        let historicalValues = toList historical
            generatedValues = toList generated
         in case firstDifferentIndex historicalValues generatedValues of
                Nothing -> JsonPointer pointer
                Just index -> case (indexMaybe index historicalValues, indexMaybe index generatedValues) of
                    (Just historicalValue, Just generatedValue) -> go (appendPointer pointer (tshow index)) historicalValue generatedValue
                    _ -> JsonPointer (appendPointer pointer (tshow index))
    go pointer _ _ = JsonPointer pointer

firstDifferentKey :: KeyMap.KeyMap Value -> KeyMap.KeyMap Value -> Maybe Text
firstDifferentKey historical generated =
    firstMatch differs allKeys
  where
    allKeys = sort (map Key.toText (KeyMap.keys historical <> KeyMap.keys generated))
    differs key = KeyMap.lookup (Key.fromText key) historical /= KeyMap.lookup (Key.fromText key) generated

firstDifferentIndex :: [Value] -> [Value] -> Maybe Int
firstDifferentIndex historical generated =
    firstMatch differs [0 .. max (length historical) (length generated) - 1]
  where
    differs index = indexMaybe index historical /= indexMaybe index generated

indexMaybe :: Int -> [a] -> Maybe a
indexMaybe index values = case drop index values of
    value : _ -> Just value
    [] -> Nothing

firstMatch :: (a -> Bool) -> [a] -> Maybe a
firstMatch predicate = \case
    [] -> Nothing
    value : rest
        | predicate value -> Just value
        | otherwise -> firstMatch predicate rest

appendPointer :: Text -> Text -> Text
appendPointer base segment = base <> "/" <> escapePointerSegment segment

escapePointerSegment :: Text -> Text
escapePointerSegment = T.replace "/" "~1" . T.replace "~" "~0"

tshow :: (Show a) => a -> Text
tshow = T.pack . show

codeText :: DiagnosticCode -> Text
codeText = T.pack . show

originName :: FixtureOrigin -> Text
originName HistoricalGolden = "historical-golden"
originName FromBinding = "typed-fixture"

parseOrigin :: Text -> Parser FixtureOrigin
parseOrigin "historical-golden" = pure HistoricalGolden
parseOrigin "typed-fixture" = pure FromBinding
parseOrigin value = fail ("unknown fixture origin: " <> T.unpack value)

branchKindName :: BranchKind -> Text
branchKindName kind = case kind of
    UnionArm arm -> "union-arm:" <> arm
    OptionalPresent -> "optional-present"
    OptionalMissing -> "optional-missing"
    ExplicitNull -> "explicit-null"

parseBranchKind :: Text -> Parser BranchKind
parseBranchKind value
    | Just arm <- T.stripPrefix "union-arm:" value = pure (UnionArm arm)
    | value == "optional-present" = pure OptionalPresent
    | value == "optional-missing" = pure OptionalMissing
    | value == "explicit-null" = pure ExplicitNull
    | otherwise = fail ("unknown branch kind: " <> T.unpack value)

instance ToJSON FixtureOrigin where
    toJSON = String . originName

instance FromJSON FixtureOrigin where
    parseJSON = withText "FixtureOrigin" parseOrigin

instance ToJSON JsonPointer where
    toJSON = String . unJsonPointer

instance FromJSON JsonPointer where
    parseJSON = withText "JsonPointer" (pure . JsonPointer)

instance ToJSON BranchKind where
    toJSON = String . branchKindName

instance FromJSON BranchKind where
    parseJSON = withText "BranchKind" parseBranchKind

instance ToJSON ComparisonDifference where
    toJSON difference = case difference of
        EncodedValueDifference pointer historical generated ->
            differenceObject "encoded-value-difference" pointer "encoder outputs differ" historical generated
        DecodedValueDifference pointer historical generated ->
            differenceObject "decoded-value-difference" pointer "normalized decoder outputs differ" historical generated
        GeneratedDecodeRejected reason ->
            object
                [ "kind" .= ("generated-decode-rejected" :: Text)
                , "pointer" .= JsonPointer ""
                , "reason" .= reason
                ]
      where
        differenceObject kind pointer reason historical generated =
            object
                [ "kind" .= (kind :: Text)
                , "pointer" .= pointer
                , "reason" .= (reason :: Text)
                , "historical" .= historical
                , "generated" .= generated
                ]

instance FromJSON ComparisonDifference where
    parseJSON = withObject "ComparisonDifference" $ \value -> do
        kind <- value .: "kind" :: Parser Text
        case kind of
            "encoded-value-difference" -> EncodedValueDifference <$> value .: "pointer" <*> value .: "historical" <*> value .: "generated"
            "decoded-value-difference" -> DecodedValueDifference <$> value .: "pointer" <*> value .: "historical" <*> value .: "generated"
            "generated-decode-rejected" -> GeneratedDecodeRejected <$> value .: "reason"
            _ -> fail ("unknown comparison difference: " <> T.unpack kind)

instance ToJSON FixtureVerdict where
    toJSON JsonParity = object ["verdict" .= ("json-parity" :: Text)]
    toJSON (RequiresVersionWork difference) =
        object
            [ "verdict" .= ("requires-version-work" :: Text)
            , "code" .= codeText CodecCompareDifference
            , "difference" .= difference
            ]

instance FromJSON FixtureVerdict where
    parseJSON = withObject "FixtureVerdict" $ \value -> do
        verdict <- value .: "verdict" :: Parser Text
        case verdict of
            "json-parity" -> pure JsonParity
            "requires-version-work" -> RequiresVersionWork <$> value .: "difference"
            _ -> fail ("unknown fixture verdict: " <> T.unpack verdict)

instance ToJSON CompareInputIssue where
    toJSON issue = case issue of
        HistoricalGoldenUnreadable path reason -> issueObject "historical-golden-unreadable" path reason
        HistoricalCodecRejected path reason -> issueObject "historical-codec-rejected" path reason
        HistoricalCodecProvenanceInvalid reason ->
            object
                [ "code" .= codeText CodecCompareInvalidInput
                , "kind" .= ("historical-codec-provenance-invalid" :: Text)
                , "reason" .= reason
                ]
      where
        issueObject kind path reason =
            object
                [ "code" .= codeText CodecCompareInvalidInput
                , "kind" .= (kind :: Text)
                , "path" .= path
                , "reason" .= reason
                ]

instance FromJSON CompareInputIssue where
    parseJSON = withObject "CompareInputIssue" $ \value -> do
        kind <- value .: "kind" :: Parser Text
        case kind of
            "historical-golden-unreadable" -> HistoricalGoldenUnreadable <$> value .: "path" <*> value .: "reason"
            "historical-codec-rejected" -> HistoricalCodecRejected <$> value .: "path" <*> value .: "reason"
            "historical-codec-provenance-invalid" -> HistoricalCodecProvenanceInvalid <$> value .: "reason"
            _ -> fail ("unknown comparison input issue: " <> T.unpack kind)

instance ToJSON DeclaredBranch where
    toJSON branch =
        object
            [ "origin" .= dbOrigin branch
            , "pointer" .= dbPointer branch
            , "branch" .= dbKind branch
            ]

instance FromJSON DeclaredBranch where
    parseJSON = withObject "DeclaredBranch" $ \value ->
        DeclaredBranch <$> value .: "origin" <*> value .: "pointer" <*> value .: "branch"

instance ToJSON ObservedBranch where
    toJSON branch =
        object
            [ "origin" .= obOrigin branch
            , "pointer" .= obPointer branch
            , "branch" .= obKind branch
            ]

instance FromJSON ObservedBranch where
    parseJSON = withObject "ObservedBranch" $ \value ->
        ObservedBranch <$> value .: "origin" <*> value .: "pointer" <*> value .: "branch"

instance ToJSON CoverageGap where
    toJSON gap =
        object
            [ "code" .= codeText CodecCompareCoverageGap
            , "origin" .= cgOrigin gap
            , "pointer" .= cgPointer gap
            , "branch" .= cgKind gap
            ]

instance FromJSON CoverageGap where
    parseJSON = withObject "CoverageGap" $ \value ->
        CoverageGap <$> value .: "origin" <*> value .: "pointer" <*> value .: "branch"

instance ToJSON CompareProvenance where
    toJSON provenance =
        object
            [ "historicalCodecIdentity" .= cpHistoricalCodecIdentity provenance
            , "historicalCodecVersion" .= cpHistoricalCodecVersion provenance
            , "canonicalType" .= unCanonicalTypeId (cpCanonicalType provenance)
            , "bindingSymbol" .= unQualifiedValueName (cpBindingSymbol provenance)
            , "bindingVersion" .= unBindingVersion (cpBindingVersion provenance)
            , "wireFingerprint" .= cpWireFingerprint provenance
            ]

instance FromJSON CompareProvenance where
    parseJSON = withObject "CompareProvenance" $ \value ->
        CompareProvenance
            <$> value .: "historicalCodecIdentity"
            <*> value .: "historicalCodecVersion"
            <*> (CanonicalTypeId <$> value .: "canonicalType")
            <*> (QualifiedValueName <$> value .: "bindingSymbol")
            <*> (BindingVersion <$> value .: "bindingVersion")
            <*> value .: "wireFingerprint"

instance ToJSON ClassifiedObservation where
    toJSON observation =
        object
            [ "origin" .= classifiedOrigin observation
            , "name" .= classifiedName observation
            , "result" .= classifiedVerdict observation
            ]

instance FromJSON ClassifiedObservation where
    parseJSON = withObject "ClassifiedObservation" $ \value ->
        ClassifiedObservation <$> value .: "origin" <*> value .: "name" <*> value .: "result"

instance ToJSON CompareReport where
    toJSON report =
        object
            [ "schema" .= ("keiro-dsl/codec-compare-report/1" :: Text)
            , "authority" .= crAuthority report
            , "provenance" .= crProvenance report
            , "success" .= reportSucceeded report
            , "summary"
                .= object
                    [ "observations" .= length (crObservations report)
                    , "parity" .= length (filter ((== JsonParity) . classifiedVerdict) (crObservations report))
                    , "differences" .= length (filter ((/= JsonParity) . classifiedVerdict) (crObservations report))
                    , "inputIssues" .= length (crInputIssues report)
                    , "coverageGaps" .= length (crCoverageGaps report)
                    ]
            , "observations" .= crObservations report
            , "inputIssues" .= crInputIssues report
            , "coverageGaps" .= crCoverageGaps report
            ]

instance FromJSON CompareReport where
    parseJSON = withObject "CompareReport" $ \value ->
        CompareReport
            <$> value .: "provenance"
            <*> value .: "observations"
            <*> value .: "inputIssues"
            <*> value .: "coverageGaps"
            <*> value .: "authority"