packages feed

mmzk-env-0.5.0.0: src/Data/Env/RecordParserW.hs

{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE UndecidableInstances #-}
{-# LANGUAGE AllowAmbiguousTypes #-}

{- |
Module      : Data.Env.RecordParserW
Description : Schema-driven record parsing with value-level field parsers

Provides 'RecordParserW' for parsing environment-variable schemas whose
fields use the 'Col' column family.

== Design

A @schema 'Dec@ is a plain Haskell record whose fields are functions
@'String' -> 'Either' 'String' a@. An absent or empty env variable is
passed as @""@; the function decides what to do. A @schema 'Res@ is the
same record with each field replaced by its resolved value.

@
data DbConfig c = DbConfig
  { host :: Col c Text
  , port :: Col c Int
  }
  deriving (Generic)

instance RecordParserW (DbConfig \'Dec)

-- Default schema (both fields required — no default):
schema :: DbConfig \'Dec
schema = 'defaultSchema' \@DbConfig

-- Schema with runtime defaults, using infix 'orElse':
schema' :: DbConfig \'Dec
schema' = schema
  { host = 'typeParser' \@Text \`orElse\` \"localhost\"
  , port = 'typeParser' \@Int  \`orElse\` 5432
  }
@

== Helpers

* 'typeParser' — builds the standard parser for any 'TypeParser' type.
* 'orElse' — sets the fallback value when the env variable is absent or empty.
* 'fromTypeParserW' — bridges a 'TypeParserW' witness into a field function.
-}
module Data.Env.RecordParserW
  ( RecordParserW (..)
  , HasDefaultSchema (..)
  , ColumnType (..)
  , Col
  , typeParser
  , orElse
  , fromTypeParserW
  ) where

import Data.Env.ParseError
import Data.Env.TypeParser (TypeParser)
import Data.Env.TypeParser qualified as TP
import Data.Env.TypeParserW (TypeParserW)
import Data.Env.TypeParserW qualified as TPW
import Data.Kind (Type)
import Data.Map (Map)
import Data.Map qualified as M
import Data.Maybe (fromMaybe)
import Data.Proxy (Proxy (..))
import GHC.Generics

-- | Column type indicator.
--
-- * 'Dec' — schema column; each field holds a @'String' -> 'Either' 'String' a@ parser.
-- * 'Res' — result column; each field holds the resolved @a@.
data ColumnType = Dec | Res
  deriving stock (Eq, Show)

-- | Type family mapping a column kind to the field representation.
--
-- @'Col' ''Dec' a = 'String' -> 'Either' 'String' a@
-- @'Col' ''Res' a = a@
type family Col (c :: ColumnType) (a :: Type) :: Type where
  Col 'Dec a = String -> Either String a
  Col 'Res a = a

{- | Build the standard field parser for any 'TypeParser' type.

Passes non-empty strings to 'TP.parseType' and treats @""@ (absent or empty
env variable) as a missing value, delegating to 'TP.parseMissing'. Most
types treat absence as an error (required field); 'Maybe' returns
@'Right' 'Nothing'@.

Combine with 'orElse' to supply a fallback:

@
port = 'typeParser' \@Int \`orElse\` 5432
@
-}
typeParser :: forall a. TypeParser a => String -> Either String a
typeParser "" = TP.parseMissing @a
typeParser s = TP.parseType s
{-# INLINE typeParser #-}

{- | Set the fallback value when the env variable is absent or empty.

Designed for infix use with 'typeParser' or 'fromTypeParserW':

@
port  = 'typeParser' \@Int  \`orElse\` 5432
host  = 'typeParser' \@Text \`orElse\` \"localhost\"
model = 'typeParser' \@Text \`orElse\` \"claude-sonnet-4-6\"
@

@f \`orElse\` d@ returns @'Right' d@ when the variable is absent or empty,
and @f s@ for any non-empty string @s@.
-}
orElse :: (String -> Either String a) -> a -> String -> Either String a
orElse _ d "" = Right d
orElse f _ s = f s
{-# INLINE orElse #-}

{- | Build a field parser from a 'TypeParserW' witness.

Bridges existing witness types into the value-level field API. Also
composable with 'orElse':

@
fromTypeParserW \@MyCustomWitness \`orElse\` \"fallback\"
@
-}
fromTypeParserW :: forall p a. TypeParserW p a => String -> Either String a
fromTypeParserW "" = TPW.parseMissingW @p Proxy
fromTypeParserW s = TPW.parseTypeW @p Proxy s
{-# INLINE fromTypeParserW #-}

-- | Type class for schemas whose fields are 'Col' columns.
--
-- The method 'parseRecordW' takes the schema value (a @schema 'Dec@) and an
-- env map, returning all field failures collected rather than stopping at
-- the first.
class RecordParserW a where
  -- | The resolved type produced by parsing @a@.
  type RecordParsedType a

  -- | Parse all fields from the env map using the schema's field functions.
  parseRecordW :: a -> Map String String -> Either ParseError (RecordParsedType a)

instance
  ( Generic (a 'Dec)
  , GRecordParserW (Rep (a 'Dec))
  , Generic (a 'Res)
  , GRecordParsedType (Rep (a 'Dec)) () ~ Rep (a 'Res) ()
  ) =>
  RecordParserW (a 'Dec)
  where
  type RecordParsedType (a 'Dec) = a 'Res
  parseRecordW :: a 'Dec -> Map String String -> Either ParseError (a 'Res)
  parseRecordW schema env =
    validationToEither (to <$> gParseRecord (from schema) env)

{- | Generically derive a @schema 'Dec@ whose every field uses 'typeParser'.

Requires every field type to have a 'TypeParser' instance. Override
individual fields on the returned value to customise parsing:

@
mySchema :: MyConfig \'Dec
mySchema = ('defaultSchema' \@MyConfig)
  { host = 'typeParser' \@Text \`orElse\` \"localhost\" }
@
-}
class HasDefaultSchema a where
  defaultSchema :: a 'Dec

instance (Generic (a 'Dec), GDefaultSchema (Rep (a 'Dec))) => HasDefaultSchema a where
  defaultSchema :: a 'Dec
  defaultSchema = to gDefaultSchema


--------------------------------------------------------------------------------
-- Internal Validation applicative
--------------------------------------------------------------------------------

data Validation e a = VFailure e | VSuccess a

instance Functor (Validation e) where
  fmap _ (VFailure e) = VFailure e
  fmap f (VSuccess a) = VSuccess (f a)

instance Semigroup e => Applicative (Validation e) where
  pure = VSuccess
  VSuccess f <*> VSuccess x = VSuccess (f x)
  VFailure e1 <*> VFailure e2 = VFailure (e1 <> e2)
  VFailure e <*> _ = VFailure e
  _ <*> VFailure e = VFailure e

validationToEither :: Validation e a -> Either e a
validationToEither (VSuccess a) = Right a
validationToEither (VFailure e) = Left e


--------------------------------------------------------------------------------
-- Generic parsing
--------------------------------------------------------------------------------

class GRecordParserW f where
  type GRecordParsedType f :: Type -> Type
  gParseRecord :: f () -> Map String String -> Validation ParseError ((GRecordParsedType f) ())

instance GRecordParserW f => GRecordParserW (M1 D c f) where
  type GRecordParsedType (M1 D c f) = M1 D c (GRecordParsedType f)
  gParseRecord (M1 x) env = M1 <$> gParseRecord x env

instance GRecordParserW f => GRecordParserW (M1 C c f) where
  type GRecordParsedType (M1 C c f) = M1 C c (GRecordParsedType f)
  gParseRecord (M1 x) env = M1 <$> gParseRecord x env

instance (GRecordParserW f, GRecordParserW g) => GRecordParserW (f :*: g) where
  type GRecordParsedType (f :*: g) = GRecordParsedType f :*: GRecordParsedType g
  gParseRecord (x :*: y) env = (:*:) <$> gParseRecord x env <*> gParseRecord y env

instance Selector s => GRecordParserW (M1 S s (K1 i (String -> Either String a))) where
  type GRecordParsedType (M1 S s (K1 i (String -> Either String a))) = M1 S s (K1 i a)
  gParseRecord (M1 (K1 fn)) env =
    let key = selName (undefined :: M1 S s (K1 i a) p)
        result = fn (fromMaybe "" (M.lookup key env))
     in M1 . K1 <$> case result of
          Left msg -> VFailure $ ParseError [FieldError{errField = key, errMessage = msg}]
          Right val -> VSuccess val


--------------------------------------------------------------------------------
-- Generic default schema
--------------------------------------------------------------------------------

class GDefaultSchema f where
  gDefaultSchema :: f ()

instance GDefaultSchema f => GDefaultSchema (M1 D c f) where
  gDefaultSchema :: M1 D c f ()
  gDefaultSchema = M1 gDefaultSchema

instance GDefaultSchema f => GDefaultSchema (M1 C c f) where
  gDefaultSchema :: M1 C c f ()
  gDefaultSchema = M1 gDefaultSchema

instance (GDefaultSchema f, GDefaultSchema g) => GDefaultSchema (f :*: g) where
  gDefaultSchema :: (f :*: g) ()
  gDefaultSchema = gDefaultSchema :*: gDefaultSchema

instance TypeParser a => GDefaultSchema (M1 S s (K1 i (String -> Either String a))) where
  gDefaultSchema :: M1 S s (K1 i (String -> Either String a)) ()
  gDefaultSchema = M1 (K1 (typeParser @a))