packages feed

settei-0.1.0.0: src/Settei/Value.hs

-- |
-- Module: Settei.Value
-- Description: Parser-neutral raw values and secret-safe typed decoders.
module Settei.Value
  ( RawValue (..),
    DecodeFailure,
    Decoder,
    boolDecoder,
    boundedIntegralDecoder,
    enumDecoder,
    decodeFailure,
    decodeFailureExpected,
    decoder,
    renderDecodeFailure,
    runDecoder,
    textDecoder,
  )
where

import Data.Generics.Labels ()
import Data.Ratio qualified as Ratio
import Data.Text qualified as Text
import Data.Text.Read qualified as TextRead
import Settei.Key (Key, renderKey)
import Settei.Prelude

-- | The parser-neutral value tree produced by source adapters.
--
-- 'RawValue' deliberately has no 'Show' instance: it may contain a secret before a
-- setting decoder has had a chance to redact it.
data RawValue
  = RawNull
  | RawText !Text
  | RawBool !Bool
  | RawNumber !Rational
  | RawArray ![RawValue]
  | RawObject !(Map Text RawValue)
  deriving stock (Generic, Eq)

-- | A secret-safe explanation of what a decoder expected.
--
-- The rejected raw input is intentionally absent.
data DecodeFailure = DecodeFailure
  { key :: !Key,
    expected :: !Text
  }
  deriving stock (Generic, Eq, Show)

-- | A reusable decoder that receives the owning setting key for safe errors.
newtype Decoder a = Decoder
  { decode :: Key -> RawValue -> Either DecodeFailure a
  }
  deriving stock (Generic)

-- | Construct a decoder whose failures carry only safe expectation metadata.
decoder :: (Key -> RawValue -> Either DecodeFailure a) -> Decoder a
decoder decode = Decoder {decode}

-- | Construct a rejection without retaining the rejected raw value.
decodeFailure :: Key -> Text -> DecodeFailure
decodeFailure key expected = DecodeFailure {key, expected}

-- | Inspect the safe expectation text for a failed decode.
decodeFailureExpected :: DecodeFailure -> Text
decodeFailureExpected value = value ^. #expected

-- | Run a decoder for one setting key.
runDecoder :: Decoder a -> Key -> RawValue -> Either DecodeFailure a
runDecoder value = value ^. #decode

-- | Decode a text scalar.
textDecoder :: Decoder Text
textDecoder = Decoder $ \key -> \case
  RawText value -> Right value
  _ -> failure key "text"

-- | Decode a boolean scalar or an exact, case-insensitive textual boolean.
boolDecoder :: Decoder Bool
boolDecoder = Decoder $ \key -> \case
  RawBool value -> Right value
  RawText value -> case Text.toCaseFold value of
    "true" -> Right True
    "false" -> Right False
    _ -> failure key "boolean"
  _ -> failure key "boolean"

-- | Decode a whole number that fits the requested bounded integral type.
boundedIntegralDecoder :: forall a. (Bounded a, Integral a) => Decoder a
boundedIntegralDecoder = Decoder $ \key value ->
  case integralValue value of
    Just candidate
      | candidate >= toInteger (minBound @a)
          && candidate <= toInteger (maxBound @a) ->
          Right (fromInteger candidate)
    _ -> failure key "bounded integer"

-- | Decode one of a finite set of exact text spellings.
enumDecoder :: [(Text, a)] -> Decoder a
enumDecoder choices = Decoder $ \key -> \case
  RawText value ->
    maybe
      (failure key expectedValues)
      Right
      (lookup value choices)
  _ -> failure key expectedValues
  where
    names = fmap fst choices
    expectedValues = case names of
      [] -> "an allowed value"
      _ -> "one of " <> Text.intercalate ", " names

-- | Render a failure without including the rejected value.
renderDecodeFailure :: DecodeFailure -> Text
renderDecodeFailure value =
  renderKey (value ^. #key) <> ": expected " <> value ^. #expected

failure :: Key -> Text -> Either DecodeFailure a
failure key expected = Left DecodeFailure {key, expected}

integralValue :: RawValue -> Maybe Integer
integralValue = \case
  RawNumber value
    | Ratio.denominator value == 1 -> Just (Ratio.numerator value)
  RawText value -> case TextRead.signed TextRead.decimal value of
    Right (candidate, rest)
      | Text.null rest -> Just candidate
    _ -> Nothing
  _ -> Nothing