tuispec-0.1.0.0: src/TuiSpec/Server.hs
{-# LANGUAGE OverloadedStrings #-}
{- |
Module : TuiSpec.Server
Description : JSON-RPC 2.0 server for interactive TUI orchestration.
Runs a newline-delimited JSON-RPC server on stdin\/stdout, allowing
external tools to drive TUI sessions programmatically.
-}
module TuiSpec.Server (
ServerOptions (..),
runServer,
) where
import Control.Exception (SomeException, displayException, finally, try)
import Control.Monad (when)
import Data.Aeson (FromJSON (parseJSON), Result (Error, Success), Value (Null, Object), eitherDecodeStrict', encode, fromJSON, object, withObject, (.:), (.:?), (.=))
import Data.Aeson.KeyMap qualified as KM
import Data.Aeson.Types qualified as AesonTypes
import Data.ByteString qualified as BS
import Data.ByteString.Char8 qualified as BS8
import Data.ByteString.Lazy.Char8 qualified as BL8
import Data.Char (isAlphaNum, toLower)
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.List (isSuffixOf)
import Data.Text (Text)
import Data.Text qualified as T
import JSONRPC qualified as RPC
import System.Directory (doesFileExist)
import System.Exit (ExitCode (ExitSuccess))
import System.FilePath ((</>))
import System.IO (hFlush, stdin, stdout)
import System.IO.Error (isEOFError, tryIOError)
import System.Posix.Process (exitImmediately)
import System.Posix.Signals (Handler (Catch), installHandler, sigHUP)
import TuiSpec.Runner (currentView, defaultWaitOptionsFor, dumpView, expectNotVisible, expectSnapshot, expectVisible, killSessionChildrenNow, launch, openSession, press, pressCombo, sendLine, typeText, waitForSelector)
import TuiSpec.Types (AmbiguityMode (FailOnAmbiguous, FirstVisibleMatch), App (App), Key (..), Modifier (Alt, Control, Shift), Rect (Rect), RunOptions (..), Selector (..), SnapshotName (SnapshotName), Tui (..), WaitOptions (..), defaultRunOptions)
-- | Configuration for the JSON-RPC server.
data ServerOptions = ServerOptions
{ serverArtifactsDir :: FilePath
-- ^ Base directory for session artifacts.
, serverTerminalCols :: Int
-- ^ Default terminal columns for launched sessions.
, serverTerminalRows :: Int
-- ^ Default terminal rows for launched sessions.
, serverTimeoutSeconds :: Int
-- ^ Default timeout for wait operations.
, serverAmbiguityMode :: AmbiguityMode
-- ^ Default ambiguity mode for selector assertions.
}
deriving (Eq, Show)
data ActiveSession = ActiveSession
{ activeTui :: Tui
}
data ServerState = ServerState
{ stateOptions :: ServerOptions
, stateActiveSession :: IORef (Maybe ActiveSession)
}
data DispatchOutcome
= Continue Value
| Shutdown Value
data RpcFailure = RpcFailure
{ failureCode :: Int
, failureMessage :: Text
, failureData :: Maybe Value
}
-- | Run the JSON-RPC server, reading requests from stdin and writing responses to stdout.
runServer :: ServerOptions -> IO ()
runServer options = do
sessionRef <- newIORef Nothing
let state = ServerState{stateOptions = options, stateActiveSession = sessionRef}
_ <- installHandler sigHUP (Catch (handleSighup state)) Nothing
loop state `finally` killActiveChildrenNow state
where
handleSighup state = do
killActiveChildrenNow state
exitImmediately ExitSuccess
loop state = do
lineResult <- tryIOError (BS8.hGetLine stdin)
case lineResult of
Left ioErr
| isEOFError ioErr -> pure ()
| otherwise -> pure ()
Right line ->
if BS.null line
then loop state
else do
shouldContinue <- handleLine state line
when shouldContinue (loop state)
handleLine :: ServerState -> BS.ByteString -> IO Bool
handleLine state line =
case eitherDecodeStrict' line :: Either String RPC.JSONRPCRequest of
Left parseErr -> do
writeErrorResponse (RPC.RequestId Null) (parseError parseErr)
Right request ->
case validateRequestVersion request of
Left err -> do
writeErrorResponse (requestId request) err
Right () -> do
outcome <- dispatchRequest state request
case outcome of
Left err ->
writeErrorResponse (requestId request) err
Right (Continue resultValue) ->
writeSuccessResponse (requestId request) resultValue
Right (Shutdown resultValue) -> do
_ <- writeSuccessResponse (requestId request) resultValue
pure False
validateRequestVersion :: RPC.JSONRPCRequest -> Either RpcFailure ()
validateRequestVersion request =
if requestVersion request == RPC.rPC_VERSION
then Right ()
else Left (invalidRequest "Expected jsonrpc field to equal \"2.0\"")
dispatchRequest :: ServerState -> RPC.JSONRPCRequest -> IO (Either RpcFailure DispatchOutcome)
dispatchRequest state request =
case requestMethod request of
"initialize" -> dispatchInitialize state request
"launch" -> dispatchLaunch state request
"sendKey" -> dispatchSendKey state request
"sendText" -> dispatchSendText state request
"sendLine" -> dispatchSendLine state request
"currentView" -> dispatchCurrentView state request
"dumpView" -> dispatchDumpView state request
"expectSnapshot" -> dispatchExpectSnapshot state request
"waitForText" -> dispatchWaitForText state request
"expectVisible" -> dispatchExpectVisible state request
"expectNotVisible" -> dispatchExpectNotVisible state request
"server.ping" -> dispatchPing request
"server.shutdown" -> dispatchShutdown state request
unknownMethod ->
pure $
Left
( RpcFailure
{ failureCode = RPC.mETHOD_NOT_FOUND
, failureMessage = "Method not found"
, failureData = Just (object ["method" .= unknownMethod])
}
)
dispatchInitialize :: ServerState -> RPC.JSONRPCRequest -> IO (Either RpcFailure DispatchOutcome)
dispatchInitialize state request = do
existing <- readIORef (stateActiveSession state)
case existing of
Just _ ->
pure (Left sessionAlreadyStartedError)
Nothing ->
case decodeParams request of
Left err -> pure (Left err)
Right params -> do
let sessionName = T.unpack (T.strip (fromMaybeText "session" (startName params)))
if null sessionName
then pure (Left (invalidParams "session name cannot be empty"))
else do
case resolveAmbiguityOverride (startAmbiguityMode params) of
Left ambiguityErr ->
pure (Left (invalidParams ambiguityErr))
Right ambiguityOverride -> do
let runOptions = applyStartParams (stateOptions state) params ambiguityOverride
sessionResult <- try (openSession runOptions sessionName) :: IO (Either SomeException Tui)
case sessionResult of
Left err ->
pure (Left (methodFailed (displayException err)))
Right tui -> do
writeIORef (stateActiveSession state) (Just (ActiveSession tui))
pure $
Right $
Continue
( object
[ "sessionName" .= sessionName
, "artifactRoot" .= tuiTestRoot tui
, "rows" .= terminalRows (tuiOptions tui)
, "cols" .= terminalCols (tuiOptions tui)
]
)
dispatchLaunch :: ServerState -> RPC.JSONRPCRequest -> IO (Either RpcFailure DispatchOutcome)
dispatchLaunch state request =
withActiveSession state $ \active ->
case decodeParams request of
Left err -> pure (Left err)
Right params ->
runMethod $
launch (activeTui active) (App (launchCommand params) (launchArgs params))
>> pure (object ["ok" .= True])
dispatchSendKey :: ServerState -> RPC.JSONRPCRequest -> IO (Either RpcFailure DispatchOutcome)
dispatchSendKey state request =
withActiveSession state $ \active ->
case decodeParams request of
Left err -> pure (Left err)
Right params ->
case parseSendKey (sendKeyValue params) of
Left keyErr -> pure (Left (invalidParams keyErr))
Right (modifiers, keyValue) ->
runMethod $
do
if null modifiers
then press (activeTui active) keyValue
else pressCombo (activeTui active) modifiers keyValue
pure (object ["ok" .= True])
dispatchSendText :: ServerState -> RPC.JSONRPCRequest -> IO (Either RpcFailure DispatchOutcome)
dispatchSendText state request =
withActiveSession state $ \active ->
case decodeParams request of
Left err -> pure (Left err)
Right params ->
runMethod $
typeText (activeTui active) (sendTextValue params)
>> pure (object ["ok" .= True])
dispatchSendLine :: ServerState -> RPC.JSONRPCRequest -> IO (Either RpcFailure DispatchOutcome)
dispatchSendLine state request =
withActiveSession state $ \active ->
case decodeParams request of
Left err -> pure (Left err)
Right params ->
runMethod $
sendLine (activeTui active) (sendLineValue params)
>> pure (object ["ok" .= True])
dispatchCurrentView :: ServerState -> RPC.JSONRPCRequest -> IO (Either RpcFailure DispatchOutcome)
dispatchCurrentView state request =
withActiveSession state $ \active ->
case requireNoParams request of
Left err -> pure (Left err)
Right () ->
runMethod $ do
textValue <- currentView (activeTui active)
let options = tuiOptions (activeTui active)
pure
( object
[ "text" .= textValue
, "rows" .= terminalRows options
, "cols" .= terminalCols options
]
)
dispatchDumpView :: ServerState -> RPC.JSONRPCRequest -> IO (Either RpcFailure DispatchOutcome)
dispatchDumpView state request =
withActiveSession state $ \active ->
case decodeParams request of
Left err -> pure (Left err)
Right params ->
runMethod $ do
ansiPath <- dumpView (activeTui active) (SnapshotName (dumpName params))
pure
( object
[ "snapshotPath" .= ansiPath
, "metaPath" .= snapshotMetaPath ansiPath
]
)
dispatchExpectSnapshot :: ServerState -> RPC.JSONRPCRequest -> IO (Either RpcFailure DispatchOutcome)
dispatchExpectSnapshot state request =
withActiveSession state $ \active ->
case decodeParams request of
Left err -> pure (Left err)
Right params ->
runMethod $ do
let snapshotText = expectSnapshotNameValue params
let snapshotStem = safeSnapshotStem (T.unpack snapshotText)
let tui = activeTui active
let actualPath = tuiTestRoot tui </> "snapshots" </> (snapshotStem <> ".ansi.txt")
let baselinePath = tuiSnapshotRoot tui </> (snapshotStem <> ".ansi.txt")
expectSnapshot tui (SnapshotName snapshotText)
baselineExists <- doesFileExist baselinePath
pure
( object
[ "ok" .= True
, "actualPath" .= actualPath
, "baselinePath" .= baselinePath
, "baselineExists" .= baselineExists
]
)
dispatchWaitForText :: ServerState -> RPC.JSONRPCRequest -> IO (Either RpcFailure DispatchOutcome)
dispatchWaitForText state request =
withActiveSession state $ \active ->
case decodeParams request of
Left err -> pure (Left err)
Right params ->
runMethod $ do
let tui = activeTui active
let defaults = defaultWaitOptionsFor tui
let mergedWaitOptions =
defaults
{ timeoutMs = maybe (timeoutMs defaults) id (waitTimeoutMs params)
, pollIntervalMs = maybe (pollIntervalMs defaults) id (waitPollIntervalMs params)
}
waitForSelector tui mergedWaitOptions (waitSelector params)
pure (object ["ok" .= True])
dispatchExpectVisible :: ServerState -> RPC.JSONRPCRequest -> IO (Either RpcFailure DispatchOutcome)
dispatchExpectVisible state request =
withActiveSession state $ \active ->
case decodeParams request of
Left err -> pure (Left err)
Right params ->
runMethod $
expectVisible (activeTui active) (selectorValue params)
>> pure (object ["ok" .= True])
dispatchExpectNotVisible :: ServerState -> RPC.JSONRPCRequest -> IO (Either RpcFailure DispatchOutcome)
dispatchExpectNotVisible state request =
withActiveSession state $ \active ->
case decodeParams request of
Left err -> pure (Left err)
Right params ->
runMethod $
expectNotVisible (activeTui active) (selectorValue params)
>> pure (object ["ok" .= True])
dispatchPing :: RPC.JSONRPCRequest -> IO (Either RpcFailure DispatchOutcome)
dispatchPing request =
case requireNoParams request of
Left err -> pure (Left err)
Right () ->
pure
( Right
( Continue
( object
[ "pong" .= True
, "version" .= ("0.1.0.0" :: String)
]
)
)
)
dispatchShutdown :: ServerState -> RPC.JSONRPCRequest -> IO (Either RpcFailure DispatchOutcome)
dispatchShutdown state request =
case requireNoParams request of
Left err -> pure (Left err)
Right () -> do
killActiveChildrenNow state
pure
( Right
( Shutdown
( object
[ "shuttingDown" .= True
]
)
)
)
withActiveSession :: ServerState -> (ActiveSession -> IO (Either RpcFailure DispatchOutcome)) -> IO (Either RpcFailure DispatchOutcome)
withActiveSession state action = do
maybeActive <- readIORef (stateActiveSession state)
case maybeActive of
Nothing -> pure (Left noActiveSessionError)
Just active -> action active
killActiveChildrenNow :: ServerState -> IO ()
killActiveChildrenNow state = do
maybeActive <- readIORef (stateActiveSession state)
case maybeActive of
Nothing -> pure ()
Just active -> killSessionChildrenNow (activeTui active)
runMethod :: IO Value -> IO (Either RpcFailure DispatchOutcome)
runMethod action = do
result <- try action :: IO (Either SomeException Value)
pure $
case result of
Left err -> Left (methodFailed (displayException err))
Right value -> Right (Continue value)
writeSuccessResponse :: RPC.RequestId -> Value -> IO Bool
writeSuccessResponse reqId resultValue =
writeJsonLine
( encode
(RPC.JSONRPCResponse RPC.rPC_VERSION reqId resultValue)
)
writeErrorResponse :: RPC.RequestId -> RpcFailure -> IO Bool
writeErrorResponse reqId failure =
writeJsonLine
( encode
( RPC.JSONRPCError
RPC.rPC_VERSION
reqId
(RPC.JSONRPCErrorInfo (failureCode failure) (failureMessage failure) (failureData failure))
)
)
writeJsonLine :: BL8.ByteString -> IO Bool
writeJsonLine bytes = do
writeResult <-
tryIOError $ do
BL8.hPutStr stdout bytes
BL8.hPutStr stdout "\n"
hFlush stdout
pure $
case writeResult of
Left _ -> False
Right () -> True
decodeParams :: (FromJSON a) => RPC.JSONRPCRequest -> Either RpcFailure a
decodeParams request =
case fromJSON (requestParams request) of
Error err -> Left (invalidParams err)
Success value -> Right value
requireNoParams :: RPC.JSONRPCRequest -> Either RpcFailure ()
requireNoParams request =
case requestParams request of
Null -> Right ()
Object keyMap
| KM.null keyMap -> Right ()
_ -> Left (invalidParams "expected params to be null or empty object")
invalidRequest :: String -> RpcFailure
invalidRequest details =
RpcFailure
{ failureCode = RPC.iNVALID_REQUEST
, failureMessage = "Invalid request"
, failureData = Just (object ["details" .= details])
}
invalidParams :: String -> RpcFailure
invalidParams details =
RpcFailure
{ failureCode = RPC.iNVALID_PARAMS
, failureMessage = "Invalid params"
, failureData = Just (object ["details" .= details])
}
parseError :: String -> RpcFailure
parseError details =
RpcFailure
{ failureCode = RPC.pARSE_ERROR
, failureMessage = "Parse error"
, failureData = Just (object ["details" .= details])
}
methodFailed :: String -> RpcFailure
methodFailed details =
RpcFailure
{ failureCode = -32004
, failureMessage = "Method failed"
, failureData = Just (object ["details" .= details])
}
noActiveSessionError :: RpcFailure
noActiveSessionError =
RpcFailure
{ failureCode = -32001
, failureMessage = "No active session"
, failureData = Nothing
}
sessionAlreadyStartedError :: RpcFailure
sessionAlreadyStartedError =
RpcFailure
{ failureCode = -32002
, failureMessage = "Session already started"
, failureData = Nothing
}
data StartParams = StartParams
{ startName :: Maybe Text
, startTimeoutSeconds :: Maybe Int
, startTerminalCols :: Maybe Int
, startTerminalRows :: Maybe Int
, startAmbiguityMode :: Maybe Text
, startSnapshotTheme :: Maybe Text
, startUpdateSnapshots :: Maybe Bool
}
instance FromJSON StartParams where
parseJSON value =
case value of
Null -> pure defaultStartParams
_ ->
withObject "StartParams" parseObject value
where
parseObject o =
StartParams
<$> o .:? "name"
<*> o .:? "timeoutSeconds"
<*> o .:? "terminalCols"
<*> o .:? "terminalRows"
<*> o .:? "ambiguityMode"
<*> o .:? "snapshotTheme"
<*> o .:? "updateSnapshots"
defaultStartParams :: StartParams
defaultStartParams =
StartParams
{ startName = Nothing
, startTimeoutSeconds = Nothing
, startTerminalCols = Nothing
, startTerminalRows = Nothing
, startAmbiguityMode = Nothing
, startSnapshotTheme = Nothing
, startUpdateSnapshots = Nothing
}
data LaunchParams = LaunchParams
{ launchCommand :: FilePath
, launchArgs :: [String]
}
instance FromJSON LaunchParams where
parseJSON =
withObject "LaunchParams" $ \o ->
LaunchParams
<$> o .: "command"
<*> o .:? "args" AesonTypes..!= []
data SendKeyParams = SendKeyParams
{ sendKeyValue :: Text
}
instance FromJSON SendKeyParams where
parseJSON =
withObject "SendKeyParams" $ \o ->
SendKeyParams <$> o .: "key"
data SendTextParams = SendTextParams
{ sendTextValue :: Text
}
instance FromJSON SendTextParams where
parseJSON =
withObject "SendTextParams" $ \o ->
SendTextParams <$> o .: "text"
data SendLineParams = SendLineParams
{ sendLineValue :: Text
}
instance FromJSON SendLineParams where
parseJSON =
withObject "SendLineParams" $ \o ->
SendLineParams <$> o .: "text"
data DumpViewParams = DumpViewParams
{ dumpName :: Text
}
instance FromJSON DumpViewParams where
parseJSON =
withObject "DumpViewParams" $ \o ->
DumpViewParams <$> o .: "name"
data ExpectSnapshotParams = ExpectSnapshotParams
{ expectSnapshotNameValue :: Text
}
instance FromJSON ExpectSnapshotParams where
parseJSON =
withObject "ExpectSnapshotParams" $ \o ->
ExpectSnapshotParams <$> o .: "name"
data SelectorParams = SelectorParams
{ selectorValue :: Selector
}
instance FromJSON SelectorParams where
parseJSON =
withObject "SelectorParams" $ \o ->
SelectorParams <$> (o .: "selector" >>= parseSelector)
data WaitForTextParams = WaitForTextParams
{ waitSelector :: Selector
, waitTimeoutMs :: Maybe Int
, waitPollIntervalMs :: Maybe Int
}
instance FromJSON WaitForTextParams where
parseJSON =
withObject "WaitForTextParams" $ \o ->
WaitForTextParams
<$> (o .: "selector" >>= parseSelector)
<*> o .:? "timeoutMs"
<*> o .:? "pollIntervalMs"
parseSelector :: Value -> AesonTypes.Parser Selector
parseSelector =
withObject "Selector" $ \o -> do
selectorType <- (T.toLower <$> (o .: "type" :: AesonTypes.Parser Text))
case selectorType of
"exact" -> Exact <$> o .: "text"
"regex" -> Regex <$> o .: "pattern"
"at" -> At <$> o .: "col" <*> o .: "row"
"within" -> do
rectValue <- o .: "rect" >>= parseRect
nested <- o .: "selector" >>= parseSelector
pure (Within rectValue nested)
"nth" -> Nth <$> o .: "index" <*> (o .: "selector" >>= parseSelector)
_ -> fail ("unknown selector type: " <> T.unpack selectorType)
parseRect :: Value -> AesonTypes.Parser Rect
parseRect =
withObject "Rect" $ \o ->
Rect
<$> o .: "col"
<*> o .: "row"
<*> o .: "width"
<*> o .: "height"
requestId :: RPC.JSONRPCRequest -> RPC.RequestId
requestId (RPC.JSONRPCRequest _ reqId _ _) = reqId
requestVersion :: RPC.JSONRPCRequest -> Text
requestVersion (RPC.JSONRPCRequest version _ _ _) = version
requestMethod :: RPC.JSONRPCRequest -> Text
requestMethod (RPC.JSONRPCRequest _ _ methodName _) = methodName
requestParams :: RPC.JSONRPCRequest -> Value
requestParams (RPC.JSONRPCRequest _ _ _ paramsValue) = paramsValue
applyStartParams :: ServerOptions -> StartParams -> Maybe AmbiguityMode -> RunOptions
applyStartParams options params ambiguityOverride =
defaultRunOptions
{ timeoutSeconds = maybe (serverTimeoutSeconds options) id (startTimeoutSeconds params)
, terminalCols = maybe (serverTerminalCols options) id (startTerminalCols params)
, terminalRows = maybe (serverTerminalRows options) id (startTerminalRows params)
, artifactsDir = serverArtifactsDir options
, ambiguityMode = maybe (serverAmbiguityMode options) id ambiguityOverride
, updateSnapshots = maybe False id (startUpdateSnapshots params)
, snapshotTheme = maybe "auto" T.unpack (startSnapshotTheme params)
}
resolveAmbiguityOverride :: Maybe Text -> Either String (Maybe AmbiguityMode)
resolveAmbiguityOverride maybeRaw =
case maybeRaw of
Nothing -> Right Nothing
Just raw ->
case parseAmbiguityMode raw of
Just mode -> Right (Just mode)
Nothing ->
Left "ambiguityMode must be one of: fail, first, first-visible"
parseAmbiguityMode :: Text -> Maybe AmbiguityMode
parseAmbiguityMode raw =
case map toLower (T.unpack (T.strip raw)) of
"fail" -> Just FailOnAmbiguous
"first" -> Just FirstVisibleMatch
"first-visible" -> Just FirstVisibleMatch
_ -> Nothing
parseSendKey :: Text -> Either String ([Modifier], Key)
parseSendKey rawKey =
if trimmed == "+"
then (,) [] <$> parseBaseKey trimmed
else case T.breakOn "+" trimmed of
(_, "") ->
(,) [] <$> parseBaseKey trimmed
(modifierText, remainder) ->
do
modifier <- parseModifier (T.strip modifierText)
keyValue <- parseModifiedKey (T.strip (T.drop 1 remainder))
pure ([modifier], keyValue)
where
trimmed = T.strip rawKey
parseModifier textValue =
case map toLower (T.unpack textValue) of
"ctrl" -> Right Control
"control" -> Right Control
"alt" -> Right Alt
"shift" -> Right Shift
_ -> Left ("unknown modifier: " <> T.unpack textValue)
parseModifiedKey textValue =
case T.unpack textValue of
[charValue] -> Right (CharKey charValue)
_ -> Left "modified keys must use a single character (for example Ctrl+C)"
parseBaseKey :: Text -> Either String Key
parseBaseKey keyText =
let lowered = map toLower (T.unpack (T.strip keyText))
in case lowered of
"enter" -> Right Enter
"esc" -> Right Esc
"escape" -> Right Esc
"tab" -> Right Tab
"backspace" -> Right Backspace
"arrowup" -> Right ArrowUp
"arrowdown" -> Right ArrowDown
"arrowleft" -> Right ArrowLeft
"arrowright" -> Right ArrowRight
"space" -> Right (CharKey ' ')
_ ->
case parseFunctionKey lowered of
Just functionNumber -> Right (FunctionKey functionNumber)
Nothing ->
case T.unpack (T.strip keyText) of
[charValue] -> Right (CharKey charValue)
_ -> Left ("unknown key: " <> T.unpack keyText)
parseFunctionKey :: String -> Maybe Int
parseFunctionKey lowered =
case lowered of
'f' : digits
| all isDigitAscii digits ->
case reads digits of
[(value, "")]
| value >= 1 && value <= 12 -> Just value
_ -> Nothing
_ -> Nothing
where
isDigitAscii c = c >= '0' && c <= '9'
snapshotMetaPath :: FilePath -> FilePath
snapshotMetaPath ansiPath =
if ".ansi.txt" `isSuffixOf` ansiPath
then take (length ansiPath - length (".ansi.txt" :: String)) ansiPath <> ".meta.json"
else ansiPath <> ".meta.json"
safeSnapshotStem :: String -> String
safeSnapshotStem input =
let lowered = map toLower input
normalized = map normalize lowered
compact = collapseDashes normalized
trimmed = trimDashes compact
in if null trimmed then "snapshot" else trimmed
where
normalize c
| isAlphaNum c = c
| otherwise = '-'
collapseDashes [] = []
collapseDashes ('-' : '-' : rest) = collapseDashes ('-' : rest)
collapseDashes (c : rest) = c : collapseDashes rest
trimDashes = reverse . dropWhile (== '-') . reverse . dropWhile (== '-')
fromMaybeText :: Text -> Maybe Text -> Text
fromMaybeText fallback maybeValue =
case maybeValue of
Just value -> value
Nothing -> fallback