packages feed

effectful-cli-0.2.6.0: src/Effectful/CLI/Config.hs

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE OverloadedStrings #-}

-- | Read and edit a TOML config file by dotted key (@github.project.team@).
module Effectful.CLI.Config (
  readConfigText,
  getConfigKey,
  setConfigKey,
  lookupKey,
  setKey,
  parseValue,
) where

import Control.Monad ((<=<))
import Data.Error.CLI (CLIError (..))
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.IO qualified as T
import Effectful (Eff, IOE, liftIO, (:>))
import Effectful.Error.Static (Error, throwError)
import System.Directory (createDirectoryIfMissing, doesFileExist)
import System.FilePath (takeDirectory)
import Toml (
  Result (..),
  Table,
  Table' (..),
  Value,
  Value' (..),
  forgetTableAnns,
  prettyToml,
 )
import Toml qualified
import Toml.Pretty (prettyValue)
import Toml.Schema (FromValue)

-- | The config file's contents, empty if it does not exist.
--
-- @since 0.2.6.0
readConfigText
  :: IOE :> es
  => FilePath
  -> Eff es Text
readConfigText path = liftIO $ do
  exists <- doesFileExist path
  if exists then T.readFile path else pure ""

-- | Render a dotted key's value: strings unquoted, tables as TOML sections.
--
-- @since 0.2.6.0
getConfigKey
  :: IOE :> es
  => Error CLIError :> es
  => FilePath
  -> Text
  -> Eff es Text
getConfigKey path key =
  maybe (throwError (NotFoundError key)) (pure . render) . lookupKey (T.splitOn "." key)
    =<< readTable path
 where
  render (Text t) = t
  render (Table t) = T.stripEnd (T.show (prettyToml t))
  render v = T.show (prettyValue v)

-- | Set a dotted key to 'parseValue' of the raw input, creating intermediate
-- tables; the file is only written if the result still decodes as @a@.
-- Re-renders the whole file, dropping comments and formatting.
--
-- @since 0.2.6.0
setConfigKey
  :: forall a es
   . FromValue a
  => IOE :> es
  => Error CLIError :> es
  => FilePath
  -> Text
  -> Text
  -> Eff es ()
setConfigKey path key raw = do
  table <-
    either (throwError . InputError) pure . setKey (T.splitOn "." key) (parseValue raw)
      =<< readTable path
  let rendered = T.show (prettyToml table)
  case Toml.decode @a rendered of
    Failure es -> throwError (InputError ("invalid config: " <> T.pack (unlines es)))
    Success _ _ -> liftIO $ do
      createDirectoryIfMissing True (takeDirectory path)
      T.writeFile path rendered

readTable
  :: IOE :> es
  => Error CLIError :> es
  => FilePath
  -> Eff es Table
readTable =
  either (throwError . InputError . T.pack) (pure . forgetTableAnns) . Toml.parse
    <=< readConfigText

-- | Value at a dotted key path, the empty path being the whole table.
--
-- @since 0.2.6.0
lookupKey
  :: [Text]
  -> Table
  -> Maybe Value
lookupKey [] t = Just (Table t)
lookupKey (k : ks) (MkTable m) = case (ks, snd <$> Map.lookup k m) of
  ([], v) -> v
  (_, Just (Table t)) -> lookupKey ks t
  _ -> Nothing

-- | Set a value at a dotted key path, creating missing tables; fails when a
-- path segment is not a table.
--
-- @since 0.2.6.0
setKey
  :: [Text]
  -> Value
  -> Table
  -> Either Text Table
setKey [] _ _ = Left "empty key"
setKey [k] v (MkTable m) = Right (MkTable (Map.insert k ((), v) m))
setKey (k : ks) v (MkTable m) = case snd <$> Map.lookup k m of
  Nothing -> descend (MkTable mempty)
  Just (Table t) -> descend t
  Just _ -> Left (k <> " is not a table")
 where
  descend t = (\t' -> MkTable (Map.insert k ((), Table t') m)) <$> setKey ks v t

-- | A TOML literal (@true@, @42@, @["a"]@, @"x"@), else the raw text as a string.
--
-- @since 0.2.6.0
parseValue :: Text -> Value
parseValue raw =
  either
    (const (Text raw))
    (fromMaybe (Text raw) . lookupKey ["v"] . forgetTableAnns)
    (Toml.parse ("v = " <> raw))