packages feed

settei-0.2.0.0: src/Settei/Provenance.hs

-- |
-- Module: Settei.Provenance
-- Description: Raw candidates paired with their structured origin.
module Settei.Provenance
  ( Candidate,
    ReportedValue,
    candidate,
    candidateOrigin,
    candidateValue,
    derivedReportedValue,
    redactReportedValue,
    renderReportedValue,
    reportedValue,
    visibleReportedValue,
  )
where

import Data.Generics.Labels ()
import Data.Map.Strict qualified as Map
import Data.Ratio qualified as Ratio
import Data.Text qualified as Text
import Settei.Origin (Origin)
import Settei.Prelude
import Settei.Setting (Sensitivity (..))
import Settei.Value (RawValue (..))

-- | One source's value for one declared key.
--
-- There is deliberately no 'Show' instance: the raw value may be secret.
data Candidate = Candidate
  { value :: !RawValue,
    origin :: !Origin
  }
  deriving stock (Generic, Eq)

-- | Pair one raw source value with its exact origin.
candidate :: RawValue -> Origin -> Candidate
candidate value origin = Candidate {value, origin}

-- | Return the candidate's raw value for core decoding.
candidateValue :: Candidate -> RawValue
candidateValue value = value ^. #value

-- | Return the candidate's structured origin.
candidateOrigin :: Candidate -> Origin
candidateOrigin value = value ^. #origin

-- | A value safe to retain in reports and structured errors.
--
-- Constructors are private so reporting code can display a value but cannot unwrap a
-- supposedly redacted secret.
data ReportedValue
  = VisibleValue !Text
  | RedactedValue
  | DerivedValue
  deriving stock (Generic, Eq, Ord, Show)

-- | Apply a setting's sensitivity before retaining any display representation.
reportedValue :: Sensitivity -> RawValue -> ReportedValue
reportedValue Public = VisibleValue . renderRawValue
reportedValue Secret = const RedactedValue

-- | Collapse any retained display representation to the redaction marker.
redactReportedValue :: ReportedValue -> ReportedValue
redactReportedValue _ = RedactedValue

-- | Render a retained value. A redacted value has no recovery path.
renderReportedValue :: ReportedValue -> Text
renderReportedValue (VisibleValue value) = value
renderReportedValue RedactedValue = "<redacted>"
renderReportedValue DerivedValue = "<derived>"

-- | Represent a typed default when no format-independent encoder exists.
derivedReportedValue :: Sensitivity -> ReportedValue
derivedReportedValue Public = DerivedValue
derivedReportedValue Secret = RedactedValue

-- | Retain an already-rendered public value.
visibleReportedValue :: Text -> ReportedValue
visibleReportedValue = VisibleValue

renderRawValue :: RawValue -> Text
renderRawValue = \case
  RawNull -> "null"
  RawText value -> "\"" <> escapeText value <> "\""
  RawBool True -> "true"
  RawBool False -> "false"
  RawNumber value
    | Ratio.denominator value == 1 -> Text.pack (show (Ratio.numerator value))
    | otherwise ->
        case renderDecimal value of
          Just decimal -> decimal
          Nothing ->
            Text.pack (show (Ratio.numerator value))
              <> "/"
              <> Text.pack (show (Ratio.denominator value))
  RawArray values -> "[" <> Text.intercalate ", " (fmap renderRawValue values) <> "]"
  RawObject values ->
    "{"
      <> Text.intercalate
        ", "
        [ "\"" <> escapeText key <> "\": " <> renderRawValue value
        | (key, value) <- Map.toAscList values
        ]
      <> "}"

renderDecimal :: Rational -> Maybe Text
renderDecimal value =
  let numerator = Ratio.numerator value
      denominator = Ratio.denominator value
      (twos, afterTwos) = countFactor 2 denominator
      (fives, residual) = countFactor 5 afterTwos
   in if residual /= 1
        then Nothing
        else
          let scale = max twos fives
              scaled = abs numerator * (10 ^ scale) `quot` denominator
              digits = Text.pack (show scaled)
              padded = Text.replicate (max 0 (scale + 1 - Text.length digits)) "0" <> digits
              (whole, fractional) = Text.splitAt (Text.length padded - scale) padded
              sign = if numerator < 0 then "-" else ""
           in Just (sign <> whole <> "." <> fractional)

countFactor :: Integer -> Integer -> (Int, Integer)
countFactor factor = go 0
  where
    go count remaining
      | remaining `rem` factor == 0 = go (count + 1) (remaining `quot` factor)
      | otherwise = (count, remaining)

escapeText :: Text -> Text
escapeText =
  Text.replace "\n" "\\n"
    . Text.replace "\r" "\\r"
    . Text.replace "\t" "\\t"
    . Text.replace "\"" "\\\""
    . Text.replace "\\" "\\\\"