packages feed

keiro-dsl-0.15.0.0: src/Keiro/Dsl/Goldens.hs

-- | Versioned event-payload fixtures captured at spec-diff time.
--
-- The current aggregate specification cannot reconstruct an older payload shape,
-- so golden payloads are synthesized while both the old and new specifications
-- are available. Existing files are never overwritten: a hand-captured
-- production payload is always more authoritative than a synthesized sample.
module Keiro.Dsl.Goldens
  ( GoldenEvidence (..),
    GoldenPayload (..),
    goldensForDiff,
    emitGoldenPayloads,
    loadGoldenPayloads,
    goldenRelativePath,
  )
where

import Data.Aeson (Value (..))
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Aeson.Text qualified as AesonText
import Data.List (find)
import Data.List.NonEmpty qualified as NE
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe, listToMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Data.Text.Lazy qualified as TL
import Keiro.Dsl.AggregateType
import Keiro.Dsl.FieldIdentity (ResolvedFieldIdentity (..))
import Keiro.Dsl.Grammar
import Keiro.Dsl.NominalType
import Keiro.Dsl.Scaffold (Agg (..), ResolvedCtor (..), defaultContext, resolveAgg)
import Keiro.Dsl.TypeGraph
import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist)
import System.FilePath (dropTrailingPathSeparator, takeDirectory, takeFileName, (</>))

data GoldenEvidence = SynthesizedWeakStandIn | FileOwnedFixture
  deriving stock (Eq, Show)

data GoldenPayload = GoldenPayload
  { context :: !Text,
    aggregate :: !Text,
    event :: !Text,
    version :: !Int,
    json :: !Text,
    evidence :: !GoldenEvidence
  }
  deriving stock (Eq, Show)

-- | Synthesize one old-shape payload for each event whose version increases.
-- The result is deterministic and ordered like the old specification.
goldensForDiff :: Spec -> Spec -> [GoldenPayload]
goldensForDiff oldSpec newSpec =
  [ GoldenPayload
      { context = (.context) oldSpec,
        aggregate = (.name) oldAggregate,
        event = (.name) oldEvent,
        version = (.version) oldEvent,
        json = renderGolden oldSpec oldResolved oldResolvedEvent,
        evidence = SynthesizedWeakStandIn
      }
  | oldAggregate <- aggregates oldSpec,
    Just newAggregate <- [find ((== (.name) oldAggregate) . (.name)) (aggregates newSpec)],
    let oldResolved = resolveAgg (defaultContext ((.context) oldSpec)) oldSpec oldAggregate,
    oldEvent <- (.events) oldAggregate,
    Just newEvent <- [find ((== (.name) oldEvent) . (.name)) ((.events) newAggregate)],
    (.version) newEvent > (.version) oldEvent,
    Just oldResolvedEvent <- [find ((== (.name) oldEvent) . (.name)) ((.events) oldResolved)]
  ]
  where
    aggregates spec = [aggregate | NAggregate aggregate <- (.nodes) spec]

-- | Write newly synthesized fixtures below
-- @<root>/<context>/<aggregate>/<event>.v<version>.json@. Existing files are
-- left untouched and omitted from the returned path list.
emitGoldenPayloads :: FilePath -> Spec -> Spec -> IO [FilePath]
emitGoldenPayloads root oldSpec newSpec =
  fmap concat . traverse writeIfMissing $ goldensForDiff oldSpec newSpec
  where
    writeIfMissing golden = do
      let path = root </> goldenRelativePath golden
      exists <- doesFileExist path
      if exists
        then pure []
        else do
          createDirectoryIfMissing True (takeDirectory path)
          TIO.writeFile path ((.json) golden)
          pure [path]

-- | Load only the fixtures relevant to declared upcasters in @spec@.
-- @root@ may name the global golden root or its context child directory.
loadGoldenPayloads :: FilePath -> Spec -> IO [GoldenPayload]
loadGoldenPayloads root spec = do
  contextRoot <- resolveContextRoot root (T.unpack ((.context) spec))
  fmap concat . traverse (loadAggregate contextRoot) $ aggregates spec
  where
    aggregates current = [aggregate | NAggregate aggregate <- (.nodes) current]

    loadAggregate contextRoot aggregate =
      fmap concat . traverse (loadEvent contextRoot aggregate) $ (.events) aggregate

    loadEvent contextRoot aggregate event = case (.upcastFrom) event of
      Nothing -> pure []
      Just (sourceVersion, _) -> do
        let golden =
              GoldenPayload
                { context = (.context) spec,
                  aggregate = (.name) aggregate,
                  event = (.name) event,
                  version = sourceVersion,
                  json = "",
                  evidence = FileOwnedFixture
                }
            path = contextRoot </> aggregateRelativePath golden
        exists <- doesFileExist path
        if exists
          then do
            contents <- TIO.readFile path
            pure [goldenWithJson contents golden]
          else pure []

    goldenWithJson json golden =
      GoldenPayload
        { context = golden.context,
          aggregate = golden.aggregate,
          event = golden.event,
          version = golden.version,
          json,
          evidence = golden.evidence
        }

goldenRelativePath :: GoldenPayload -> FilePath
goldenRelativePath golden =
  T.unpack ((.context) golden) </> aggregateRelativePath golden

aggregateRelativePath :: GoldenPayload -> FilePath
aggregateRelativePath golden =
  T.unpack ((.aggregate) golden)
    </> T.unpack ((.event) golden)
      <> ".v"
      <> show ((.version) golden)
      <> ".json"

resolveContextRoot :: FilePath -> FilePath -> IO FilePath
resolveContextRoot root name = do
  let nested = root </> name
  nestedExists <- doesDirectoryExist nested
  pure $
    if nestedExists
      then nested
      else
        if takeFileName (dropTrailingPathSeparator root) == name
          then root
          else nested

renderGolden :: Spec -> Agg -> ResolvedCtor -> Text
renderGolden spec aggregate event =
  TL.toStrict (AesonText.encodeToLazyText (Object (KeyMap.fromList entries))) <> "\n"
  where
    graph = either (const Nothing) Just (resolveTypeGraph spec)
    entries =
      (Key.fromText "kind", String ((.name) event))
        : [(Key.fromText ((.wireKey) identity), sampleValue graph spec aggregate valueType) | (identity, valueType) <- (.fields) event]

sampleValue :: Maybe TypeGraph -> Spec -> Agg -> ResolvedAggregateType -> Value
sampleValue graph spec _aggregate resolvedType =
  case resolvedType of
    AggregateNominal nominal -> case (.representation) nominal of
      IdRepresentation prefix -> String (prefix <> "_01hzy3v7q2e8kaw2m5x0d41n9c")
      EnumRepresentation constructors -> String (snd (NE.head constructors))
      ScalarRepresentation NominalText -> String "sample"
      ScalarRepresentation NominalInt -> Number 1
      ScalarRepresentation NominalNatural -> Number 1
      ScalarRepresentation NominalBool -> Bool True
      ScalarRepresentation NominalTime -> String "2026-01-02T03:04:05.123456789012Z"
    AggregateVertex vertexType ->
      String
        ( fromMaybe
            "sample"
            ( (.name)
                <$> ( find ((== vertexType) . (<> "Vertex") . (.name)) aggregates
                        >>= listToMaybe . (.states)
                    )
            )
        )
    AggregateMapped key
      | Just resolved <- graph,
        Just declaration <- Map.lookup key ((.declarations) resolved) ->
          sampleMappedDeclaration resolved declaration
      | otherwise -> emptyObject
    AggregateInt -> Number 1
    AggregateInteger -> Number 1
    AggregateNatural -> Number 1
    AggregateBool -> Bool True
    AggregateTime -> String "2026-01-02T03:04:05.123456789012Z"
    AggregateText -> String "sample"
  where
    aggregates = [aggregate | NAggregate aggregate <- (.nodes) spec]

sampleMappedDeclaration :: TypeGraph -> ResolvedMappedDecl -> Value
sampleMappedDeclaration graph =
  foldMappedDecl
    MappedDeclAlgebra
      { onStructuralDecl = \_ -> sampleMappedShape graph,
        onOpaqueDecl = const emptyObject
      }

sampleMappedShape :: TypeGraph -> ResolvedMappedShape -> Value
sampleMappedShape graph =
  foldMappedShape
    MappedShapeAlgebra
      { onRecord = \_ _ fields ->
          Object . KeyMap.fromList $
            [ (Key.fromText ((.key) field), sampleMappedExpression graph ((.valueType) field))
            | field <- fields,
              includeField field
            ],
        onEnum = \entries -> case entries of
          firstEntry : _ -> String ((.tag) firstEntry)
          [] -> String "sample",
        onUnion = \encoding arms -> case arms of
          firstArm : _ ->
            Object . KeyMap.fromList $
              [(Key.fromText ((.tagField) encoding), String ((.tag) firstArm))]
                <> [ (Key.fromText ((.contentsField) encoding), sampleMappedExpression graph payload)
                   | payload <- maybeToList ((.payload) firstArm)
                   ]
          [] -> emptyObject
      }
  where
    includeField field = case (.presence) field of
      PRequired -> True
      POptional -> isNothingValue ((.onMissing) field)

sampleMappedExpression :: TypeGraph -> ResolvedTypeExpr -> Value
sampleMappedExpression graph =
  foldTypeExpr
    TypeExprAlgebra
      { onText = String "sample",
        onInt = Number 1,
        onInteger = Number 1,
        onBool = Bool True,
        onNatural = Number 1,
        onTime = String "2026-01-01T00:00:00Z",
        onJson = emptyObject,
        onOptional = id,
        onList = \value -> Array (pure value),
        onMap = \value -> Object (KeyMap.singleton (Key.fromText "sample") value),
        onRef = \key -> maybe emptyObject (sampleMappedDeclaration graph) (Map.lookup key ((.declarations) graph))
      }

emptyObject :: Value
emptyObject = Object KeyMap.empty

maybeToList :: Maybe a -> [a]
maybeToList = maybe [] pure

isNothingValue :: Maybe a -> Bool
isNothingValue Nothing = True
isNothingValue Just {} = False