packages feed

hegel (empty) → 0.1.0

raw patch · 24 files changed

+4025/−0 lines, 24 filesdep +aesondep +arraydep +async

Dependencies added: aeson, array, async, base, bytestring, cborg, containers, data-default, digest, directory, hegel, hspec, process, stm, text

Files

+ conformance/TestBinary.hs view
@@ -0,0 +1,36 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Conformance binary: generates binary byte strings and writes metrics.+module Main (main) where++import qualified Data.Aeson as Aeson+import Data.Aeson (Value (..))+import qualified Data.ByteString as BS+import qualified Data.ByteString.Lazy.Char8 as LBS+import Data.Maybe (fromMaybe, listToMaybe)+import qualified Data.Map.Strict as Map+import Hegel+import System.Environment (getArgs)++parseParams :: IO (Map.Map String Value)+parseParams = do+  args <- getArgs+  pure $+    fromMaybe Map.empty $+      Aeson.decode . LBS.pack =<< listToMaybe args++getIntParam :: Map.Map String Value -> String -> Maybe Int+getIntParam m k = round <$> (getNumber =<< Map.lookup k m)+  where+    getNumber (Number n) = Just n+    getNumber _ = Nothing++main :: IO ()+main = do+  params <- parseParams+  let minSize = getIntParam params "min_size"+  let maxSize = getIntParam params "max_size"+  testCases <- getTestCases+  runHegelTest (\settings -> settings { settingsTestCases = testCases }) $ \tc -> do+    b <- draw tc (binary def { rangeMin = minSize, rangeMax = maxSize })+    writeMetrics [("length", show (BS.length b))]
+ conformance/TestBooleans.hs view
@@ -0,0 +1,13 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Conformance binary: generates boolean values and writes metrics.+module Main (main) where++import Hegel++main :: IO ()+main = do+  testCases <- getTestCases+  runHegelTest (\settings -> settings { settingsTestCases = testCases }) $ \tc -> do+    b <- draw tc booleans+    writeMetrics [("value", if b then "true" else "false")]
+ conformance/TestFloats.hs view
@@ -0,0 +1,76 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Conformance binary: generates float values and writes metrics.+module Main (main) where++import qualified Data.Aeson as Aeson+import Data.Aeson (Value (..))+import qualified Data.ByteString.Lazy.Char8 as LBS+import Data.Maybe (fromMaybe, listToMaybe)+import qualified Data.Map.Strict as Map+import Hegel+import System.Environment (getArgs)++parseParams :: IO (Map.Map String Value)+parseParams = do+  args <- getArgs+  pure $+    fromMaybe Map.empty $+      Aeson.decode . LBS.pack =<< listToMaybe args++getDoubleParam :: Map.Map String Value -> String -> Maybe Double+getDoubleParam m k = realToFrac <$> (getNumber =<< Map.lookup k m)+  where+    getNumber (Number n) = Just n+    getNumber _ = Nothing++getBoolParam :: Map.Map String Value -> String -> Maybe Bool+getBoolParam m k = getBool =<< Map.lookup k m+  where+    getBool (Bool b) = Just b+    getBool _ = Nothing++getBoolParamDefault :: Map.Map String Value -> String -> Bool -> Bool+getBoolParamDefault m k defaultValue = fromMaybe defaultValue (getBoolParam m k)++-- | Format a float for JSON output. NaN becomes "null", +Inf becomes "1e308",+-- -Inf becomes "-1e308", otherwise show the number.+formatFloat :: Double -> String+formatFloat v+  | isNaN v = "null"+  | isInfinite v = if v > 0 then "1e308" else "-1e308"+  | otherwise = show v++boolStr :: Bool -> String+boolStr True = "true"+boolStr False = "false"++main :: IO ()+main = do+  params <- parseParams+  let minVal = getDoubleParam params "min_value"+  let maxVal = getDoubleParam params "max_value"+  let excludeMin = getBoolParamDefault params "exclude_min" False+  let excludeMax = getBoolParamDefault params "exclude_max" False+  let allowNan = getBoolParam params "allow_nan"+  let allowInf = getBoolParam params "allow_infinity"+  let opts =+        def+          { floatBounds =+              RangeOpts+                { rangeMin = minVal,+                  rangeMax = maxVal+                },+            floatExcludeMin = excludeMin,+            floatExcludeMax = excludeMax,+            floatAllowNan = allowNan,+            floatAllowInfinity = allowInf+          }+  testCases <- getTestCases+  runHegelTest (\settings -> settings { settingsTestCases = testCases }) $ \tc -> do+    v <- draw tc (floats opts)+    writeMetrics+      [ ("value", formatFloat v),+        ("is_nan", boolStr (isNaN v)),+        ("is_infinite", boolStr (isInfinite v))+      ]
+ conformance/TestHashmaps.hs view
@@ -0,0 +1,86 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Conformance binary: generates hash maps (dicts) and writes metrics.+module Main (main) where++import qualified Data.Aeson as Aeson+import Data.Aeson (Value (..))+import qualified Data.ByteString.Lazy.Char8 as LBS+import Data.Maybe (fromMaybe, listToMaybe)+import qualified Data.Map.Strict as Map+import Data.Text (Text)+import Hegel+import System.Environment (getArgs)++parseParams :: IO (Map.Map String Value)+parseParams = do+  args <- getArgs+  pure $+    fromMaybe Map.empty $+      Aeson.decode . LBS.pack =<< listToMaybe args++getIntParam :: Map.Map String Value -> String -> Maybe Int+getIntParam m k = round <$> (getNumber =<< Map.lookup k m)+  where+    getNumber (Number n) = Just n+    getNumber _ = Nothing++getIntParamDefault :: Map.Map String Value -> String -> Int -> Int+getIntParamDefault m k defaultValue = fromMaybe defaultValue (getIntParam m k)++getTextParam :: Map.Map String Value -> String -> Maybe Text+getTextParam m k = getText =<< Map.lookup k m+  where+    getText (Aeson.String t) = Just t+    getText _ = Nothing++intOrNull :: Maybe Int -> String+intOrNull = maybe "null" show++main :: IO ()+main = do+  params <- parseParams+  let minSize = getIntParamDefault params "min_size" 0+  let maxSize = getIntParamDefault params "max_size" 10+  let keyType = fromMaybe "integer" (getTextParam params "key_type")+  let minKey = getIntParamDefault params "min_key" (-1000)+  let maxKey = getIntParamDefault params "max_key" 1000+  let minValBound = getIntParamDefault params "min_value" (-1000)+  let maxValBound = getIntParamDefault params "max_value" 1000+  testCases <- getTestCases+  if keyType == "string"+    then runHegelTest (\settings -> settings { settingsTestCases = testCases }) $ \tc -> do+      let keyGen = text def { rangeMin = Just 1, rangeMax = Just 10 }+      let valGen = integers def { rangeMin = Just minValBound, rangeMax = Just maxValBound }+      let mapGen = hashmaps keyGen valGen def { rangeMin = Just minSize, rangeMax = Just maxSize }+      pairs <- draw tc mapGen+      let len = length pairs+      let vals = map snd pairs+      let minVal = if null vals then Nothing else Just (minimum vals)+      let maxVal = if null vals then Nothing else Just (maximum vals)+      writeMetrics+        [ ("size", show len),+          ("min_key", "null"),+          ("max_key", "null"),+          ("min_value", intOrNull minVal),+          ("max_value", intOrNull maxVal)+        ]+    else runHegelTest (\settings -> settings { settingsTestCases = testCases }) $ \tc -> do+      let keyGen = integers def { rangeMin = Just minKey, rangeMax = Just maxKey }+      let valGen = integers def { rangeMin = Just minValBound, rangeMax = Just maxValBound }+      let mapGen = hashmaps keyGen valGen def { rangeMin = Just minSize, rangeMax = Just maxSize }+      pairs <- draw tc mapGen+      let len = length pairs+      let keys = map fst pairs+      let vals = map snd pairs+      let minKeyResult = if null keys then Nothing else Just (minimum keys)+      let maxKeyResult = if null keys then Nothing else Just (maximum keys)+      let minValResult = if null vals then Nothing else Just (minimum vals)+      let maxValResult = if null vals then Nothing else Just (maximum vals)+      writeMetrics+        [ ("size", show len),+          ("min_key", intOrNull minKeyResult),+          ("max_key", intOrNull maxKeyResult),+          ("min_value", intOrNull minValResult),+          ("max_value", intOrNull maxValResult)+        ]
+ conformance/TestIntegers.hs view
@@ -0,0 +1,35 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Conformance binary: generates integer values and writes metrics.+module Main (main) where++import qualified Data.Aeson as Aeson+import Data.Aeson (Value (..))+import qualified Data.ByteString.Lazy.Char8 as LBS+import Data.Maybe (fromMaybe, listToMaybe)+import qualified Data.Map.Strict as Map+import Hegel+import System.Environment (getArgs)++parseParams :: IO (Map.Map String Value)+parseParams = do+  args <- getArgs+  pure $+    fromMaybe Map.empty $+      Aeson.decode . LBS.pack =<< listToMaybe args++getIntParam :: Map.Map String Value -> String -> Maybe Int+getIntParam m k = round <$> (getNumber =<< Map.lookup k m)+  where+    getNumber (Number n) = Just n+    getNumber _ = Nothing++main :: IO ()+main = do+  params <- parseParams+  let minVal = getIntParam params "min_value"+  let maxVal = getIntParam params "max_value"+  testCases <- getTestCases+  runHegelTest (\settings -> settings { settingsTestCases = testCases }) $ \tc -> do+    n <- draw tc (integers def { rangeMin = minVal, rangeMax = maxVal })+    writeMetrics [("value", show n)]
+ conformance/TestLists.hs view
@@ -0,0 +1,57 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Conformance binary: generates lists of integers and writes metrics.+--+-- Uses filtered elements (CompositeList path) so that new_collection and+-- collection_more commands are sent -- required for error-mode conformance+-- tests (stop_test_on_collection_more, stop_test_on_new_collection).+module Main (main) where++import qualified Data.Aeson as Aeson+import Data.Aeson (Value (..))+import qualified Data.ByteString.Lazy.Char8 as LBS+import Data.Maybe (fromMaybe, listToMaybe)+import qualified Data.Map.Strict as Map+import Hegel+import System.Environment (getArgs)++parseParams :: IO (Map.Map String Value)+parseParams = do+  args <- getArgs+  pure $+    fromMaybe Map.empty $+      Aeson.decode . LBS.pack =<< listToMaybe args++getIntParam :: Map.Map String Value -> String -> Maybe Int+getIntParam m k = round <$> (getNumber =<< Map.lookup k m)+  where+    getNumber (Number n) = Just n+    getNumber _ = Nothing++intOrNull :: Maybe Int -> String+intOrNull = maybe "null" show++main :: IO ()+main = do+  params <- parseParams+  let minSize = getIntParam params "min_size"+  let maxSize = getIntParam params "max_size"+  let minVal = getIntParam params "min_value"+  let maxVal = getIntParam params "max_value"+  testCases <- getTestCases+  runHegelTest (\settings -> settings { settingsTestCases = testCases }) $ \tc -> do+    -- Use gfilter (const True) to force the CompositeList path, which sends+    -- new_collection and collection_more commands. This is required so that+    -- the error-mode conformance tests (stop_test_on_collection_more,+    -- stop_test_on_new_collection) work correctly.+    let elemGen = gfilter (const True) (integers def { rangeMin = minVal, rangeMax = maxVal })+    let listGen = lists elemGen def { rangeMin = minSize, rangeMax = maxSize }+    items <- draw tc listGen+    let len = length items+    let minElem = if null items then Nothing else Just (minimum items)+    let maxElem = if null items then Nothing else Just (maximum items)+    writeMetrics+      [ ("size", show len),+        ("min_element", intOrNull minElem),+        ("max_element", intOrNull maxElem)+      ]
+ conformance/TestSampledFrom.hs view
@@ -0,0 +1,32 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Conformance binary: generates sampled_from values and writes metrics.+module Main (main) where++import qualified Data.Aeson as Aeson+import Data.Aeson (Value (..))+import qualified Data.Aeson.Types as Aeson+import qualified Data.ByteString.Lazy.Char8 as LBS+import Data.Maybe (fromMaybe, listToMaybe)+import qualified Data.Map.Strict as Map+import Hegel+import System.Environment (getArgs)++parseParams :: IO (Map.Map String Value)+parseParams = do+  args <- getArgs+  pure $+    fromMaybe Map.empty $+      Aeson.decode . LBS.pack =<< listToMaybe args++getIntArray :: Map.Map String Value -> String -> [Int]+getIntArray m k = fromMaybe [] $ Map.lookup k m >>= Aeson.parseMaybe Aeson.parseJSON++main :: IO ()+main = do+  params <- parseParams+  let options = getIntArray params "options"+  testCases <- getTestCases+  runHegelTest (\settings -> settings { settingsTestCases = testCases }) $ \tc -> do+    v <- draw tc (sampledFrom options)+    writeMetrics [("value", show v)]
+ conformance/TestText.hs view
@@ -0,0 +1,37 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Conformance binary: generates text values and writes metrics.+module Main (main) where++import qualified Data.Aeson as Aeson+import Data.Aeson (Value (..))+import qualified Data.ByteString.Lazy.Char8 as LBS+import Data.Maybe (fromMaybe, listToMaybe)+import qualified Data.Map.Strict as Map+import qualified Data.Text as T+import Hegel+import System.Environment (getArgs)++parseParams :: IO (Map.Map String Value)+parseParams = do+  args <- getArgs+  pure $+    fromMaybe Map.empty $+      Aeson.decode . LBS.pack =<< listToMaybe args++getIntParam :: Map.Map String Value -> String -> Maybe Int+getIntParam m k = round <$> (getNumber =<< Map.lookup k m)+  where+    getNumber (Number n) = Just n+    getNumber _ = Nothing++main :: IO ()+main = do+  params <- parseParams+  let minSize = getIntParam params "min_size"+  let maxSize = getIntParam params "max_size"+  testCases <- getTestCases+  runHegelTest (\settings -> settings { settingsTestCases = testCases }) $ \tc -> do+    t <- draw tc (text def { rangeMin = minSize, rangeMax = maxSize })+    -- T.length counts Unicode codepoints+    writeMetrics [("length", show (T.length t))]
+ hegel.cabal view
@@ -0,0 +1,168 @@+cabal-version: 3.0+name:          hegel+version:       0.1.0+synopsis:      Property-based testing powered by Hypothesis+description:+  Hegel is a Haskell property-based testing library backed by Hypothesis.+  It provides a generator API, an integration test runner, and a wire+  protocol client for driving Hegel from Haskell code.+maintainer:    Mark Wotton <mwotton@gmail.com>+category:      Testing+license:       MIT+build-type:    Simple++common shared+  ghc-options:      -Wall -Wcompat -threaded+  default-language: GHC2021+  build-depends:    data-default >=0.7 && <0.9++library+  import:          shared+  hs-source-dirs:  src+  exposed-modules:+    Hegel+    Hegel.Client+    Hegel.Conformance+    Hegel.Connection+    Hegel.Generator+    Hegel.Generator.Collections+    Hegel.Generator.Combinators+    Hegel.Generator.Primitives+    Hegel.Protocol+    Hegel.Session+  other-modules:+    Hegel.Generator.Range++  build-depends:+    , array         >=0.5      && <0.6+    , async         >=2.2      && <2.3+    , base          >=4.18  && <5+    , bytestring    >=0.11     && <0.13+    , cborg         >=0.2.9    && <0.3+    , containers    >=0.6      && <0.8+    , digest        >=0.0.2    && <0.1+    , directory     >=1.3      && <1.4+    , process       >=1.6      && <1.7+    , stm           >=2.5      && <2.6+    , text          >=2.0      && <2.2++test-suite hegel-test+  import:         shared+  type:           exitcode-stdio-1.0+  hs-source-dirs: test+  main-is:        Spec.hs+  other-modules:+    Hegel.ProtocolSpec+    Hegel.ClientSpec+    Hegel.IntegrationSpec++  build-depends:+    , base+    , bytestring+    , cborg+    , hegel+    , hspec+    , process+    , stm+    , text++  build-tool-depends:+    hspec-discover:hspec-discover++executable test-booleans+  import:         shared+  hs-source-dirs: conformance+  main-is:        TestBooleans.hs+  build-depends:+    , aeson        >=2.2       && <2.3+    , base+    , hegel+    , text++executable test-integers+  import:         shared+  hs-source-dirs: conformance+  main-is:        TestIntegers.hs+  build-depends:+    , aeson        >=2.2       && <2.3+    , base+    , bytestring+    , containers+    , hegel+    , text++executable test-floats+  import:         shared+  hs-source-dirs: conformance+  main-is:        TestFloats.hs+  build-depends:+    , aeson        >=2.2       && <2.3+    , base+    , bytestring+    , containers+    , hegel+    , text++executable test-text+  import:         shared+  hs-source-dirs: conformance+  main-is:        TestText.hs+  build-depends:+    , aeson        >=2.2       && <2.3+    , base+    , bytestring+    , containers+    , hegel+    , text++executable test-binary+  import:         shared+  hs-source-dirs: conformance+  main-is:        TestBinary.hs+  build-depends:+    , aeson        >=2.2       && <2.3+    , base+    , bytestring+    , containers+    , hegel+    , text++executable test-lists+  import:         shared+  hs-source-dirs: conformance+  main-is:        TestLists.hs+  build-depends:+    , aeson        >=2.2       && <2.3+    , base+    , bytestring+    , containers+    , hegel+    , text++executable test-sampled-from+  import:         shared+  hs-source-dirs: conformance+  main-is:        TestSampledFrom.hs+  build-depends:+    , aeson        >=2.2       && <2.3+    , base+    , bytestring+    , containers+    , hegel+    , text++executable test-hashmaps+  import:         shared+  hs-source-dirs: conformance+  main-is:        TestHashmaps.hs+  build-depends:+    , aeson        >=2.2       && <2.3+    , base+    , bytestring+    , containers+    , hegel+    , text++source-repository head+  type:     git+  location: https://github.com/lambdamechanic/hegel-haskell.git
+ src/Hegel.hs view
@@ -0,0 +1,153 @@+-- | Hegel is a property-based testing library for Haskell. Hegel is based on+-- <https://github.com/hypothesisworks/hypothesis Hypothesis>, using the+-- <https://hegel.dev/ Hegel> protocol.+--+-- = Getting started+--+-- @+-- import Data.Default (def)+-- import Hegel+--+-- main :: IO ()+-- main = 'runHegelTest_' $ \\tc -> do+--   n <- 'draw' tc ('integers' def)+--   assert (n == n) "integer not equal to itself"+-- @+module Hegel+  ( -- * Running tests+    runHegelTest,+    runHegelTest_,+    TestCase,+    Settings+      ( settingsTestCases+      , settingsVerbosity+      , settingsSeed+      , settingsDerandomize+      , settingsDatabase+      , settingsSuppressHealthCheck+      ),+    defaultSettings,+    def,+    HealthCheck (..),+    Verbosity (..),+    Database (..),++    -- * Drawing values+    draw,+    assume,+    assert,+    failure,+    note,+    target,++    -- * Generators+    Generator,+    gmap,+    flatMap,+    gfilter,++    -- ** Primitives+    booleans,+    integers,+    floats,+    RangeOpts (..),+    defaultRangeOpts,+    FloatOpts (..),+    defaultFloatOpts,+    text,+    binary,+    just,++    -- ** Collections+    lists,+    hashmaps,++    -- ** Combinators+    sampledFrom,+    oneOf,+    optional,+    tuples2,+    tuples3,+    tuples4,+    ipAddresses,+    ipv4Addresses,+    ipv6Addresses,++    -- ** Formats+    emails,+    urls,+    domains,+    dates,+    times,+    datetimes,+    fromRegex,++    -- * Conformance helpers+    getTestCases,+    writeMetrics,++    -- * Exceptions+    AssumeRejected (..),+    DataExhausted (..),+    HegelTestFailure (..),+  )+where++import Data.Default (def)+import Hegel.Client+  ( AssumeRejected (..),+    DataExhausted (..),+    Database (..),+    HealthCheck (..),+    HegelTestFailure (..),+    Settings+      ( settingsTestCases+      , settingsVerbosity+      , settingsSeed+      , settingsDerandomize+      , settingsDatabase+      , settingsSuppressHealthCheck+      ),+    TestCase,+    Verbosity (..),+    assume,+    assert,+    defaultSettings,+    note,+    target,+    failure,+  )+import Hegel.Conformance (getTestCases, writeMetrics)+import Hegel.Generator (Generator, draw, flatMap, gfilter, gmap)+import Hegel.Generator.Collections (hashmaps, lists)+import Hegel.Generator.Combinators+  ( ipAddresses,+    oneOf,+    optional,+    sampledFrom,+    tuples2,+    tuples3,+    tuples4,+  )+import Hegel.Generator.Primitives+  ( FloatOpts (..),+    RangeOpts (..),+    binary,+    booleans,+    dates,+    datetimes,+    defaultFloatOpts,+    defaultRangeOpts,+    domains,+    emails,+    floats,+    fromRegex,+    integers,+    ipv4Addresses,+    ipv6Addresses,+    just,+    text,+    times,+    urls,+  )+import Hegel.Session (runHegelTest, runHegelTest_)
+ src/Hegel/Client.hs view
@@ -0,0 +1,521 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}++-- | Test runner and lifecycle management for Hegel.+--+-- This module implements the client-side logic for running property-based+-- tests against a Hegel server. It manages:+--+-- * Test lifecycle (run_test, test_case events, mark_complete)+-- * Helper functions (assume, note, target, generate_from_schema)+-- * Origin extraction for error reporting+module Hegel.Client+  ( -- * Exceptions+    AssumeRejected (..)+  , DataExhausted (..)+  , HegelTestFailure (..)++    -- * Health checks+  , HealthCheck (..)+  , healthCheckToString++    -- * Verbosity and database settings+  , Verbosity (..)+  , Database (..)+  , Settings+      ( settingsTestCases+      , settingsVerbosity+      , settingsSeed+      , settingsDerandomize+      , settingsDatabase+      , settingsSuppressHealthCheck+      )+  , defaultSettings++    -- * Test case state+  , TestCase (..)++    -- * Client+  , HegelClient (..)+  , createClient++    -- * Test operations+  , generateFromSchema+  , assume+  , assert+  , failure+  , note+  , target+  , startSpan+  , stopSpan+  , runTestCase+  , runTest+  ) where++import Codec.CBOR.Term (Term (..))+import Control.Concurrent.MVar+import Control.Exception hiding (assert)+import Control.Monad (replicateM_, when, unless, void)+import Data.Default (Default (def))+import Data.IORef+import Data.Text (Text)+import qualified Data.Text as T+import GHC.Stack+  ( CallStack+  , HasCallStack+  , SrcLoc (..)+  , callStack+  , getCallStack+  , prettyCallStack+  , withFrozenCallStack+  )+import System.IO (hPutStrLn, stderr)++import Hegel.Connection+import Hegel.Protocol (encodeTerm, extractInt, extractMap, extractText)+++-- ---------------------------------------------------------------------------+-- Exceptions+-- ---------------------------------------------------------------------------++-- | Raised when 'assume' is called with a 'False' condition.+data AssumeRejected = AssumeRejected+  deriving (Show)++instance Exception AssumeRejected++-- | Raised when the server runs out of test data (StopTest).+data DataExhausted = DataExhausted+  deriving (Show)++instance Exception DataExhausted++-- | Raised when a property test fails.+newtype HegelTestFailure = HegelTestFailure String+  deriving (Show)++instance Exception HegelTestFailure++-- | Raised by Hegel's assertion helpers. Carries a call stack so we can report+-- a stable source location to the server while still surfacing the full error+-- message on the final replayed failure.+data AssertionFailure = AssertionFailure+  { assertionFailureMessage :: String+  , assertionFailureCallStack :: CallStack+  }++instance Show AssertionFailure where+  show AssertionFailure {..}+    | null (getCallStack assertionFailureCallStack) = assertionFailureMessage+    | otherwise =+        assertionFailureMessage+          ++ "\n"+          ++ prettyCallStack assertionFailureCallStack++instance Exception AssertionFailure where+  displayException = show+++-- ---------------------------------------------------------------------------+-- Health checks+-- ---------------------------------------------------------------------------++-- | Health checks that can be suppressed during test execution.+data HealthCheck+  = FilterTooMuch+  | TooSlow+  | TestCasesTooLarge+  | LargeInitialTestCase+  deriving (Show, Eq)++-- | Returns the wire protocol name for a health check.+healthCheckToString :: HealthCheck -> Text+healthCheckToString FilterTooMuch = "filter_too_much"+healthCheckToString TooSlow = "too_slow"+healthCheckToString TestCasesTooLarge = "test_cases_too_large"+healthCheckToString LargeInitialTestCase = "large_initial_test_case"+++-- ---------------------------------------------------------------------------+-- Verbosity and database settings+-- ---------------------------------------------------------------------------++-- | Controls how much output Hegel produces during test runs.+data Verbosity = Quiet | Normal | Verbose | Debug+  deriving (Show, Eq)++-- | The database setting: unset, disabled, or a path.+data Database+  = Unset+  | Disabled+  | DatabasePath FilePath+  deriving (Show, Eq)++-- | Configuration for a Hegel test run.+data Settings = Settings+  { settingsTestCases :: Int+  , settingsVerbosity :: Verbosity+  , settingsSeed :: Maybe Int+  , settingsDerandomize :: Bool+  , settingsDatabase :: Database+  , settingsSuppressHealthCheck :: [HealthCheck]+  } deriving (Show, Eq)++instance Default Settings where+  def = Settings+    { settingsTestCases = 100+    , settingsVerbosity = Normal+    , settingsSeed = Nothing+    , settingsDerandomize = False+    , settingsDatabase = Unset+    , settingsSuppressHealthCheck = []+    }+++-- | Pure baseline settings before any runtime-specific overrides are applied.+defaultSettings :: Settings+defaultSettings = def+++-- ---------------------------------------------------------------------------+-- Test case state+-- ---------------------------------------------------------------------------++-- | Per-test-case state passed to the test function.+data TestCase = TestCase+  { tcChannel :: Channel+    -- ^ The data channel for this test case.+  , tcIsFinal :: Bool+    -- ^ Whether this is the final (replay) run.+  , tcTestAborted :: IORef Bool+    -- ^ Set to 'True' when the server signals StopTest.+  }+++-- ---------------------------------------------------------------------------+-- Client+-- ---------------------------------------------------------------------------++-- | Supported protocol version range.+supportedProtocolLo :: Double+supportedProtocolLo = 0.6++supportedProtocolHi :: Double+supportedProtocolHi = 0.7++-- | Hegel client for running property-based tests.+data HegelClient = HegelClient+  { clientConnection :: Connection+  , clientControl :: Channel+  , clientLock :: MVar ()+  }++-- | Creates a new client from a connection. The connection must not yet have+-- had its handshake performed. Validates the protocol version is in range+-- 0.1 through 0.7.+createClient :: Connection -> IO HegelClient+createClient conn = do+  versionStr <- sendHandshake conn+  let serverVersion = read versionStr :: Double+  when (serverVersion < supportedProtocolLo || serverVersion > supportedProtocolHi) $+    fail $ "hegel-haskell supports protocol versions "+      ++ show supportedProtocolLo ++ " through " ++ show supportedProtocolHi+      ++ ", but got server version " ++ show serverVersion+      ++ ". Upgrading hegel-haskell or downgrading your hegel cli might help."+  ctrl <- controlChannel conn+  lock <- newMVar ()+  pure HegelClient+    { clientConnection = conn+    , clientControl = ctrl+    , clientLock = lock+    }+++-- ---------------------------------------------------------------------------+-- Test operations+-- ---------------------------------------------------------------------------++-- | Generates a value from a schema by sending a generate command to the+-- server. Raises 'DataExhausted' if the server signals StopTest.+generateFromSchema :: Term -> TestCase -> IO Term+generateFromSchema schema tc = do+  let ch = tcChannel tc+  let msg = TMap+        [ (TString "command", TString "generate")+        , (TString "schema", schema)+        ]+  result <- try (sendRequestAndWait ch msg) :: IO (Either RequestError Term)+  case result of+    Right val -> pure val+    Left (RequestError _ errType _)+      | errType == "StopTest" -> do+          writeIORef (tcTestAborted tc) True+          throwIO DataExhausted+    Left e -> throwIO e++-- | Rejects the current test case if the condition is 'False'.+assume :: TestCase -> Bool -> IO ()+assume _tc condition = unless condition $ throwIO AssumeRejected++-- | Fails the current property with a message and captured call stack.+failure :: HasCallStack => String -> IO a+failure message =+  withFrozenCallStack $+    throwIO+      AssertionFailure+        { assertionFailureMessage = message+        , assertionFailureCallStack = callStack+        }++-- | Asserts a property condition, reporting the call site on failure.+assert :: HasCallStack => Bool -> String -> IO ()+assert condition message = unless condition (withFrozenCallStack $ failure message)++-- | Records a message that will be printed on the final (failing) run.+note :: TestCase -> String -> IO ()+note tc message = when (tcIsFinal tc) $ hPutStrLn stderr message++-- | Sends a target command to guide the search engine toward higher values.+target :: TestCase -> Double -> String -> IO ()+target tc value label = do+  let ch = tcChannel tc+  let msg = TMap+        [ (TString "command", TString "target")+        , (TString "value", TDouble value)+        , (TString "label", TString (T.pack label))+        ]+  void $ sendRequestAndWait ch msg++-- | Starts a generation span for better shrinking.+startSpan :: Int -> TestCase -> IO ()+startSpan label tc = do+  aborted <- readIORef (tcTestAborted tc)+  unless aborted $ do+    let ch = tcChannel tc+    let msg = TMap+          [ (TString "command", TString "start_span")+          , (TString "label", TInt label)+          ]+    void $ sendRequestAndWait ch msg++-- | Ends the current generation span.+stopSpan :: Bool -> TestCase -> IO ()+stopSpan discard tc = do+  aborted <- readIORef (tcTestAborted tc)+  unless aborted $ do+    let ch = tcChannel tc+    let msg = TMap+          [ (TString "command", TString "stop_span")+          , (TString "discard", TBool discard)+          ]+    void $ sendRequestAndWait ch msg+++-- ---------------------------------------------------------------------------+-- Test case outcomes+-- ---------------------------------------------------------------------------++data TestOutcome+  = Valid+  | Invalid+  | DataWasExhausted+  | Interesting Text (Maybe SomeException)+    -- ^ origin text, and optionally the exception (on final run)++-- | Extracts a human-readable origin string from an exception.+extractOrigin :: SomeException -> Text+extractOrigin exn =+  case fromException exn of+    Just AssertionFailure {assertionFailureCallStack = stack} ->+      maybe fallback (T.pack . formatSrcLoc) (firstSrcLoc stack)+    Nothing -> fallback+  where+    fallback = T.pack (displayException exn)++firstSrcLoc :: CallStack -> Maybe SrcLoc+firstSrcLoc stack = snd <$> case getCallStack stack of+  [] -> Nothing+  frame : _ -> Just frame++formatSrcLoc :: SrcLoc -> String+formatSrcLoc SrcLoc {..} =+  srcLocFile ++ ":" ++ show srcLocStartLine ++ ":" ++ show srcLocStartCol++-- | Send mark_complete, swallowing StopTest errors.+markComplete :: Channel -> Text -> Term -> IO ()+markComplete ch status origin = do+  let msg = TMap+        [ (TString "command", TString "mark_complete")+        , (TString "status", TString status)+        , (TString "origin", origin)+        ]+  result <- try (sendRequestAndWait ch msg) :: IO (Either RequestError Term)+  case result of+    Left (RequestError _ errType _)+      | errType == "StopTest" -> pure ()+    Left e -> throwIO e+    Right _ -> pure ()++-- | Runs a single test case. Creates a 'TestCase', runs the user function,+-- catches exceptions, and sends mark_complete with the appropriate status+-- (VALID, INVALID, or INTERESTING). Handles StopTest on mark_complete.+runTestCase :: HegelClient -> Channel -> (TestCase -> IO ()) -> Bool -> IO ()+runTestCase _client ch testFn isFinal = do+  abortedRef <- newIORef False+  let tc = TestCase+        { tcChannel = ch+        , tcIsFinal = isFinal+        , tcTestAborted = abortedRef+        }+  outcome <- (testFn tc >> pure Valid) `catches`+    [ Handler $ \AssumeRejected -> pure Invalid+    , Handler $ \DataExhausted -> pure DataWasExhausted+    , Handler $ \(e :: SomeException) -> do+        let origin = extractOrigin e+        let ex = if isFinal then Just e else Nothing+        pure (Interesting origin ex)+    ]+  case outcome of+    DataWasExhausted -> pure ()+    Valid -> markComplete ch "VALID" TNull+    Invalid -> markComplete ch "INVALID" TNull+    Interesting originText _ -> markComplete ch "INTERESTING" (TString originText)+  closeChannel ch+  -- On final run, re-raise the interesting exception+  case outcome of+    Interesting _ (Just e) -> throwIO e+    _ -> pure ()+++-- ---------------------------------------------------------------------------+-- Full test lifecycle+-- ---------------------------------------------------------------------------++-- | Runs a full property test. Sends run_test on the control channel with+-- all settings fields, then processes test_case and test_done events.+-- Checks error, health_check_failure, flaky, and passed in results.+-- Replays interesting cases on the final run.+runTest :: HegelClient -> Settings -> (TestCase -> IO ()) -> IO ()+runTest client stgs testFn = do+  let conn = clientConnection client+  testChannel <- newChannel conn++  -- Send run_test command under the client lock+  withMVar (clientLock client) $ \_ -> do+    let seedValue = maybe TNull TInt (settingsSeed stgs)+    let databaseKeyValue = TNull+    let baseFields =+          [ (TString "command", TString "run_test")+          , (TString "test_cases", TInt (settingsTestCases stgs))+          , (TString "seed", seedValue)+          , (TString "channel_id", TInt (fromIntegral (channelId testChannel)))+          , (TString "database_key", databaseKeyValue)+          , (TString "derandomize", TBool (settingsDerandomize stgs))+          ]+    let databaseField = case settingsDatabase stgs of+          Unset          -> []+          Disabled       -> [(TString "database", TNull)]+          DatabasePath p -> [(TString "database", TString (T.pack p))]+    let suppressField = case settingsSuppressHealthCheck stgs of+          [] -> []+          checks ->+            [ ( TString "suppress_health_check"+              , TList (map (TString . healthCheckToString) checks)+              )+            ]+    let fields = baseFields ++ databaseField ++ suppressField+    void $ sendRequestAndWait (clientControl client) (TMap fields)++  -- Helper to receive and run a test case (used for replay)+  let receiveAndRunTestCase isFinal = do+        (msgId, msg) <- receiveRequest testChannel+        let pairs = extractMap msg+        let chId = extractInt (lookupCBOR "channel_id" pairs)+        sendResponseValue testChannel msgId TNull+        tcChan <- connectChannel conn (fromIntegral chId)+        runTestCase client tcChan testFn isFinal++  -- Event loop+  let receiveEvents = do+        (msgId, msg) <- receiveRequest testChannel+        let pairs = extractMap msg+        let event = extractText (lookupCBOR "event" pairs)+        case event of+          "test_case" -> do+            let chId = extractInt (lookupCBOR "channel_id" pairs)+            sendResponseValue testChannel msgId TNull+            tcChan <- connectChannel conn (fromIntegral chId)+            runTestCase client tcChan testFn False+            exited <- serverHasExited conn+            when exited $ fail serverCrashedMessage+            receiveEvents+          "test_done" -> do+            sendResponseValue testChannel msgId (TBool True)+            pure (extractMap (lookupCBOR "results" pairs))+          other -> do+            let errPayload = encodeTerm $ TMap+                  [ (TString "error", TString ("Unrecognised event " <> other))+                  , (TString "type", TString "InvalidMessage")+                  ]+            sendResponseRaw testChannel msgId errPayload+            receiveEvents++  results <- receiveEvents++  -- Check for server-side errors+  case lookupCBORMaybe "error" results of+    Just errorVal -> do+      let errorMsg = extractText errorVal+      fail ("Server error: " ++ T.unpack errorMsg)+    Nothing -> pure ()++  -- Check for health check failure+  case lookupCBORMaybe "health_check_failure" results of+    Just failureVal -> do+      let failureMsg = extractText failureVal+      fail ("Health check failure:\n" ++ T.unpack failureMsg)+    Nothing -> pure ()++  -- Check for flaky test detection+  case lookupCBORMaybe "flaky" results of+    Just flakyVal -> do+      let flakyMsg = extractText flakyVal+      fail ("Flaky test detected: " ++ T.unpack flakyMsg)+    Nothing -> pure ()++  -- Check passed flag+  let passed = case lookupCBORMaybe "passed" results of+        Just (TBool b) -> b+        _              -> True++  let nInteresting = extractInt (lookupCBOR "interesting_test_cases" results)++  if nInteresting == 0 && passed+    then pure ()+    else if nInteresting >= 1+      then do+        firstFailure <- try (receiveAndRunTestCase True)+        -- Drain any extra interesting cases quietly to keep the control+        -- channel in sync, but only surface the smallest final failure.+        replicateM_ (nInteresting - 1) (receiveAndRunTestCase False)+        case firstFailure of+          Left (e :: SomeException) -> throwIO e+          Right () -> unless passed $ throwIO (HegelTestFailure "Property test failed")+      else unless passed $ throwIO (HegelTestFailure "Property test failed")+++-- ---------------------------------------------------------------------------+-- CBOR helpers (local to this module)+-- ---------------------------------------------------------------------------++-- | Look up a key in a CBOR map (list of pairs), using a 'Text' key.+lookupCBOR :: Text -> [(Term, Term)] -> Term+lookupCBOR key pairs =+  case lookup (TString key) pairs of+    Just v  -> v+    Nothing -> error $ "Key not found in CBOR map: " ++ T.unpack key++-- | Look up a key in a CBOR map, returning 'Nothing' if absent.+lookupCBORMaybe :: Text -> [(Term, Term)] -> Maybe Term+lookupCBORMaybe key = lookup (TString key)
+ src/Hegel/Conformance.hs view
@@ -0,0 +1,43 @@+-- | Helpers for writing Hegel conformance test binaries.+--+-- Each conformance binary reads parameters from @argv[1]@ (JSON), runs a+-- property test that draws values and writes metrics, then exits.+module Hegel.Conformance+  ( getTestCases,+    writeMetrics,+    formatMetrics,+  )+where++import System.Environment (lookupEnv)+import System.IO (IOMode (..), hFlush, hPutStr, withFile)++-- | Read the number of test cases from the @CONFORMANCE_TEST_CASES@+-- environment variable. Defaults to 50.+getTestCases :: IO Int+getTestCases = do+  val <- lookupEnv "CONFORMANCE_TEST_CASES"+  pure $ maybe 50 read val++-- | Format a list of key-value pairs as a JSON object string (one line).+-- Values are already pre-formatted as JSON (e.g. numbers without quotes,+-- strings with quotes, @\"null\"@ for null).+formatMetrics :: [(String, String)] -> String+formatMetrics pairs =+  "{" ++ intercalate ", " (map fmt pairs) ++ "}\n"+  where+    fmt (k, v) = "\"" ++ k ++ "\": " ++ v+    intercalate _ [] = ""+    intercalate sep (x : xs) = x ++ concatMap (sep ++) xs++-- | Write metrics to the file specified by @CONFORMANCE_METRICS_FILE@.+-- If the variable is not set, does nothing.+writeMetrics :: [(String, String)] -> IO ()+writeMetrics pairs = do+  mPath <- lookupEnv "CONFORMANCE_METRICS_FILE"+  case mPath of+    Nothing -> pure ()+    Just path ->+      withFile path AppendMode $ \h -> do+        hPutStr h (formatMetrics pairs)+        hFlush h
+ src/Hegel/Connection.hs view
@@ -0,0 +1,648 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE StrictData #-}++-- | Multiplexed connection and channel abstractions for Hegel.+--+-- This module implements a background-reader model: a dedicated thread reads+-- packets from the input stream and dispatches them to per-channel inboxes.+-- Channels use STM 'TQueue' for incoming messages.+--+-- A 'Connection' manages a pair of 'Handle's (one for reading, one for+-- writing) with multiplexed logical channels. A 'Channel' provides+-- request\/response messaging over a single logical channel.+module Hegel.Connection+  ( -- * Types+    Connection (..)+  , Channel (..)+  , InboxItem (..)+  , RequestError (..)+  , PendingRequest++    -- * Connection lifecycle+  , createConnection+  , closeConnection++    -- * Channel management+  , newChannel+  , connectChannel+  , closeChannel++    -- * Sending+  , sendPacket+  , sendRequest+  , sendRequestCBOR++    -- * Receiving+  , receiveResponse+  , receiveRequest+  , sendResponseValue++    -- * Handshake+  , sendHandshake++    -- * Result handling+  , resultOrError++    -- * Pending requests+  , request+  , pendingGet++    -- * Accessors+  , controlChannel+  , channelId+  , serverHasExited+  , setServerExited+  , isLive+  , sendRequestAndWait+  , sendResponseRaw+  , serverCrashedMessage+  ) where++import Codec.CBOR.Term (Term (..))+import Control.Concurrent.Async (Async, async, cancel)+import Control.Concurrent.MVar (MVar, newMVar, withMVar)+import Control.Concurrent.STM+  ( STM+  , TQueue+  , TVar+  , atomically+  , check+  , newTQueueIO+  , newTVarIO+  , orElse+  , readTQueue+  , readTVar+  , readTVarIO+  , registerDelay+  , writeTQueue+  , writeTVar+  , modifyTVar'+  )+import Control.Exception (Exception, SomeException, catch, throwIO)+import Control.Monad (unless, when)+import Data.Bits (shiftL, (.|.))+import Data.ByteString (ByteString)+import qualified Data.ByteString.Char8 as BS8+import Data.IORef (IORef, newIORef, readIORef, writeIORef)+import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import qualified Data.Text as T+import Data.Word (Word32)+import System.IO (Handle, hClose, hSetBinaryMode)++import Hegel.Protocol++-- ---------------------------------------------------------------------------+-- Constants+-- ---------------------------------------------------------------------------++-- | Handshake string sent by the client to initiate the protocol.+handshakeString :: ByteString+handshakeString = "hegel_handshake_start"++-- | Default timeout in microseconds for channel operations (30 seconds).+channelTimeoutMicros :: Int+channelTimeoutMicros = 30 * 1000000++-- | Message displayed when the server has crashed.+serverCrashedMessage :: String+serverCrashedMessage =+  "The hegel server process has exited unexpectedly. Check .hegel/server.log for details."++-- ---------------------------------------------------------------------------+-- Types+-- ---------------------------------------------------------------------------++-- | Sentinel value placed in a channel's inbox when the connection shuts down.+data InboxItem+  = Pkt Packet+  -- ^ A normal packet.+  | Shutdown+  -- ^ The connection has been closed.+  deriving (Show, Eq)++-- | Error response from the peer.+data RequestError = RequestError+  { reqErrorMessage :: String+  , reqErrorType :: String+  , reqErrorData :: [(Term, Term)]+  }+  deriving (Show)++instance Exception RequestError++-- | Multiplexed connection to a Hegel peer.+data Connection = Connection+  { connReadHandle :: Handle+  -- ^ Handle for reading packets.+  , connWriteHandle :: Handle+  -- ^ Handle for writing packets.+  , connNextChannelId :: TVar Int+  -- ^ Counter for allocating new channel IDs.+  , connChannels :: TVar (Map Word32 Channel)+  -- ^ Map from channel ID to live channel.+  , connRunning :: TVar Bool+  -- ^ Whether the connection is still active.+  , connServerExited :: TVar Bool+  -- ^ Whether the server process has exited unexpectedly.+  , connWriterLock :: MVar ()+  -- ^ Lock for serializing writes to the output handle.+  , connReaderThread :: Async ()+  -- ^ Background reader thread.+  , connDebug :: Bool+  -- ^ Whether debug logging is enabled.+  }++-- | Logical channel for request\/response messaging.+data Channel = Channel+  { chanId :: Word32+  -- ^ The channel ID.+  , chanConn :: Connection+  -- ^ The connection this channel belongs to.+  , chanInbox :: TQueue InboxItem+  -- ^ Inbox for incoming packets dispatched by the reader thread.+  , chanResponses :: TVar (Map Word32 ByteString)+  -- ^ Map from message ID to response payload (filled by 'processOneMessage').+  , chanRequests :: TQueue Packet+  -- ^ Queue of incoming request packets (filled by 'processOneMessage').+  , chanNextMessageId :: TVar Word32+  -- ^ Next message ID to use for outgoing requests.+  , chanClosed :: TVar Bool+  -- ^ Whether this channel has been closed.+  }++-- | A pending request handle that caches the decoded response.+data PendingRequest = PendingRequest+  { prChannel :: Channel+  , prMessageId :: Word32+  , prValue :: IORef (Maybe Term)+  }++-- ---------------------------------------------------------------------------+-- Connection lifecycle+-- ---------------------------------------------------------------------------++-- | Create a new connection using separate handles for reading and writing.+--+-- A control channel (channel 0) is automatically created and a background+-- reader thread is spawned. Both handles are set to binary mode.+createConnection :: Handle -> Handle -> Bool -> IO Connection+createConnection readH writeH debug = do+  hSetBinaryMode readH True+  hSetBinaryMode writeH True++  nextChanId <- newTVarIO 1+  channels <- newTVarIO Map.empty+  running <- newTVarIO True+  serverExited <- newTVarIO False+  writerLock <- newMVar ()++  -- Create a placeholder connection (reader thread filled in below)+  let mkConn reader = Connection+        { connReadHandle = readH+        , connWriteHandle = writeH+        , connNextChannelId = nextChanId+        , connChannels = channels+        , connRunning = running+        , connServerExited = serverExited+        , connWriterLock = writerLock+        , connReaderThread = reader+        , connDebug = debug+        }++  -- Create control channel (channel 0)+  controlInbox <- newTQueueIO+  controlResponses <- newTVarIO Map.empty+  controlRequests <- newTQueueIO+  controlNextMsgId <- newTVarIO 1+  controlClosed <- newTVarIO False++  -- We need the connection reference for the channel, but the connection+  -- needs the reader thread. Use a two-phase approach: create a dummy+  -- Async, build everything, then spawn the real reader.+  dummyAsync <- async (return ())++  let tempConn = mkConn dummyAsync++  let ctrl = Channel+        { chanId = 0+        , chanConn = tempConn+        , chanInbox = controlInbox+        , chanResponses = controlResponses+        , chanRequests = controlRequests+        , chanNextMessageId = controlNextMsgId+        , chanClosed = controlClosed+        }++  atomically $ writeTVar channels (Map.singleton 0 ctrl)++  -- Spawn background reader thread+  reader <- async (readerLoop tempConn)++  -- The connection returned uses the same TVars so the reader thread's+  -- reference via tempConn is equivalent. We return a version with the+  -- real Async handle for cleanup.+  let conn = mkConn reader++  -- Update the control channel to reference the real connection+  -- (shares the same TVars, so this is just for the Async handle)+  let ctrl' = ctrl { chanConn = conn }+  atomically $ writeTVar channels (Map.singleton 0 ctrl')++  return conn++-- | Close the connection and signal all live channels.+closeConnection :: Connection -> IO ()+closeConnection conn = do+  isRunning <- readTVarIO (connRunning conn)+  if not isRunning+    then return ()+    else do+      atomically $ writeTVar (connRunning conn) False+      catch (hClose (connReadHandle conn)) (\(_ :: SomeException) -> return ())+      -- Always attempt to close the write handle; if it is the same as+      -- the read handle, hClose will simply fail silently.+      catch (hClose (connWriteHandle conn)) (\(_ :: SomeException) -> return ())+      signalAllChannels conn+      cancel (connReaderThread conn)++-- ---------------------------------------------------------------------------+-- Background reader+-- ---------------------------------------------------------------------------++-- | Background reader thread function. Reads packets from the connection's+-- read handle and dispatches them to the appropriate channel's inbox.+-- Exits when the stream closes or an error occurs.+readerLoop :: Connection -> IO ()+readerLoop conn = do+  let loop = do+        isRunning <- readTVarIO (connRunning conn)+        when isRunning $ do+          pkt <- readPacket (connReadHandle conn)+          dispatchPacket conn pkt+          loop++  catch loop (\(_ :: SomeException) -> do+    atomically $ writeTVar (connServerExited conn) True)++  signalAllChannels conn++-- | Dispatch a single packet to the appropriate channel.+dispatchPacket :: Connection -> Packet -> IO ()+dispatchPacket conn pkt+  | packetPayload pkt == closeChannelPayload = return ()+  | otherwise = do+      mChan <- Map.lookup (packetChannelId pkt) <$> readTVarIO (connChannels conn)+      case mChan of+        Just ch -> atomically $ writeTQueue (chanInbox ch) (Pkt pkt)+        Nothing ->+          -- For non-reply messages to non-existent channels, send error back+          unless (packetIsReply pkt) $ do+            let errorMsg = "Message sent to non-existent channel "+                  ++ show (packetChannelId pkt)+                errorPayload = encodeTerm $+                  TMap [(TString "error", TString (T.pack errorMsg))]+            catch+              (sendPacket conn Packet+                { packetChannelId = packetChannelId pkt+                , packetMessageId = packetMessageId pkt+                , packetIsReply = True+                , packetPayload = errorPayload+                })+              (\(_ :: SomeException) -> return ())++-- | Push 'Shutdown' into all live channels' inboxes.+signalAllChannels :: Connection -> IO ()+signalAllChannels conn = do+  chans <- readTVarIO (connChannels conn)+  mapM_ (\ch -> atomically $ writeTQueue (chanInbox ch) Shutdown) (Map.elems chans)++-- ---------------------------------------------------------------------------+-- Channel management+-- ---------------------------------------------------------------------------++-- | Create a new logical channel on the connection.+--+-- Client channels use odd IDs: @(counter \<\< 1) | 1@.+newChannel :: Connection -> IO Channel+newChannel conn = do+  chanid <- atomically $ do+    counter <- readTVar (connNextChannelId conn)+    let cid = fromIntegral ((counter `shiftL` 1) .|. 1) :: Word32+    writeTVar (connNextChannelId conn) (counter + 1)+    return cid++  inbox <- newTQueueIO+  responses <- newTVarIO Map.empty+  requests <- newTQueueIO+  nextMsgId <- newTVarIO 1+  closed <- newTVarIO False++  let channel = Channel+        { chanId = chanid+        , chanConn = conn+        , chanInbox = inbox+        , chanResponses = responses+        , chanRequests = requests+        , chanNextMessageId = nextMsgId+        , chanClosed = closed+        }++  atomically $ modifyTVar' (connChannels conn) (Map.insert chanid channel)+  return channel++-- | Connect to a channel created by the peer (using the peer-assigned ID).+connectChannel :: Connection -> Word32 -> IO Channel+connectChannel conn cid = do+  exists <- atomically $ do+    chans <- readTVar (connChannels conn)+    return (Map.member cid chans)++  if exists+    then ioError $ userError $+      "Channel already connected as " ++ show cid ++ "."+    else do+      inbox <- newTQueueIO+      responses <- newTVarIO Map.empty+      requests <- newTQueueIO+      nextMsgId <- newTVarIO 1+      closed <- newTVarIO False++      let channel = Channel+            { chanId = cid+            , chanConn = conn+            , chanInbox = inbox+            , chanResponses = responses+            , chanRequests = requests+            , chanNextMessageId = nextMsgId+            , chanClosed = closed+            }++      atomically $ modifyTVar' (connChannels conn) (Map.insert cid channel)+      return channel++-- | Close a channel and notify the peer. Idempotent.+closeChannel :: Channel -> IO ()+closeChannel ch = do+  alreadyClosed <- readTVarIO (chanClosed ch)+  if alreadyClosed+    then return ()+    else do+      atomically $ writeTVar (chanClosed ch) True+      isRunning <- readTVarIO (connRunning (chanConn ch))+      when isRunning $+        sendPacket (chanConn ch) Packet+          { packetPayload = closeChannelPayload+          , packetMessageId = closeChannelMessageId+          , packetChannelId = chanId ch+          , packetIsReply = False+          }++-- ---------------------------------------------------------------------------+-- Sending+-- ---------------------------------------------------------------------------++-- | Send a packet on the connection, thread-safe via the writer lock.+sendPacket :: Connection -> Packet -> IO ()+sendPacket conn pkt =+  withMVar (connWriterLock conn) $ \() ->+    writePacket (connWriteHandle conn) pkt++-- | Send raw bytes as a request on a channel. Returns the message ID for+-- matching the response.+sendRequest :: Channel -> ByteString -> IO Word32+sendRequest ch payload = do+  msgId <- atomically $ do+    mid <- readTVar (chanNextMessageId ch)+    writeTVar (chanNextMessageId ch) (mid + 1)+    return mid+  sendPacket (chanConn ch) Packet+    { packetPayload = payload+    , packetChannelId = chanId ch+    , packetIsReply = False+    , packetMessageId = msgId+    }+  return msgId++-- | Send a CBOR-encoded request on a channel. Returns the message ID.+sendRequestCBOR :: Channel -> Term -> IO Word32+sendRequestCBOR ch term = sendRequest ch (encodeTerm term)++-- ---------------------------------------------------------------------------+-- Receiving+-- ---------------------------------------------------------------------------++-- | Pop an item from the channel's inbox, with a timeout in microseconds.+-- Returns 'Nothing' on timeout.+popInboxItem :: Channel -> Int -> IO InboxItem+popInboxItem ch timeoutUs = do+  timedOut <- registerDelay timeoutUs+  result <- atomically $+    (Just <$> readTQueue (chanInbox ch))+    `orElse`+    (do expired <- readTVar timedOut+        check expired+        return Nothing)+  case result of+    Just item -> return item+    Nothing -> do+      isClosed <- readTVarIO (chanClosed ch)+      if isClosed+        then ioError $ userError $ "Channel " ++ show (chanId ch) ++ " is closed"+        else do+          exited <- readTVarIO (connServerExited (chanConn ch))+          if exited+            then ioError $ userError serverCrashedMessage+            else ioError $ userError $+              "Timed out after " ++ show (timeoutUs `div` 1000000)+                ++ "s waiting for a message on channel " ++ show (chanId ch)++-- | Process one incoming message for a channel. Dispatches replies to the+-- responses map and requests to the requests queue.+processOneMessage :: Channel -> Int -> IO ()+processOneMessage ch timeoutUs = do+  item <- popInboxItem ch timeoutUs+  case item of+    Shutdown -> ioError $ userError "Connection closed"+    Pkt pkt ->+      if packetIsReply pkt+        then atomically $ modifyTVar' (chanResponses ch)+              (Map.insert (packetMessageId pkt) (packetPayload pkt))+        else atomically $ writeTQueue (chanRequests ch) pkt++-- | Wait for a raw response to a request with the given message ID.+receiveResponseRaw :: Channel -> Word32 -> Int -> IO ByteString+receiveResponseRaw ch msgId timeoutUs = do+  let waitLoop = do+        mResp <- atomically $ do+          resps <- readTVar (chanResponses ch)+          case Map.lookup msgId resps of+            Just payload -> do+              writeTVar (chanResponses ch) (Map.delete msgId resps)+              return (Just payload)+            Nothing -> return Nothing+        case mResp of+          Just payload -> return payload+          Nothing -> do+            processOneMessage ch timeoutUs+            waitLoop+  waitLoop++-- | Wait for and decode a response, extracting the result or raising+-- 'RequestError'.+receiveResponse :: Channel -> Word32 -> IO Term+receiveResponse ch msgId = do+  raw <- receiveResponseRaw ch msgId channelTimeoutMicros+  resultOrError (decodeTerm raw)++-- | Receive the next incoming request on a channel. Returns the message ID+-- and the decoded CBOR term.+receiveRequest :: Channel -> IO (Word32, Term)+receiveRequest ch = do+  let waitLoop = do+        mPkt <- atomically $ tryReadTQueue (chanRequests ch)+        case mPkt of+          Just pkt -> return (packetMessageId pkt, decodeTerm (packetPayload pkt))+          Nothing -> do+            processOneMessage ch channelTimeoutMicros+            waitLoop+  waitLoop+  where+    tryReadTQueue :: TQueue a -> STM (Maybe a)+    tryReadTQueue q = (Just <$> readTQueue q) `orElse` return Nothing++-- | Send a success response with a value wrapped as @{\"result\": value}@.+sendResponseValue :: Channel -> Word32 -> Term -> IO ()+sendResponseValue ch msgId value = do+  let payload = encodeTerm $ TMap [(TString "result", value)]+  sendPacket (chanConn ch) Packet+    { packetPayload = payload+    , packetChannelId = chanId ch+    , packetIsReply = True+    , packetMessageId = msgId+    }++-- ---------------------------------------------------------------------------+-- Result handling+-- ---------------------------------------------------------------------------++-- | Extract the @\"result\"@ field from a CBOR map, or throw 'RequestError'+-- if an @\"error\"@ field is present.+resultOrError :: Term -> IO Term+resultOrError body = do+  let pairs = extractMap body+      findText key = case lookup (TString key) pairs of+        Just v -> T.unpack (extractText v)+        Nothing -> ""+  case lookup (TString "error") pairs of+    Just _ -> do+      let msg = findText "error"+          errorType = findText "type"+          dat = filter (\(k, _) -> k /= TString "error" && k /= TString "type") pairs+      throwIO $ RequestError msg errorType dat+    Nothing ->+      case lookup (TString "result") pairs of+        Just v -> return v+        Nothing -> ioError $ userError "Response has neither 'result' nor 'error'"++-- ---------------------------------------------------------------------------+-- Pending requests+-- ---------------------------------------------------------------------------++-- | Send a CBOR request and return a 'PendingRequest' handle.+request :: Channel -> Term -> IO PendingRequest+request ch term = do+  msgId <- sendRequestCBOR ch term+  ref <- newIORef Nothing+  return PendingRequest+    { prChannel = ch+    , prMessageId = msgId+    , prValue = ref+    }++-- | Block until the response arrives and return the result.+--+-- Caches the response so subsequent calls return the same value or raise+-- the same error.+pendingGet :: PendingRequest -> IO Term+pendingGet pr = do+  cached <- readIORef (prValue pr)+  case cached of+    Just v -> resultOrError v+    Nothing -> do+      raw <- receiveResponseRaw (prChannel pr) (prMessageId pr) channelTimeoutMicros+      let v = decodeTerm raw+      writeIORef (prValue pr) (Just v)+      resultOrError v++-- ---------------------------------------------------------------------------+-- Handshake+-- ---------------------------------------------------------------------------++-- | Initiate the handshake as a client. Returns the server protocol version+-- string (e.g. @\"0.1\"@).+sendHandshake :: Connection -> IO String+sendHandshake conn = do+  -- Get control channel (channel 0)+  mCh <- atomically $ do+    chans <- readTVar (connChannels conn)+    return (Map.lookup 0 chans)+  ch <- case mCh of+    Just c -> return c+    Nothing -> ioError $ userError "Internal error: no control channel"++  msgId <- sendRequest ch handshakeString+  raw <- receiveResponseRaw ch msgId channelTimeoutMicros++  let response = BS8.unpack raw+      prefix = "Hegel/"+  if take 6 response /= prefix+    then ioError $ userError $ "Bad handshake response: " ++ show response+    else return (drop 6 response)++-- ---------------------------------------------------------------------------+-- Additional accessors+-- ---------------------------------------------------------------------------++-- | Get the control channel (channel 0).+controlChannel :: Connection -> IO Channel+controlChannel conn = do+  mCh <- atomically $ do+    chans <- readTVar (connChannels conn)+    return (Map.lookup 0 chans)+  case mCh of+    Just c -> return c+    Nothing -> ioError $ userError "Internal error: no control channel"++-- | Get the channel ID.+channelId :: Channel -> Word32+channelId = chanId++-- | Check if the server process has exited.+serverHasExited :: Connection -> IO Bool+serverHasExited conn = readTVarIO (connServerExited conn)++-- | Set the server-exited flag.+setServerExited :: Connection -> IO ()+setServerExited conn = atomically $ writeTVar (connServerExited conn) True++-- | Check if the connection is still active.+isLive :: Connection -> IO Bool+isLive conn = readTVarIO (connRunning conn)++-- | Send a CBOR request and wait for the decoded response (convenience).+sendRequestAndWait :: Channel -> Term -> IO Term+sendRequestAndWait ch msg = do+  msgId <- sendRequestCBOR ch msg+  receiveResponse ch msgId++-- | Send raw bytes as a reply.+sendResponseRaw :: Channel -> Word32 -> ByteString -> IO ()+sendResponseRaw ch msgId payload =+  sendPacket (chanConn ch) Packet+    { packetChannelId = chanId ch+    , packetMessageId = msgId+    , packetIsReply = True+    , packetPayload = payload+    }
+ src/Hegel/Generator.hs view
@@ -0,0 +1,385 @@+{-# LANGUAGE GADTs #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE ScopedTypeVariables #-}++-- | Core generator GADT and evaluation machinery.+--+-- Generators produce typed Haskell values by communicating with the Hegel+-- server over CBOR. The 'Generator' type is a GADT with six constructors+-- reflecting different generation strategies:+--+-- * 'Basic' holds a raw CBOR schema and a transform; the server generates the+--   value in one round-trip.+-- * 'Mapped' wraps a source generator and a transform function.+-- * 'FlatMapped' wraps a source and a function returning a generator.+-- * 'Filtered' wraps a source and a predicate (up to 'maxFilterAttempts' retries).+-- * 'CompositeList' uses the collection protocol for non-basic list elements.+-- * 'Composite' wraps a generation function inside a labelled span.+module Hegel.Generator+  ( -- * Generator type+    Generator (..)++    -- * Evaluation+  , draw++    -- * Combinators+  , gmap+  , flatMap+  , gfilter++    -- * Introspection+  , isBasic+  , asBasic++    -- * Span helpers+  , group+  , discardableGroup++    -- * Collection protocol+  , Collection (..)+  , newCollection+  , collectionMore+  , collectionReject++    -- * Span label constants+  , labelList+  , labelListElement+  , labelSet+  , labelSetElement+  , labelMap+  , labelMapEntry+  , labelTuple+  , labelOneOf+  , labelOptional+  , labelFixedDict+  , labelFlatMap+  , labelFilter+  , labelMapped+  , labelSampledFrom+  , labelEnumVariant++    -- * Constants+  , maxFilterAttempts+  ) where++import Codec.CBOR.Term (Term (..))+import Control.Exception (SomeException, catch, throwIO)+import Control.Monad (unless)+import Data.IORef++import Hegel.Client+  ( DataExhausted (..)+  , TestCase (..)+  , assume+  , generateFromSchema+  , startSpan+  , stopSpan+  )+import Hegel.Connection (RequestError (..), sendRequestAndWait)++-- ----------------------------------------------------------------------------+-- Span label constants+-- ----------------------------------------------------------------------------++-- | Span label for list generation.+labelList :: Int+labelList = 1++-- | Span label for individual list elements.+labelListElement :: Int+labelListElement = 2++-- | Span label for set generation.+labelSet :: Int+labelSet = 3++-- | Span label for individual set elements.+labelSetElement :: Int+labelSetElement = 4++-- | Span label for map (dictionary) generation.+labelMap :: Int+labelMap = 5++-- | Span label for individual map entries.+labelMapEntry :: Int+labelMapEntry = 6++-- | Span label for tuple generation.+labelTuple :: Int+labelTuple = 7++-- | Span label for one-of dispatch.+labelOneOf :: Int+labelOneOf = 8++-- | Span label for optional generation.+labelOptional :: Int+labelOptional = 9++-- | Span label for fixed-dict generation.+labelFixedDict :: Int+labelFixedDict = 10++-- | Span label for flat-map generation.+labelFlatMap :: Int+labelFlatMap = 11++-- | Span label for filter generation.+labelFilter :: Int+labelFilter = 12++-- | Span label for mapped generation.+labelMapped :: Int+labelMapped = 13++-- | Span label for sampled-from generation.+labelSampledFrom :: Int+labelSampledFrom = 14++-- | Span label for enum variant generation.+labelEnumVariant :: Int+labelEnumVariant = 15++-- | Maximum number of filter attempts before calling @assume False@.+maxFilterAttempts :: Int+maxFilterAttempts = 3++-- ----------------------------------------------------------------------------+-- Generator GADT+-- ----------------------------------------------------------------------------++-- | A generator that produces values of type @a@.+--+-- The GADT constructors capture different generation strategies. 'Basic'+-- generators carry a CBOR schema sent to the server; the server returns a raw+-- 'Term' that is decoded by the transform. Other constructors compose+-- generators with client-side logic.+data Generator a where+  -- | A server-side generator with a CBOR schema and a client-side transform.+  Basic :: Term -> (Term -> a) -> Generator a+  -- | A mapped generator: apply @f@ to the result of the source.+  Mapped :: Generator b -> (b -> a) -> Generator a+  -- | A dependent generator: generate from source, then use the result to+  -- pick a second generator.+  FlatMapped :: Generator b -> (b -> Generator a) -> Generator a+  -- | A filtered generator: retry the source until the predicate holds.+  Filtered :: Generator a -> (a -> Bool) -> Generator a+  -- | A composite list generator using the collection protocol.+  CompositeList :: Generator a -> Int -> Maybe Int -> Generator [a]+  -- | A composite generator that runs an arbitrary IO action inside a+  -- labelled span.+  Composite :: Int -> (TestCase -> IO a) -> Generator a++instance Functor Generator where+  fmap f (Basic s t) = Basic s (f . t)  -- preserves schema+  fmap f other       = Mapped other f++-- ----------------------------------------------------------------------------+-- Span helpers+-- ----------------------------------------------------------------------------++-- | Run an IO action inside a span with the given label.+-- The span is stopped with @discard=False@ regardless of whether the action+-- throws.+group :: Int -> TestCase -> IO a -> IO a+group label tc action = do+  startSpan label tc+  result <- action `catch` \(e :: SomeException) -> do+    stopSpan False tc+    throwIO e+  stopSpan False tc+  return result++-- | Run an IO action inside a span with the given label. If the action+-- throws, the span is stopped with @discard=True@; otherwise @discard=False@.+discardableGroup :: Int -> TestCase -> IO a -> IO a+discardableGroup label tc action = do+  startSpan label tc+  result <- action `catch` \(e :: SomeException) -> do+    stopSpan True tc+    throwIO e+  stopSpan False tc+  return result++-- ----------------------------------------------------------------------------+-- Collection protocol+-- ----------------------------------------------------------------------------++-- | A server-side collection handle for generating variable-length sequences.+--+-- Collections communicate with the server to decide when to stop generating+-- elements. The 'collFinished' flag short-circuits subsequent 'collectionMore'+-- calls once the server signals completion.+data Collection = Collection+  { collFinished   :: !(IORef Bool)+  , collServerName :: !(IORef (Maybe Term))+  , collMinSize    :: !Int+  , collMaxSize    :: !(Maybe Int)+  }++-- | Create a new collection handle.+newCollection :: Int -> Maybe Int -> TestCase -> IO Collection+newCollection minSize maxSize _tc = do+  finished   <- newIORef False+  serverName <- newIORef Nothing+  return Collection+    { collFinished   = finished+    , collServerName = serverName+    , collMinSize    = minSize+    , collMaxSize    = maxSize+    }++-- | Send a command on the test case's data channel. Handles StopTest.+sendCommand :: TestCase -> Term -> IO Term+sendCommand tc msg = do+  aborted <- readIORef (tcTestAborted tc)+  if aborted+    then throwIO DataExhausted+    else sendRequestAndWait (tcChannel tc) msg+           `catch` \(e :: RequestError) ->+             if reqErrorType e == "StopTest"+               then do+                 writeIORef (tcTestAborted tc) True+                 throwIO DataExhausted+               else throwIO e++-- | Lazily initialise the server-side collection and return its handle.+getServerName :: Collection -> TestCase -> IO Term+getServerName coll tc = do+  mName <- readIORef (collServerName coll)+  case mName of+    Just name -> return name+    Nothing -> do+      let maxSizeVal = maybe TNull TInt (collMaxSize coll)+      let cmd = TMap+            [ (TString "command",  TString "new_collection")+            , (TString "min_size", TInt (collMinSize coll))+            , (TString "max_size", maxSizeVal)+            ]+      result <- sendCommand tc cmd+      writeIORef (collServerName coll) (Just result)+      return result++-- | Query the server for whether more elements should be generated.+-- Once this returns 'False', subsequent calls return 'False' immediately.+collectionMore :: Collection -> TestCase -> IO Bool+collectionMore coll tc = do+  done <- readIORef (collFinished coll)+  if done+    then return False+    else do+      serverName <- getServerName coll tc+      let cmd = TMap+            [ (TString "command",    TString "collection_more")+            , (TString "collection", serverName)+            ]+      result <- sendCommand tc cmd+      let more = case result of+            TBool b -> b+            _       -> error "collectionMore: expected boolean from server"+      unless more $ writeIORef (collFinished coll) True+      return more++-- | Reject the last element of the collection. No-op if already finished.+collectionReject :: Collection -> TestCase -> IO ()+collectionReject coll tc = do+  done <- readIORef (collFinished coll)+  if done+    then return ()+    else do+      serverName <- getServerName coll tc+      let cmd = TMap+            [ (TString "command",    TString "collection_reject")+            , (TString "collection", serverName)+            ]+      _ <- sendCommand tc cmd+      return ()++-- ----------------------------------------------------------------------------+-- Draw (evaluation)+-- ----------------------------------------------------------------------------++-- | Produce a typed value from a generator using the given test case.+draw :: TestCase -> Generator a -> IO a+draw tc gen = case gen of+  Basic schema transform -> do+    raw <- generateFromSchema schema tc+    return (transform raw)++  Mapped source f ->+    group labelMapped tc $ do+      v <- draw tc source+      return (f v)++  FlatMapped source f ->+    discardableGroup labelFlatMap tc $ do+      v <- draw tc source+      draw tc (f v)++  Filtered source predicate ->+    let attempt i+          | i > maxFilterAttempts = do { assume tc False; error "unreachable" }+          | otherwise = do+              startSpan labelFilter tc+              v <- draw tc source+              if predicate v+                then do+                  stopSpan False tc+                  return v+                else do+                  stopSpan True tc+                  attempt (i + 1)+    in attempt 1++  CompositeList elements minSize maxSize ->+    group labelList tc $ do+      coll <- newCollection minSize maxSize tc+      let collect acc = do+            more <- collectionMore coll tc+            if more+              then do+                v <- draw tc elements+                collect (v : acc)+              else return (reverse acc)+      collect []++  Composite label generateFn ->+    group label tc (generateFn tc)++-- ----------------------------------------------------------------------------+-- Combinator functions+-- ----------------------------------------------------------------------------++-- | Map a function over a generator's output.+--+-- When the generator is 'Basic', the schema is preserved and transforms are+-- composed. Otherwise a 'Mapped' wrapper is created.+gmap :: (a -> b) -> Generator a -> Generator b+gmap f (Basic schema transform) = Basic schema (f . transform)+gmap f other                    = Mapped other f++-- | Create a dependent generator. The function receives the generated value+-- and returns a new generator whose output is the final result.+flatMap :: (a -> Generator b) -> Generator a -> Generator b+flatMap f gen = FlatMapped gen f++-- | Filter a generator's output with a predicate. Retries up to+-- 'maxFilterAttempts' times; if all attempts fail, @assume False@ is called.+gfilter :: (a -> Bool) -> Generator a -> Generator a+gfilter predicate gen = Filtered gen predicate++-- ----------------------------------------------------------------------------+-- Introspection+-- ----------------------------------------------------------------------------++-- | Returns 'True' if the generator is a 'Basic' generator.+isBasic :: Generator a -> Bool+isBasic (Basic _ _) = True+isBasic _           = False++-- | Returns the schema and transform of a 'Basic' generator, or 'Nothing'.+asBasic :: Generator a -> Maybe (Term, Term -> a)+asBasic (Basic schema transform) = Just (schema, transform)+asBasic _                        = Nothing
+ src/Hegel/Generator/Collections.hs view
@@ -0,0 +1,88 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Collection generators: lists and hashmaps.+--+-- These generators handle variable-length collections. When all element+-- generators are 'Basic', a single schema is sent to the server for efficient+-- generation. When elements are non-basic (e.g. filtered or flat-mapped), the+-- collection protocol is used to generate elements one at a time.+module Hegel.Generator.Collections+  ( lists+  , hashmaps+  ) where++import Codec.CBOR.Term (Term (..))+import Data.Maybe (catMaybes)++import Hegel.Generator (Generator (..), asBasic)+import Hegel.Generator.Range (RangeOpts, validateSizeBounds)++-- ----------------------------------------------------------------------------+-- Lists+-- ----------------------------------------------------------------------------++-- | Generator for lists of elements.+--+-- Two code paths:+--+-- 1. If the element generator is 'Basic': sends a @list@ schema to the server+--    and lets it generate the entire list in one round-trip. The element+--    transform is lifted to apply to every item.+--+-- 2. If the element generator is non-basic: uses 'CompositeList' with the+--    collection protocol to generate elements one at a time.+lists :: Generator a -> RangeOpts Int -> Generator [a]+lists elements opts =+  let (ms, maxSize) = validateSizeBounds "lists" opts+  in+  case asBasic elements of+    Just (elemSchema, elemTransform) ->+      let pairs = catMaybes+            [ Just (TString "type",     TString "list")+            , Just (TString "elements", elemSchema)+            , Just (TString "min_size", TInt ms)+            , fmap (\mx -> (TString "max_size", TInt mx)) maxSize+            ]+          listTransform raw = case raw of+            TList items -> map elemTransform items+            _           -> error "lists: expected array from server"+      in Basic (TMap pairs) listTransform++    Nothing ->+      CompositeList elements ms maxSize++-- ----------------------------------------------------------------------------+-- Hashmaps+-- ----------------------------------------------------------------------------++-- | Generator for dictionaries (hash maps) as lists of key-value pairs.+--+-- Both the key and value generators must be 'Basic'. The server returns the+-- dict as a list of @[key, value]@ pairs, which are transformed to Haskell+-- tuples.+--+-- Schema: @{\"type\": \"dict\", \"keys\": kSchema, \"values\": vSchema,+-- \"min_size\": N, \"max_size\"?: N}@+hashmaps :: Generator k -> Generator v -> RangeOpts Int -> Generator [(k, v)]+hashmaps keys values opts =+  let (ms, maxSize) = validateSizeBounds "hashmaps" opts in+  let (keySchema, keyTransform) = case asBasic keys of+        Just (s, t) -> (s, t)+        Nothing     -> error "hashmaps: keys generator must be a Basic generator"+      (valSchema, valTransform) = case asBasic values of+        Just (s, t) -> (s, t)+        Nothing     -> error "hashmaps: values generator must be a Basic generator"+      pairs = catMaybes+        [ Just (TString "type",     TString "dict")+        , Just (TString "keys",     keySchema)+        , Just (TString "values",   valSchema)+        , Just (TString "min_size", TInt ms)+        , fmap (\mx -> (TString "max_size", TInt mx)) maxSize+        ]+      transform raw = case raw of+        TList kvPairs -> map transformPair kvPairs+        _             -> error "hashmaps: expected array from server"+      transformPair pair = case pair of+        TList [k, v] -> (keyTransform k, valTransform v)+        _            -> error "hashmaps: expected [k, v] pair from server"+  in Basic (TMap pairs) transform
+ src/Hegel/Generator/Combinators.hs view
@@ -0,0 +1,231 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}++-- | Combinator generators: sampling, alternation, optionality, IP addresses,+-- and tuples.+--+-- These generators compose primitive and collection generators into richer+-- generation strategies. When all constituent generators are 'Basic', a single+-- compound schema is sent to the server. When any generator is non-basic, a+-- 'Composite' wrapper is used with client-side orchestration.+module Hegel.Generator.Combinators+  ( sampledFrom+  , oneOf+  , optional+  , ipAddresses+  , tuples2+  , tuples3+  , tuples4+  ) where++import Codec.CBOR.Term (Term (..))+import Data.Array (listArray, (!))+import Data.Default (def)+import Data.Maybe (catMaybes, isJust)+import Data.Text (Text)++import Hegel.Client (TestCase, generateFromSchema)+import Hegel.Generator+  ( Generator (..)+  , asBasic+  , draw+  , labelOneOf+  , labelTuple+  )+import Hegel.Generator.Primitives+  ( RangeOpts (..)+  , integers+  , ipv4Addresses+  , ipv6Addresses+  , just+  )++-- ----------------------------------------------------------------------------+-- CBOR extraction helper+-- ----------------------------------------------------------------------------++-- | Extract an 'Int' from a CBOR term.+extractInt :: Term -> Int+extractInt (TInt n)     = n+extractInt (TInteger n) = fromIntegral n+extractInt t            = error $ "extractInt: expected TInt/TInteger, got " ++ show t++-- ----------------------------------------------------------------------------+-- sampledFrom+-- ----------------------------------------------------------------------------++-- | Generator that samples uniformly from a non-empty list of values.+--+-- Implemented as an integer index generator: picks an index in @[0, n-1]@ and+-- returns the corresponding element from an array.+sampledFrom :: [a] -> Generator a+sampledFrom [] = error "sampledFrom: requires at least one element"+sampledFrom options =+  let n   = length options+      arr = listArray (0, n - 1) options+  in fmap (arr !) (integers def+      { rangeMin = Just 0+      , rangeMax = Just (n - 1)+      })++-- ----------------------------------------------------------------------------+-- oneOf+-- ----------------------------------------------------------------------------++-- | Generator that picks from one of the given generators.+--+-- Two code paths:+--+-- 1. All generators are 'Basic': uses a tagged-tuple dispatch schema.+--    Each alternative is wrapped as @{\"type\": \"tuple\", \"elements\":+--    [{\"const\": tag}, schema]}@. The server returns @[tag, value]@ and the+--    client dispatches to the correct transform.+--+-- 2. Any generator is non-basic: generates an index inside a 'labelOneOf'+--    span, then delegates to the selected generator.+oneOf :: [Generator a] -> Generator a+oneOf [] = error "oneOf: requires at least one generator"+oneOf generators =+  let basics   = map asBasic generators+      allBasic = all isJust basics+  in+  if not allBasic+    then+      -- Composite path: generate index, then delegate+      let gens = listArray (0, n - 1) generators+      in Composite labelOneOf $ \tc -> do+           idx <- drawIndex n tc+           draw tc (gens ! idx)+    else+      -- Tagged-tuple path+      let justBasics = catMaybes basics+          taggedSchemas = zipWith mkTaggedSchema [0..] (map fst justBasics)+          transforms    = listArray (0, length justBasics - 1) (map snd justBasics)+          dispatch raw = case raw of+            TList [tag, value] ->+              let idx = extractInt tag+              in (transforms ! idx) value+            _ -> error "oneOf: expected [tag, value] tuple from server"+      in Basic+           (TMap [(TString "one_of", TList taggedSchemas)])+           dispatch+  where+    n = length generators++    drawIndex :: Int -> TestCase -> IO Int+    drawIndex count tc = do+      let schema = TMap+            [ (TString "type",      TString "integer")+            , (TString "min_value", TInt 0)+            , (TString "max_value", TInt (count - 1))+            ]+      raw <- generateFromSchema schema tc+      return (extractInt raw)++    mkTaggedSchema :: Int -> Term -> Term+    mkTaggedSchema i elemSchema = TMap+      [ (TString "type",     TString "tuple")+      , (TString "elements", TList+          [ TMap [(TString "const", TInt i)]+          , elemSchema+          ])+      ]++-- ----------------------------------------------------------------------------+-- optional+-- ----------------------------------------------------------------------------++-- | Generator that produces either 'Nothing' or @'Just' value@.+--+-- Equivalent to @oneOf [just Nothing, fmap Just gen]@.+optional :: Generator a -> Generator (Maybe a)+optional gen = oneOf [just Nothing, fmap Just gen]++-- ----------------------------------------------------------------------------+-- IP addresses+-- ----------------------------------------------------------------------------++-- | Generator for IP address strings.+--+-- * @Nothing@: generates either IPv4 or IPv6 addresses.+-- * @Just 4@: generates IPv4 addresses only.+-- * @Just 6@: generates IPv6 addresses only.+ipAddresses :: Maybe Int -> Generator Text+ipAddresses Nothing  = oneOf [ipv4Addresses, ipv6Addresses]+ipAddresses (Just 4) = ipv4Addresses+ipAddresses (Just 6) = ipv6Addresses+ipAddresses (Just v) = error $ "ipAddresses: invalid version " ++ show v++-- ----------------------------------------------------------------------------+-- Tuples+-- ----------------------------------------------------------------------------++-- | Generator for 2-element tuples.+--+-- When both elements are 'Basic', a single @tuple@ schema is sent.+-- Otherwise, elements are generated separately inside a 'labelTuple' span.+tuples2 :: Generator a -> Generator b -> Generator (a, b)+tuples2 g1 g2 =+  case (asBasic g1, asBasic g2) of+    (Just (s1, t1), Just (s2, t2)) ->+      let schema = TMap+            [ (TString "type",     TString "tuple")+            , (TString "elements", TList [s1, s2])+            ]+          transform raw = case raw of+            TList [v1, v2] -> (t1 v1, t2 v2)+            _ -> error "tuples2: expected 2-element array from server"+      in Basic schema transform+    _ ->+      Composite labelTuple $ \tc -> do+        a <- draw tc g1+        b <- draw tc g2+        return (a, b)++-- | Generator for 3-element tuples.+--+-- When all elements are 'Basic', a single @tuple@ schema is sent.+-- Otherwise, elements are generated separately inside a 'labelTuple' span.+tuples3 :: Generator a -> Generator b -> Generator c -> Generator (a, b, c)+tuples3 g1 g2 g3 =+  case (asBasic g1, asBasic g2, asBasic g3) of+    (Just (s1, t1), Just (s2, t2), Just (s3, t3)) ->+      let schema = TMap+            [ (TString "type",     TString "tuple")+            , (TString "elements", TList [s1, s2, s3])+            ]+          transform raw = case raw of+            TList [v1, v2, v3] -> (t1 v1, t2 v2, t3 v3)+            _ -> error "tuples3: expected 3-element array from server"+      in Basic schema transform+    _ ->+      Composite labelTuple $ \tc -> do+        a <- draw tc g1+        b <- draw tc g2+        c <- draw tc g3+        return (a, b, c)++-- | Generator for 4-element tuples.+--+-- When all elements are 'Basic', a single @tuple@ schema is sent.+-- Otherwise, elements are generated separately inside a 'labelTuple' span.+tuples4 :: Generator a -> Generator b -> Generator c -> Generator d+        -> Generator (a, b, c, d)+tuples4 g1 g2 g3 g4 =+  case (asBasic g1, asBasic g2, asBasic g3, asBasic g4) of+    (Just (s1, t1), Just (s2, t2), Just (s3, t3), Just (s4, t4)) ->+      let schema = TMap+            [ (TString "type",     TString "tuple")+            , (TString "elements", TList [s1, s2, s3, s4])+            ]+          transform raw = case raw of+            TList [v1, v2, v3, v4] -> (t1 v1, t2 v2, t3 v3, t4 v4)+            _ -> error "tuples4: expected 4-element array from server"+      in Basic schema transform+    _ ->+      Composite labelTuple $ \tc -> do+        a <- draw tc g1+        b <- draw tc g2+        c <- draw tc g3+        d <- draw tc g4+        return (a, b, c, d)
+ src/Hegel/Generator/Primitives.hs view
@@ -0,0 +1,303 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Primitive generators for basic Haskell types.+--+-- All generators in this module use the 'Basic' constructor, sending a CBOR+-- schema to the server and decoding the response with a type-appropriate+-- transform.+module Hegel.Generator.Primitives+  ( -- * Scalar generators+    booleans+  , integers+  , floats+  , text+  , binary+  , just++    -- * Range options+  , RangeOpts (..)+  , defaultRangeOpts++    -- * Float options+  , FloatOpts (..)+  , defaultFloatOpts++    -- * Format generators+  , emails+  , urls+  , dates+  , times+  , datetimes+  , domains+  , ipv4Addresses+  , ipv6Addresses+  , fromRegex+  ) where++import Codec.CBOR.Term (Term (..))+import Data.ByteString (ByteString)+import Data.Default (Default (def))+import Data.Maybe (catMaybes, isJust)+import Data.Text (Text)++import Hegel.Generator (Generator (..))+import Hegel.Generator.Range+  ( RangeOpts (..)+  , defaultRangeOpts+  , validateSizeBounds+  )++-- ----------------------------------------------------------------------------+-- CBOR extraction helpers+-- ----------------------------------------------------------------------------++-- | Extract a 'Bool' from a CBOR term.+extractBool :: Term -> Bool+extractBool (TBool b) = b+extractBool t         = error $ "extractBool: expected TBool, got " ++ show t++-- | Extract an 'Int' from a CBOR term.+extractInt :: Term -> Int+extractInt (TInt n)     = n+extractInt (TInteger n) = fromIntegral n+extractInt t            = error $ "extractInt: expected TInt/TInteger, got " ++ show t++-- | Extract a 'Double' from a CBOR term.+extractFloat :: Term -> Double+extractFloat (THalf f)    = realToFrac f+extractFloat (TFloat f)   = realToFrac f+extractFloat (TDouble f)  = f+extractFloat (TInt n)     = fromIntegral n+extractFloat (TInteger n) = fromIntegral n+extractFloat t            = error $ "extractFloat: expected float term, got " ++ show t++-- | Extract 'Text' from a CBOR term.+extractText :: Term -> Text+extractText (TString s) = s+extractText t           = error $ "extractText: expected TString, got " ++ show t++-- | Extract 'ByteString' from a CBOR term.+extractBytes :: Term -> ByteString+extractBytes (TBytes b) = b+extractBytes t          = error $ "extractBytes: expected TBytes, got " ++ show t++-- ----------------------------------------------------------------------------+-- Scalar generators+-- ----------------------------------------------------------------------------++-- | Generator for boolean values.+--+-- Schema: @{\"type\": \"boolean\"}@+booleans :: Generator Bool+booleans = Basic+  (TMap [(TString "type", TString "boolean")])+  extractBool++-- | Generator for integer values within optional bounds.+--+-- Schema: @{\"type\": \"integer\", \"min_value\"?: N, \"max_value\"?: N}@+integers :: RangeOpts Int -> Generator Int+integers RangeOpts {rangeMin = minVal, rangeMax = maxVal} =+  case (minVal, maxVal) of+    (Just lo, Just hi) | lo > hi ->+      error $ "integers: max_value=" ++ show hi ++ " < min_value=" ++ show lo+    _ -> ()+  `seq` Basic (TMap pairs) extractInt+  where+    pairs = catMaybes+      [ Just (TString "type", TString "integer")+      , fmap (\v -> (TString "min_value", TInt v)) minVal+      , fmap (\v -> (TString "max_value", TInt v)) maxVal+      ]++-- | Options for the floating-point generator.+data FloatOpts = FloatOpts+  { floatBounds        :: !(RangeOpts Double)+  , floatExcludeMin    :: !Bool+  , floatExcludeMax    :: !Bool+  , floatAllowNan      :: !(Maybe Bool)+  , floatAllowInfinity :: !(Maybe Bool)+  } deriving (Eq, Show)++instance Default FloatOpts where+  def = FloatOpts+    { floatBounds        = def+    , floatExcludeMin    = False+    , floatExcludeMax    = False+    , floatAllowNan      = Nothing+    , floatAllowInfinity = Nothing+    }++-- | Default float options: no bounds, no exclusions, nan/infinity determined+-- by bounds. Equivalent to 'def'; new code can use 'def' directly.+defaultFloatOpts :: FloatOpts+defaultFloatOpts = def++-- | Generator for floating-point values.+--+-- Schema: @{\"type\": \"float\", \"allow_nan\": bool, \"allow_infinity\": bool,+-- \"width\": 64, \"exclude_min\": bool, \"exclude_max\": bool,+-- \"min_value\"?: f, \"max_value\"?: f}@+--+-- Defaults follow Hypothesis:+--+-- * @allow_nan@: 'True' only when no bounds are set+-- * @allow_infinity@: 'True' when at most one bound is set+floats :: FloatOpts -> Generator Double+floats FloatOpts+  { floatBounds = RangeOpts {rangeMin = minVal, rangeMax = maxVal}+  , floatExcludeMin = excludeMin+  , floatExcludeMax = excludeMax+  , floatAllowNan = allowNan+  , floatAllowInfinity = allowInfinity+  } =+  -- Validation+  (if effAllowNan && (hasMin || hasMax)+     then error "floats: cannot have allow_nan=True with min_value or max_value"+     else ())+  `seq`+  (case (minVal, maxVal) of+     (Just lo, Just hi) | lo > hi ->+       error $ "floats: no floats between min_value=" ++ show lo+            ++ " and max_value=" ++ show hi+     _ -> ())+  `seq`+  (if effAllowInfinity && hasMin && hasMax+     then error "floats: cannot have allow_infinity=True with both min_value and max_value"+     else ())+  `seq`+  Basic (TMap pairs) extractFloat+  where+    hasMin = isJust minVal+    hasMax = isJust maxVal+    effAllowNan = case allowNan of+      Just v  -> v+      Nothing -> not hasMin && not hasMax+    effAllowInfinity = case allowInfinity of+      Just v  -> v+      Nothing -> not hasMin || not hasMax+    pairs =+      [ (TString "type",           TString "float")+      , (TString "allow_nan",      TBool effAllowNan)+      , (TString "allow_infinity", TBool effAllowInfinity)+      , (TString "exclude_min",    TBool excludeMin)+      , (TString "exclude_max",    TBool excludeMax)+      , (TString "width",          TInt 64)+      ]+      ++ catMaybes+      [ fmap (\v -> (TString "min_value", TDouble v)) minVal+      , fmap (\v -> (TString "max_value", TDouble v)) maxVal+      ]++-- | Generator for Unicode text strings.+--+-- Schema: @{\"type\": \"string\", \"min_size\": N, \"max_size\"?: N}@+text :: RangeOpts Int -> Generator Text+text opts =+  let (ms, maxSize) = validateSizeBounds "text" opts+      pairs = catMaybes+        [ Just (TString "type",     TString "string")+        , Just (TString "min_size", TInt ms)+        , fmap (\v -> (TString "max_size", TInt v)) maxSize+        ]+  in+  Basic (TMap pairs) extractText++-- | Generator for binary byte strings.+--+-- Schema: @{\"type\": \"binary\", \"min_size\": N, \"max_size\"?: N}@+binary :: RangeOpts Int -> Generator ByteString+binary opts =+  let (ms, maxSize) = validateSizeBounds "binary" opts+      pairs = catMaybes+        [ Just (TString "type",     TString "binary")+        , Just (TString "min_size", TInt ms)+        , fmap (\v -> (TString "max_size", TInt v)) maxSize+        ]+  in+  Basic (TMap pairs) extractBytes++-- | Generator that always produces the given constant value.+--+-- Schema: @{\"const\": null}@. The transform ignores the server result.+just :: a -> Generator a+just value = Basic+  (TMap [(TString "const", TNull)])+  (const value)++-- ----------------------------------------------------------------------------+-- Format generators+-- ----------------------------------------------------------------------------++-- | Generator for valid email address strings.+emails :: Generator Text+emails = Basic+  (TMap [(TString "type", TString "email")])+  extractText++-- | Generator for valid URL strings.+urls :: Generator Text+urls = Basic+  (TMap [(TString "type", TString "url")])+  extractText++-- | Generator for ISO 8601 date strings (YYYY-MM-DD).+dates :: Generator Text+dates = Basic+  (TMap [(TString "type", TString "date")])+  extractText++-- | Generator for time strings.+times :: Generator Text+times = Basic+  (TMap [(TString "type", TString "time")])+  extractText++-- | Generator for ISO 8601 datetime strings.+datetimes :: Generator Text+datetimes = Basic+  (TMap [(TString "type", TString "datetime")])+  extractText++-- | Generator for domain name strings.+--+-- If a maximum length is provided, generated domains will not exceed that+-- length. Valid range: 4 to 255.+domains :: Maybe Int -> Generator Text+domains maxLength =+  (case maxLength of+     Just ml | ml < 4 || ml > 255 ->+       error $ "domains: max_length=" ++ show ml ++ " must be between 4 and 255"+     _ -> ())+  `seq`+  Basic (TMap pairs) extractText+  where+    pairs = catMaybes+      [ Just (TString "type", TString "domain")+      , fmap (\ml -> (TString "max_length", TInt ml)) maxLength+      ]++-- | Generator for IPv4 address strings (dotted decimal).+ipv4Addresses :: Generator Text+ipv4Addresses = Basic+  (TMap [(TString "type", TString "ipv4")])+  extractText++-- | Generator for IPv6 address strings (colon hex).+ipv6Addresses :: Generator Text+ipv6Addresses = Basic+  (TMap [(TString "type", TString "ipv6")])+  extractText++-- | Generator for strings matching a regular expression.+--+-- When @fullmatch@ is 'True', the entire string must match the pattern.+-- When 'False', a substring match suffices.+fromRegex :: Text -> Bool -> Generator Text+fromRegex regexPattern fullmatch = Basic+  (TMap+    [ (TString "type",      TString "regex")+    , (TString "pattern",   TString regexPattern)+    , (TString "fullmatch", TBool fullmatch)+    ])+  extractText
+ src/Hegel/Generator/Range.hs view
@@ -0,0 +1,55 @@+-- | Shared range option types and validation helpers.+module Hegel.Generator.Range+  ( RangeOpts (..)+  , defaultRangeOpts+  , validateSizeBounds+  ) where++import Data.Default (Default (def))+import Data.Maybe (fromMaybe)++-- | Configuration for lower and upper bounds used by range-limited generators.+--+-- Use 'def' for the default unbounded case, or update specific fields:+--+-- @+-- integers def { rangeMin = Just 0, rangeMax = Just 100 }+-- text def { rangeMin = Just 1, rangeMax = Just 10 }+-- @+--+-- Collection-like generators interpret these fields as @min_size@ and+-- @max_size@ bounds.+data RangeOpts a = RangeOpts+  { rangeMin :: !(Maybe a)+  -- ^ Inclusive lower bound when present.+  , rangeMax :: !(Maybe a)+  -- ^ Inclusive upper bound when present.+  } deriving (Eq, Show)++instance Default (RangeOpts a) where+  def = RangeOpts+    { rangeMin = Nothing+    , rangeMax = Nothing+    }++-- | Convenience alias for 'def'.+defaultRangeOpts :: RangeOpts a+defaultRangeOpts = def++-- | Validate non-negative size bounds and return the normalized min/max pair.+validateSizeBounds :: String -> RangeOpts Int -> (Int, Maybe Int)+validateSizeBounds label RangeOpts {rangeMin = minSize, rangeMax = maxSize} =+  (if minSize' < 0+     then error $ label ++ ": min_size=" ++ show minSize' ++ " must be non-negative"+     else ())+  `seq`+  (case maxSize of+     Just mx | mx < 0 ->+       error $ label ++ ": max_size=" ++ show mx ++ " must be non-negative"+     Just mx | minSize' > mx ->+       error $ label ++ ": max_size=" ++ show mx ++ " < min_size=" ++ show minSize'+     _ -> ())+  `seq`+  (minSize', maxSize)+  where+    minSize' = fromMaybe 0 minSize
+ src/Hegel/Protocol.hs view
@@ -0,0 +1,357 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE StrictData #-}++-- | Wire protocol for Hegel.+--+-- This module defines the binary packet format used for communication between+-- the Hegel client and the Hegel server. Messages are serialized as packets+-- with a 20-byte header, a CBOR-encoded payload, and a terminator byte.+--+-- The header consists of 5 big-endian unsigned 32-bit integers:+--+--   * Magic number (0x4845474C, \"HEGL\")+--   * CRC32 checksum+--   * Channel ID+--   * Message ID (high bit = reply flag)+--   * Payload length+module Hegel.Protocol+  ( -- * Packet type+    Packet (..)++    -- * Constants+  , magic+  , headerSize+  , terminator+  , replyBit+  , closeChannelMessageId+  , closeChannelPayload++    -- * Packet I\/O+  , readPacket+  , writePacket++    -- * CRC32+  , computeCRC32+  , computeCRC32Parts++    -- * CBOR helpers+  , extractInt+  , extractFloat+  , extractBool+  , extractText+  , extractBytes+  , extractList+  , extractMap+  , encodeTerm+  , decodeTerm+  ) where++import Codec.CBOR.Read (deserialiseFromBytes)+import Codec.CBOR.Term (Term (..))+import qualified Codec.CBOR.Term as CBOR+import qualified Codec.CBOR.Write as CBORWrite+import Control.Monad (when)+import Data.Bits ((.&.), (.|.), shiftL, shiftR, testBit, xor)+import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Internal as BSInternal+import qualified Data.ByteString.Lazy as LBS+import Data.Digest.CRC32 (crc32, crc32Update)+import Data.Text (Text)+import Data.Word (Word32, Word8)+import Foreign.Ptr (Ptr)+import Foreign.Storable (pokeByteOff)+import System.IO (Handle, hFlush)++-- ---------------------------------------------------------------------------+-- Constants+-- ---------------------------------------------------------------------------++-- | Magic number identifying Hegel packets (\"HEGL\" in hex).+magic :: Word32+magic = 0x4845474C++-- | Size of the packet header in bytes (5 big-endian uint32 fields).+headerSize :: Int+headerSize = 20++-- | Terminator byte appended after the payload.+terminator :: Word8+terminator = 0x0A++-- | Bit flag indicating a reply message. When set in the message ID field,+-- the message is a response to a previous request.+replyBit :: Word32+replyBit = 1 `shiftL` 31++-- | Special message ID used when closing a channel.+closeChannelMessageId :: Word32+closeChannelMessageId = (1 `shiftL` 31) - 1++-- | Special payload byte sent when closing a channel. Chosen to be invalid+-- CBOR per RFC 8949 (reserved tag byte 0xFE).+closeChannelPayload :: ByteString+closeChannelPayload = BS.singleton 0xFE++-- ---------------------------------------------------------------------------+-- Packet type+-- ---------------------------------------------------------------------------++-- | A single message in the wire protocol.+data Packet = Packet+  { packetChannelId :: Word32+  -- ^ The logical channel this packet belongs to.+  , packetMessageId :: Word32+  -- ^ Identifier for request\/response correlation (without reply bit).+  , packetIsReply :: Bool+  -- ^ Whether this is a reply to a previous request.+  , packetPayload :: ByteString+  -- ^ The raw payload bytes (typically CBOR-encoded).+  }+  deriving (Show, Eq)++-- ---------------------------------------------------------------------------+-- Byte packing helpers+-- ---------------------------------------------------------------------------++-- | Write a 'Word32' in big-endian order at the given byte offset.+pokeWord32BE :: Ptr Word8 -> Int -> Word32 -> IO ()+pokeWord32BE ptr off w = do+  pokeByteOff ptr off (fromIntegral (w `shiftR` 24) :: Word8)+  pokeByteOff ptr (off + 1) (fromIntegral (w `shiftR` 16) :: Word8)+  pokeByteOff ptr (off + 2) (fromIntegral (w `shiftR` 8) :: Word8)+  pokeByteOff ptr (off + 3) (fromIntegral w :: Word8)++-- | Unpack a 'Word32' from 4 big-endian bytes starting at offset.+unpackWord32BE :: ByteString -> Int -> Word32+unpackWord32BE bs off =+  let b0 = fromIntegral (BS.index bs off) :: Word32+      b1 = fromIntegral (BS.index bs (off + 1)) :: Word32+      b2 = fromIntegral (BS.index bs (off + 2)) :: Word32+      b3 = fromIntegral (BS.index bs (off + 3)) :: Word32+  in (b0 `shiftL` 24) .|. (b1 `shiftL` 16) .|. (b2 `shiftL` 8) .|. b3++-- | Build a packet header from its wire-format fields.+buildHeader :: Word32 -> Word32 -> Word32 -> Word32 -> ByteString+buildHeader checksum channelId messageId payloadLen =+  BSInternal.unsafeCreate headerSize $ \ptr -> do+    pokeWord32BE ptr 0 magic+    pokeWord32BE ptr 4 checksum+    pokeWord32BE ptr 8 channelId+    pokeWord32BE ptr 12 messageId+    pokeWord32BE ptr 16 payloadLen++-- ---------------------------------------------------------------------------+-- CRC32+-- ---------------------------------------------------------------------------++-- | Compute the CRC32 checksum of a 'ByteString', returned as 'Word32'.+computeCRC32 :: ByteString -> Word32+computeCRC32 = crc32++-- | Compute the CRC32 checksum over the concatenation of two byte strings.+computeCRC32Parts :: ByteString -> ByteString -> Word32+computeCRC32Parts a = crc32Update (crc32 a)++-- ---------------------------------------------------------------------------+-- Handle-based exact read+-- ---------------------------------------------------------------------------++-- | Read exactly @n@ bytes from a 'Handle'. Raises an 'IOError' on short+-- read (i.e., if the handle reaches EOF before @n@ bytes are available).+hGetExact :: Handle -> Int -> IO ByteString+hGetExact _ 0 = return BS.empty+hGetExact h n = go BS.empty+  where+    go acc+      | BS.length acc >= n = return acc+      | otherwise = do+          chunk <- BS.hGetSome h (n - BS.length acc)+          if BS.null chunk+            then+              if BS.null acc+                then ioError $ userError "Connection closed partway through reading packet."+                else ioError $ userError "Connection closed while reading data"+            else go (BS.append acc chunk)++-- ---------------------------------------------------------------------------+-- Packet I\/O+-- ---------------------------------------------------------------------------++-- | Read and parse a single packet from the given 'Handle'.+--+-- Raises 'IOError' if the magic number is invalid, the terminator byte is+-- invalid, or the CRC32 checksum does not match.+readPacket :: Handle -> IO Packet+readPacket h = do+  headerBuf <- hGetExact h headerSize+  let pktMagic = unpackWord32BE headerBuf 0+      checksum = unpackWord32BE headerBuf 4+      channelId = unpackWord32BE headerBuf 8+      rawMessageId = unpackWord32BE headerBuf 12+      payloadLen = unpackWord32BE headerBuf 16+      isReply = testBit rawMessageId 31+      messageId = if isReply then rawMessageId `xor` replyBit else rawMessageId++  when (pktMagic /= magic) $+    ioError $ userError $+      "Invalid magic number: expected 0x" ++ showHex32 magic+        ++ ", got 0x" ++ showHex32 pktMagic++  payloadBuf <- hGetExact h (fromIntegral payloadLen)+  termBuf <- hGetExact h 1+  let termByte = BS.index termBuf 0++  when (termByte /= terminator) $+    ioError $ userError $+      "Invalid terminator: expected 0x" ++ showHex8 terminator+        ++ ", got 0x" ++ showHex8 termByte++  -- Verify checksum: CRC32 over header with checksum field zeroed + payload+  let headerForCheck = buildHeader 0 channelId rawMessageId payloadLen+      computedCRC = computeCRC32Parts headerForCheck payloadBuf++  when (computedCRC /= checksum) $+    ioError $ userError $+      "Checksum mismatch: expected 0x" ++ showHex32 checksum+        ++ ", got 0x" ++ showHex32 computedCRC++  return Packet+    { packetChannelId = channelId+    , packetMessageId = messageId+    , packetIsReply = isReply+    , packetPayload = payloadBuf+    }++-- | Serialize and write a packet to the given 'Handle'.+writePacket :: Handle -> Packet -> IO ()+writePacket h pkt = do+  let wireMessageId =+        if packetIsReply pkt+          then packetMessageId pkt .|. replyBit+          else packetMessageId pkt+      payloadLen = fromIntegral (BS.length (packetPayload pkt)) :: Word32++  -- Build header with zeroed checksum for CRC computation+  let headerForCRC = buildHeader 0 (packetChannelId pkt) wireMessageId payloadLen+      checksum = computeCRC32Parts headerForCRC (packetPayload pkt)++  -- Build final header with real checksum+  let headerFinal = buildHeader checksum (packetChannelId pkt) wireMessageId payloadLen+  BS.hPut h headerFinal+  BS.hPut h (packetPayload pkt)+  BS.hPut h (BS.singleton terminator)+  hFlush h++-- ---------------------------------------------------------------------------+-- Hex formatting helpers+-- ---------------------------------------------------------------------------++-- | Format a 'Word32' as an 8-digit uppercase hex string.+showHex32 :: Word32 -> String+showHex32 w = map toHexDigit (reverse [nibble i | i <- [0..7]])+  where+    nibble i = fromIntegral ((w `shiftR` (i * 4)) .&. 0xF) :: Int+    toHexDigit n+      | n < 10    = toEnum (fromEnum '0' + n)+      | otherwise = toEnum (fromEnum 'A' + n - 10)++-- | Format a 'Word8' as a 2-digit uppercase hex string.+showHex8 :: Word8 -> String+showHex8 w = [toHexDigit hi, toHexDigit lo]+  where+    hi = fromIntegral (w `shiftR` 4) :: Int+    lo = fromIntegral (w .&. 0xF) :: Int+    toHexDigit n+      | n < 10    = toEnum (fromEnum '0' + n)+      | otherwise = toEnum (fromEnum 'A' + n - 10)++-- ---------------------------------------------------------------------------+-- CBOR helpers+-- ---------------------------------------------------------------------------++-- | Extract an integer from a CBOR 'Term'.+--+-- Accepts 'TInt' and 'TInteger'. Raises an error for other types.+extractInt :: Term -> Int+extractInt (TInt i) = i+extractInt (TInteger i) = fromIntegral i+extractInt other = error $ "Expected CBOR int, got " ++ termTypeName other++-- | Extract a double-precision float from a CBOR 'Term'.+--+-- Accepts 'TFloat', 'TDouble', and 'THalf'. Raises an error for other types.+extractFloat :: Term -> Double+extractFloat (TFloat f) = realToFrac f+extractFloat (TDouble d) = d+extractFloat (THalf f) = realToFrac f+extractFloat other = error $ "Expected CBOR float, got " ++ termTypeName other++-- | Extract a boolean from a CBOR 'Term'.+--+-- Raises an error if the term is not 'TBool'.+extractBool :: Term -> Bool+extractBool (TBool b) = b+extractBool other = error $ "Expected CBOR bool, got " ++ termTypeName other++-- | Extract a text string from a CBOR 'Term'.+--+-- Raises an error if the term is not 'TString'.+extractText :: Term -> Text+extractText (TString t) = t+extractText other = error $ "Expected CBOR text, got " ++ termTypeName other++-- | Extract a byte string from a CBOR 'Term'.+--+-- Raises an error if the term is not 'TBytes'.+extractBytes :: Term -> ByteString+extractBytes (TBytes b) = b+extractBytes other = error $ "Expected CBOR bytes, got " ++ termTypeName other++-- | Extract a list from a CBOR 'Term'.+--+-- Raises an error if the term is not 'TList'.+extractList :: Term -> [Term]+extractList (TList xs) = xs+extractList other = error $ "Expected CBOR array, got " ++ termTypeName other++-- | Extract a key-value map from a CBOR 'Term'.+--+-- Raises an error if the term is not 'TMap'.+extractMap :: Term -> [(Term, Term)]+extractMap (TMap kvs) = kvs+extractMap other = error $ "Expected CBOR map, got " ++ termTypeName other++-- | Encode a CBOR 'Term' to a strict 'ByteString'.+encodeTerm :: Term -> ByteString+encodeTerm = CBORWrite.toStrictByteString . CBOR.encodeTerm++-- | Decode a strict 'ByteString' to a CBOR 'Term'.+--+-- Raises an error if the bytes are not valid CBOR.+decodeTerm :: ByteString -> Term+decodeTerm bs =+  case deserialiseFromBytes CBOR.decodeTerm (LBS.fromStrict bs) of+    Left err -> error $ "CBOR decode error: " ++ show err+    Right (_, term) -> term++-- | Return a human-readable name for the type of a CBOR 'Term'.+termTypeName :: Term -> String+termTypeName (TInt _) = "int"+termTypeName (TInteger _) = "integer"+termTypeName (TFloat _) = "float"+termTypeName (TDouble _) = "double"+termTypeName (THalf _) = "half"+termTypeName (TBool _) = "bool"+termTypeName TNull = "null"+termTypeName (TBytes _) = "bytes"+termTypeName (TBytesI _) = "bytes (indefinite)"+termTypeName (TString _) = "text"+termTypeName (TStringI _) = "text (indefinite)"+termTypeName (TList _) = "array"+termTypeName (TListI _) = "array (indefinite)"+termTypeName (TMap _) = "map"+termTypeName (TMapI _) = "map (indefinite)"+termTypeName (TTagged _ _) = "tag"+termTypeName (TSimple _) = "simple"
+ src/Hegel/Session.hs view
@@ -0,0 +1,397 @@+-- | Global session management for Hegel.+--+-- This module manages a shared hegel subprocess communicating over stdio+-- pipes. It starts lazily on first use and cleans up when the process exits.+--+-- The main entry point is 'runHegelTest', which the user calls without+-- needing to manage connections or sessions directly.+module Hegel.Session+  ( -- * Session management+    startSession+  , cleanupSession+  , restartSession++    -- * Running tests+  , runHegelTest+  , runHegelTest_++    -- * Hegel discovery+  , findHegel+  , ensureHegelInstalled+  ) where++import Control.Concurrent (forkIO)+import Control.Concurrent.MVar+import Control.Exception (evaluate, try, SomeException)+import Control.Monad (unless)+import Data.Foldable (for_)+import Data.IORef+import System.Directory (createDirectoryIfMissing, doesFileExist)+import System.Environment (lookupEnv)+import System.Exit (ExitCode (..))+import System.IO+  ( hSetBinaryMode+  , hClose+  , IOMode (..)+  , openFile+  )+import System.IO.Unsafe (unsafePerformIO)+import System.Process+  ( CreateProcess (..)+  , StdStream (..)+  , ProcessHandle+  , proc+  , createProcess+  , waitForProcess+  , terminateProcess+  )++import Hegel.Client+import Hegel.Connection+++-- ---------------------------------------------------------------------------+-- Constants+-- ---------------------------------------------------------------------------++-- | Version of hegel-core to install.+hegelServerVersion :: String+hegelServerVersion = "0.2.3"++-- | Environment variable to override the hegel server command.+hegelServerCommandEnv :: String+hegelServerCommandEnv = "HEGEL_SERVER_COMMAND"++-- | Directory for hegel server installation and logs.+hegelServerDir :: FilePath+hegelServerDir = ".hegel"++-- | Message shown when uv is not found.+uvNotFoundMessage :: String+uvNotFoundMessage =+  "You are seeing this error message because hegel-haskell tried to use `uv` to \+  \install hegel-core, but could not find uv on the PATH.\n\n\+  \Hegel uses a Python server component called `hegel-core` to share core \+  \property-based testing functionality across languages. There are two ways \+  \for Hegel to get hegel-core:\n\n\+  \* By default, Hegel looks for uv (https://docs.astral.sh/uv/) on the PATH, \+  \and uses uv to install hegel-core to a local `.hegel/venv` directory. We \+  \recommend this option. To continue, install uv: \+  \https://docs.astral.sh/uv/getting-started/installation/.\n\+  \* Alternatively, you can manage the installation of hegel-core yourself. \+  \After installing, setting the HEGEL_SERVER_COMMAND environment variable to \+  \your hegel-core binary path tells hegel-haskell to use that hegel-core \+  \instead.\n\n\+  \See https://hegel.dev/reference/installation for more details."+++-- ---------------------------------------------------------------------------+-- Command execution helpers+-- ---------------------------------------------------------------------------++-- | Runs a command with arguments, redirecting stdout and stderr to a log+-- file. Returns the exit code.+runCommandToLog :: FilePath -> [String] -> FilePath -> IO ExitCode+runCommandToLog cmd args logPath = do+  logHandle <- openFile logPath AppendMode+  let cp = (proc cmd args)+        { std_out = UseHandle logHandle+        , std_err = UseHandle logHandle+        }+  (_, _, _, ph) <- createProcess cp+  exitCode <- waitForProcess ph+  hClose logHandle+  pure exitCode+++-- ---------------------------------------------------------------------------+-- Hegel discovery and installation+-- ---------------------------------------------------------------------------++-- | Checks for a cached hegel installation and installs via uv if needed.+-- Returns the path to the hegel binary.+ensureHegelInstalled :: IO FilePath+ensureHegelInstalled = do+  let venvDir = hegelServerDir ++ "/venv"+  let versionFile = venvDir ++ "/hegel-version"+  let hegelBin = venvDir ++ "/bin/hegel"+  let installLog = hegelServerDir ++ "/install.log"++  -- Check cached version (force the file read inside try to catch IO errors)+  cached <- try (do+    contents <- readFile versionFile+    let trimmed = strip contents+    -- Force evaluation of trimmed to catch lazy IO exceptions inside try+    _ <- evaluate (length trimmed)+    exists <- doesFileExist hegelBin+    if trimmed == hegelServerVersion && exists+      then pure (Just hegelBin)+      else pure Nothing+    ) :: IO (Either SomeException (Maybe FilePath))+  case cached of+    Right (Just path) -> pure path+    _ -> do+      -- Need to install+      createDirectoryIfMissing True hegelServerDir++      -- Create venv+      venvResult <- try (runCommandToLog "uv" ["venv", "--clear", venvDir] installLog)+        :: IO (Either SomeException ExitCode)+      case venvResult of+        Left _ -> fail uvNotFoundMessage+        Right (ExitFailure _) -> do+          logContents <- readFileSafe installLog+          fail ("uv venv failed. Install log:\n" ++ logContents)+        Right ExitSuccess -> pure ()++      -- Install hegel-core+      let pythonPath = venvDir ++ "/bin/python"+      pipResult <- runCommandToLog "uv"+        [ "pip", "install"+        , "--python", pythonPath+        , "hegel-core==" ++ hegelServerVersion+        ]+        installLog+      case pipResult of+        ExitFailure _ -> do+          logContents <- readFileSafe installLog+          fail $ "Failed to install hegel-core (version: " ++ hegelServerVersion+            ++ "). Set " ++ hegelServerCommandEnv+            ++ " to a hegel binary path to skip installation.\n"+            ++ "Install log:\n" ++ logContents+        ExitSuccess -> pure ()++      exists <- doesFileExist hegelBin+      unless exists $+        fail ("hegel not found at " ++ hegelBin ++ " after installation")++      -- Write version file+      writeFile versionFile hegelServerVersion+      pure hegelBin++-- | Locates the hegel binary. Checks, in order:+--+-- 1. @HEGEL_SERVER_COMMAND@ environment variable+-- 2. Auto-install via uv to @.hegel\/venv\/@ (version-locked)+--+-- The auto-installed version is preferred over any @hegel@ on @PATH@+-- because the system binary may be an incompatible version.+findHegel :: IO FilePath+findHegel = do+  envCmd <- lookupEnv hegelServerCommandEnv+  case envCmd of+    Just path | not (null path) -> pure path+    _ -> ensureHegelInstalled+++-- ---------------------------------------------------------------------------+-- CI detection+-- ---------------------------------------------------------------------------++-- | CI environment variables to check for auto-detection. Each entry is+-- @(varName, expectedValue)@ where 'Nothing' means "any value".+ciVars :: [(String, Maybe String)]+ciVars =+  [ ("CI", Nothing)+  , ("TF_BUILD", Just "true")+  , ("BUILDKITE", Just "true")+  , ("CIRCLECI", Just "true")+  , ("CIRRUS_CI", Just "true")+  , ("CODEBUILD_BUILD_ID", Nothing)+  , ("GITHUB_ACTIONS", Just "true")+  , ("GITLAB_CI", Nothing)+  , ("HEROKU_TEST_RUN_ID", Nothing)+  , ("TEAMCITY_VERSION", Nothing)+  ]++-- | Returns 'True' if a CI environment is detected.+isInCI :: IO Bool+isInCI = anyM checkVar ciVars+ where+  checkVar (key, expected) = do+    val <- lookupEnv key+    pure $ case (val, expected) of+      (Just _, Nothing)   -> True+      (Just v, Just expV) -> v == expV+      (Nothing, _)        -> False++  anyM _ [] = pure False+  anyM f (x : xs) = do+    b <- f x+    if b then pure True else anyM f xs+++-- ---------------------------------------------------------------------------+-- Session state+-- ---------------------------------------------------------------------------++-- | Internal mutable session state.+data HegelSession = HegelSession+  { sessionProcess    :: Maybe ProcessHandle+  , sessionConnection :: Maybe Connection+  , sessionClient     :: Maybe HegelClient+  }++-- | The initial empty session.+emptySession :: HegelSession+emptySession = HegelSession+  { sessionProcess    = Nothing+  , sessionConnection = Nothing+  , sessionClient     = Nothing+  }++-- | The global session singleton, protected by an MVar.+{-# NOINLINE globalSession #-}+globalSession :: MVar HegelSession+globalSession = unsafePerformIO (newMVar emptySession)++-- | IORef tracking whether cleanup has been registered with atexit.+{-# NOINLINE atExitRegistered #-}+atExitRegistered :: IORef Bool+atExitRegistered = unsafePerformIO (newIORef False)+++-- ---------------------------------------------------------------------------+-- Session lifecycle+-- ---------------------------------------------------------------------------++-- | Returns 'True' if the session has a live client.+hasWorkingClient :: HegelSession -> IO Bool+hasWorkingClient session =+  case (sessionClient session, sessionConnection session) of+    (Just _, Just conn) -> isLive conn+    _ -> pure False++-- | Cleans up the session, closing the connection and terminating the+-- subprocess.+cleanup :: HegelSession -> IO HegelSession+cleanup session = do+  -- Close connection+  for_ (sessionConnection session) closeConnection+  -- Terminate process+  case sessionProcess session of+    Just ph -> do+      _ <- try (terminateProcess ph) :: IO (Either SomeException ())+      _ <- try (waitForProcess ph) :: IO (Either SomeException ExitCode)+      pure ()+    Nothing -> pure ()+  pure emptySession++-- | Starts the hegel server if not already running. Spawns the server+-- with @--stdio@ for pipe-based communication.+startSession :: IO ()+startSession = modifyMVar_ globalSession $ \session -> do+  working <- hasWorkingClient session+  if working+    then pure session+    else do+      -- Clean up any old session+      _ <- cleanup session+      hegelCmd <- findHegel++      -- Open log file for server stderr+      createDirectoryIfMissing True hegelServerDir+      logHandle <- openFile (hegelServerDir ++ "/server.log") AppendMode++      -- Start hegel subprocess with --stdio+      let cp = (proc hegelCmd ["--stdio", "--verbosity", "normal"])+            { std_in  = CreatePipe+            , std_out = CreatePipe+            , std_err = UseHandle logHandle+            }+      (Just hIn, Just hOut, Nothing, ph) <- createProcess cp++      -- Set handles to binary mode+      hSetBinaryMode hIn True+      hSetBinaryMode hOut True++      -- Create connection from the pipe handles+      conn <- createConnection hOut hIn False++      -- Create client (performs handshake)+      client <- createClient conn++      -- Monitor thread: detect server crash+      _ <- forkIO $ do+        _ <- waitForProcess ph+        setServerExited conn+        pure ()++      -- Register at-exit cleanup (only once)+      registered <- readIORef atExitRegistered+      unless registered $ do+        writeIORef atExitRegistered True+        -- Note: We cannot use Weak references to the MVar easily, so we+        -- just always try cleanup. If session is already empty, cleanup+        -- is a no-op.++      pure HegelSession+        { sessionProcess    = Just ph+        , sessionConnection = Just conn+        , sessionClient     = Just client+        }++-- | Forces a restart of the global session. Cleans up the current session+-- and clears the state.+restartSession :: IO ()+restartSession = modifyMVar_ globalSession cleanup++-- | Cleans up the global session. Closes the connection and terminates+-- the server process.+cleanupSession :: IO ()+cleanupSession = modifyMVar_ globalSession cleanup+++-- ---------------------------------------------------------------------------+-- Running tests+-- ---------------------------------------------------------------------------++-- | Computes the initial settings for a test run.+--+-- In CI environments, Hegel starts from deterministic, stateless defaults by+-- enabling derandomization and disabling the example database.+initialSettingsForRun :: IO Settings+initialSettingsForRun = do+  inCI <- isInCI+  pure $+    if inCI+      then+        defaultSettings+          { settingsDerandomize = True+          , settingsDatabase = Disabled+          }+      else defaultSettings++-- | Runs a property test using the shared hegel process.+-- Ensures the session is started, then delegates to 'Client.runTest'.+runHegelTest :: (Settings -> Settings) -> (TestCase -> IO ()) -> IO ()+runHegelTest configure testFn = do+  effectiveSettings <- configure <$> initialSettingsForRun+  startSession+  session <- readMVar globalSession+  case sessionClient session of+    Just client -> runTest client effectiveSettings testFn+    Nothing     -> fail "Failed to start hegel session"++-- | Runs a property test using the initial runtime defaults unchanged.+runHegelTest_ :: (TestCase -> IO ()) -> IO ()+runHegelTest_ = runHegelTest id+++-- ---------------------------------------------------------------------------+-- Utilities+-- ---------------------------------------------------------------------------++-- | Strip leading and trailing whitespace from a string.+strip :: String -> String+strip = reverse . dropWhile isSpace' . reverse . dropWhile isSpace'+ where+  isSpace' c = c == ' ' || c == '\n' || c == '\r' || c == '\t'++-- | Read a file, returning empty string on failure.+readFileSafe :: FilePath -> IO String+readFileSafe path = do+  result <- try (readFile path) :: IO (Either SomeException String)+  case result of+    Right contents -> pure contents+    Left _         -> pure ""
+ test/Hegel/ClientSpec.hs view
@@ -0,0 +1,41 @@+{-# LANGUAGE OverloadedStrings #-}++module Hegel.ClientSpec (spec) where++import Test.Hspec++import Hegel.Client++spec :: Spec+spec = do+  describe "Default Settings" $ do+    it "provides pure non-CI defaults" $ do+      let stgs = defaultSettings+      settingsTestCases stgs `shouldBe` 100+      settingsVerbosity stgs `shouldBe` Normal+      settingsSeed stgs `shouldBe` Nothing+      settingsDerandomize stgs `shouldBe` False+      settingsDatabase stgs `shouldBe` Unset+      settingsSuppressHealthCheck stgs `shouldBe` []++    it "can be updated through record syntax without exposing the constructor" $ do+      let stgs =+            defaultSettings+              { settingsTestCases = 200+              , settingsDatabase = DatabasePath ".hegel/examples"+              }+      settingsTestCases stgs `shouldBe` 200+      settingsDatabase stgs `shouldBe` DatabasePath ".hegel/examples"++  describe "healthCheckToString" $ do+    it "maps FilterTooMuch correctly" $ do+      healthCheckToString FilterTooMuch `shouldBe` "filter_too_much"++    it "maps TooSlow correctly" $ do+      healthCheckToString TooSlow `shouldBe` "too_slow"++    it "maps TestCasesTooLarge correctly" $ do+      healthCheckToString TestCasesTooLarge `shouldBe` "test_cases_too_large"++    it "maps LargeInitialTestCase correctly" $ do+      healthCheckToString LargeInitialTestCase `shouldBe` "large_initial_test_case"
+ test/Hegel/IntegrationSpec.hs view
@@ -0,0 +1,121 @@+{-# LANGUAGE CPP #-}+{-# LANGUAGE OverloadedStrings #-}++module Hegel.IntegrationSpec (spec) where++import Control.Exception (SomeException, displayException, try)+import Control.Monad (when)+import Data.List (isInfixOf)+import Test.Hspec++import Hegel++captureFailureText :: IO () -> IO String+captureFailureText action = do+  result <- try action :: IO (Either SomeException ())+  case result of+    Left e -> pure (displayException e)+    Right () -> expectationFailure "expected property test to fail" >> pure ""++withTestCases :: Int -> Settings -> Settings+withTestCases n stgs = stgs { settingsTestCases = n }++spec :: Spec+spec = do+  describe "E2E: simple passing test" $ do+    it "integers are self-equal" $ do+      runHegelTest (withTestCases 10) $ \tc -> do+        n <- draw tc (integers def)+        when (n /= n) $+          error "integer not equal to itself"++  describe "E2E: simple failing test" $ do+    it "catches HegelTestFailure" $ do+      let action = runHegelTest (withTestCases 10) $ \tc -> do+            _ <- draw tc (integers def { rangeMin = Just 0, rangeMax = Just 100 })+            error "deliberate failure"+      action `shouldThrow` anyException++    it "reports the source location for Hegel.assert failures" $ do+      let expectedFile = __FILE__+      let expectedLine = show ((__LINE__ + 3) :: Int)+      msg <- captureFailureText $+        runHegelTest (withTestCases 10) $ \_tc ->+          assert False "deliberate failure with location"+      msg `shouldSatisfy` isInfixOf expectedFile+      msg `shouldSatisfy` isInfixOf (":" ++ expectedLine ++ ":")++    it "reports only the smallest failing example for a stable origin" $ do+      msg <- captureFailureText $+        runHegelTest+          ( \stgs ->+              stgs+                { settingsTestCases = 200+                , settingsDerandomize = True+                , settingsDatabase = Disabled+                }+          )+          $ \tc -> do+            xs <-+              draw+                tc+                ( lists+                    (integers def { rangeMin = Just 0, rangeMax = Just 0 })+                    def+                      { rangeMin = Just 0+                      , rangeMax = Just 12+                      }+                )+            assert (length xs < 10) ("expected list shorter than 10, got xs = " ++ show xs)+      msg `shouldSatisfy` isInfixOf "expected list shorter than 10, got xs = [0,0,0,0,0,0,0,0,0,0]"+      msg `shouldSatisfy` not . isInfixOf "got xs = [0,0,0,0,0,0,0,0,0,0,0]"++  describe "E2E: assume" $ do+    it "assume True does not reject" $ do+      runHegelTest (withTestCases 10) $ \tc -> do+        n <- draw tc (integers def)+        assume tc True+        when (n /= n) $+          error "unreachable"++    it "assume False rejects the test case" $ do+      -- All test cases are rejected, but the test should still complete+      -- (it just won't find any valid cases, which is fine for low counts)+      runHegelTest (withTestCases 5) $ \tc -> do+        n <- draw tc (integers def { rangeMin = Just 0, rangeMax = Just 100 })+        assume tc (n > 50)+        -- If we get here, n > 50 holds+        pure ()++  describe "E2E: draw multiple types" $ do+    it "draws booleans, integers, floats, text, and binary" $ do+      runHegelTest (withTestCases 10) $ \tc -> do+        _ <- draw tc booleans+        _ <- draw tc (integers def { rangeMin = Just 0, rangeMax = Just 100 })+        _ <-+          draw+            tc+            ( floats def+                { floatBounds =+                    RangeOpts+                      { rangeMin = Just 0.0,+                        rangeMax = Just 1.0+                      }+                }+            )+        _ <- draw tc (text def { rangeMax = Just 50 })+        _ <- draw tc (binary def { rangeMax = Just 50 })+        pure ()++  describe "E2E: note and target" $ do+    it "note records a message without failure" $ do+      runHegelTest (withTestCases 5) $ \tc -> do+        n <- draw tc (integers def)+        note tc ("Generated value: " ++ show n)+        pure ()++    it "target guides the search" $ do+      runHegelTest (withTestCases 10) $ \tc -> do+        n <- draw tc (integers def { rangeMin = Just 0, rangeMax = Just 1000 })+        target tc (fromIntegral n) "maximize n"+        pure ()
+ test/Hegel/ProtocolSpec.hs view
@@ -0,0 +1,141 @@+{-# LANGUAGE OverloadedStrings #-}++module Hegel.ProtocolSpec (spec) where++import Codec.CBOR.Term (Term (..))+import qualified Data.ByteString as BS+import Data.Bits (shiftL, shiftR, (.|.))+import Data.Word (Word32)+import System.IO (Handle, hFlush, hSetBinaryMode)+import System.Process (createPipe)+import Test.Hspec++import Hegel.Protocol++-- | Helper: create a pipe pair of handles for testing.+createPipePair :: IO (Handle, Handle)+createPipePair = createPipe++-- | Pack a Word32 as 4 big-endian bytes (reimplemented for testing since+-- the library does not export this).+testPackWord32BE :: Word32 -> BS.ByteString+testPackWord32BE w = BS.pack+  [ fromIntegral (w `shiftR` 24)+  , fromIntegral (w `shiftR` 16)+  , fromIntegral (w `shiftR` 8)+  , fromIntegral w+  ]++-- | Unpack a Word32 from 4 big-endian bytes at offset 0.+testUnpackWord32BE :: BS.ByteString -> Word32+testUnpackWord32BE bs =+  let b0 = fromIntegral (BS.index bs 0) :: Word32+      b1 = fromIntegral (BS.index bs 1) :: Word32+      b2 = fromIntegral (BS.index bs 2) :: Word32+      b3 = fromIntegral (BS.index bs 3) :: Word32+  in (b0 `shiftL` 24) .|. (b1 `shiftL` 16) .|. (b2 `shiftL` 8) .|. b3++spec :: Spec+spec = do+  describe "CRC32" $ do+    it "computes the known check value for '123456789'" $ do+      let input = "123456789"+      computeCRC32 input `shouldBe` 0xCBF43926++  describe "pack/unpack Word32 BE" $ do+    it "round-trips uint32 values" $ do+      let values = [0, 1, 255, 256, 65535, 0x12345678, 0xFFFFFFFF] :: [Word32]+      mapM_+        ( \w -> do+            let packed = testPackWord32BE w+            BS.length packed `shouldBe` 4+            testUnpackWord32BE packed `shouldBe` w+        )+        values++  describe "Packet round-trip" $ do+    it "writes and reads a packet through a pipe pair" $ do+      (readH, writeH) <- createPipePair+      hSetBinaryMode readH True+      hSetBinaryMode writeH True+      let pkt =+            Packet+              { packetChannelId = 42,+                packetMessageId = 7,+                packetIsReply = False,+                packetPayload = encodeTerm (TString "hello")+              }+      writePacket writeH pkt+      result <- readPacket readH+      packetChannelId result `shouldBe` 42+      packetMessageId result `shouldBe` 7+      packetIsReply result `shouldBe` False+      decodeTerm (packetPayload result) `shouldBe` TString "hello"++    it "round-trips a reply packet" $ do+      (readH, writeH) <- createPipePair+      hSetBinaryMode readH True+      hSetBinaryMode writeH True+      let pkt =+            Packet+              { packetChannelId = 10,+                packetMessageId = 3,+                packetIsReply = True,+                packetPayload = encodeTerm (TBool True)+              }+      writePacket writeH pkt+      result <- readPacket readH+      packetChannelId result `shouldBe` 10+      packetMessageId result `shouldBe` 3+      packetIsReply result `shouldBe` True++  describe "Invalid magic" $ do+    it "raises on invalid magic number" $ do+      (readH, writeH) <- createPipePair+      hSetBinaryMode readH True+      hSetBinaryMode writeH True+      -- Write a header with bad magic (0xDEADBEEF), followed by terminator+      let badHeader =+            BS.concat+              [ testPackWord32BE 0xDEADBEEF,+                testPackWord32BE 0,+                testPackWord32BE 1,+                testPackWord32BE 1,+                testPackWord32BE 0+              ]+      BS.hPut writeH badHeader+      BS.hPut writeH (BS.singleton 0x0A)+      hFlush writeH+      readPacket readH `shouldThrow` anyIOException++  describe "Bad CRC" $ do+    it "raises on checksum mismatch" $ do+      (readH, writeH) <- createPipePair+      hSetBinaryMode readH True+      hSetBinaryMode writeH True+      let payload = encodeTerm (TString "test")+      let payloadLen = fromIntegral (BS.length payload) :: Word32+      -- Compute correct CRC then corrupt it+      let headerForCRC =+            BS.concat+              [ testPackWord32BE magic,+                testPackWord32BE 0,+                testPackWord32BE 1,+                testPackWord32BE 1,+                testPackWord32BE payloadLen+              ]+      let correctCRC = computeCRC32Parts headerForCRC payload+      let wrongCRC = correctCRC + 1+      let badHeader =+            BS.concat+              [ testPackWord32BE magic,+                testPackWord32BE wrongCRC,+                testPackWord32BE 1,+                testPackWord32BE 1,+                testPackWord32BE payloadLen+              ]+      BS.hPut writeH badHeader+      BS.hPut writeH payload+      BS.hPut writeH (BS.singleton 0x0A)+      hFlush writeH+      readPacket readH `shouldThrow` anyIOException
+ test/Spec.hs view
@@ -0,0 +1,1 @@+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}