taiwan-id-0.1.1.0: components/lib/taiwan-id-cli/Taiwan/ID/CLI.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuantifiedConstraints #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE UndecidableInstances #-}
{- HLINT ignore "Use newtype instead of data" -}
module Taiwan.ID.CLI where
import Control.Monad
( replicateM
)
import Control.Monad.Random
( MonadRandom
, evalRand
, getRandom
)
import Data.Functor.Identity
( Identity (Identity)
, runIdentity
)
import Data.Kind
( Type
)
import Data.Maybe
( fromMaybe
)
import Data.Text
( Text
)
import Data.Version
( showVersion
)
import Options.Applicative
( CommandFields
, Mod
, Parser
, ParserInfo
, ParserResult (CompletionInvoked, Failure, Success)
, argument
, auto
, command
, eitherReader
, execParserPure
, fullDesc
, header
, help
, hidden
, hsubparser
, info
, long
, metavar
, option
, optional
, prefs
, progDesc
, renderFailure
, showHelpOnEmpty
, simpleVersioner
, str
, (<**>)
)
import Options.Applicative.Extra
( helperWith
)
import Paths_taiwan_id
( version
)
import System.Random
( mkStdGen
)
import Taiwan.ID
( ID
)
import Taiwan.ID.CharIndex
( CharIndex (CharIndex)
)
import Taiwan.ID.CharSet
( CharSet (CharRange, CharSet)
)
import Taiwan.ID.Language
( Language (Chinese, English)
)
import qualified Data.Foldable as Foldable
import qualified Data.Text as T
import qualified Data.Text as Text
import qualified Taiwan.ID as ID
import qualified Taiwan.ID.Gender as Gender
import qualified Taiwan.ID.Issuer as Issuer
import qualified Taiwan.ID.Region as Region
--------------------------------------------------------------------------------
-- Commands
--------------------------------------------------------------------------------
data Stage = Raw | Resolved
type family Optional (s :: Stage) :: Type -> Type where
Optional Raw = Maybe
Optional Resolved = Identity
type family Required (s :: Stage) :: Type -> Type where
Required Raw = Identity
Required Resolved = Identity
data Command (s :: Stage)
= Decode (DecodeCommand s)
| Generate (GenerateCommand s)
| Validate (ValidateCommand s)
deriving instance Eq (Command Raw)
deriving instance Show (Command Raw)
type CommandDescription = Mod CommandFields (Command Raw)
type CommandParser args = Parser (args Raw)
type CommandResolver m args = args Raw -> m (args Resolved)
type CommandRunner args = args Resolved -> CommandLineResult
--------------------------------------------------------------------------------
-- Results
--------------------------------------------------------------------------------
data CommandLineResult
= -- | Lines to be printed to stdout, with exit code 0.
CommandLineSuccess [Text]
| -- | Lines to be printed to stderr, with exit code 1.
CommandLineFailure [Text]
--------------------------------------------------------------------------------
-- Main
--------------------------------------------------------------------------------
run :: MonadRandom m => [String] -> m CommandLineResult
run args =
case execParserPure (prefs showHelpOnEmpty) topLevelParser args of
Failure failure ->
pure $
CommandLineFailure
(T.lines $ T.pack $ fst $ renderFailure failure "taiwan-id")
CompletionInvoked _ ->
pure $ CommandLineSuccess []
Success unresolvedCommand -> do
resolved <- commandResolver unresolvedCommand
pure $ case resolved of
Decode resolvedCommand -> decodeCommandRunner resolvedCommand
Generate resolvedCommand -> generateCommandRunner resolvedCommand
Validate resolvedCommand -> validateCommandRunner resolvedCommand
topLevelParser :: ParserInfo (Command Raw)
topLevelParser =
info
(commandParser <**> helpOption <**> versionOption)
$ mconcat
[ fullDesc
, progDesc "Tools for working with Taiwan uniform identification numbers"
, header "taiwan-id - Taiwan uniform identification number tools"
]
where
helpOption =
helperWith $
mconcat
[ long "help"
, help "Show this help text"
, hidden
]
versionOption =
simpleVersioner (showVersion version)
commandParser :: CommandParser Command
commandParser =
hsubparser $
mconcat
[ generateCommandDescription
, validateCommandDescription
, decodeCommandDescription
]
commandResolver :: MonadRandom m => CommandResolver m Command
commandResolver = \case
Decode args -> Decode <$> decodeCommandResolver args
Generate args -> Generate <$> generateCommandResolver args
Validate args -> Validate <$> validateCommandResolver args
--------------------------------------------------------------------------------
-- Decode
--------------------------------------------------------------------------------
decodeCommandDescription :: CommandDescription
decodeCommandDescription =
command
"decode"
( info
(Decode <$> decodeCommandParser)
(progDesc "Decode an identification number")
)
data DecodeCommand (s :: Stage) = DecodeCommand
{ idText :: Required s Text
, language :: Optional s Language
}
deriving instance Eq (DecodeCommand Raw)
deriving instance Show (DecodeCommand Raw)
decodeCommandParser :: CommandParser DecodeCommand
decodeCommandParser =
DecodeCommand
<$> argument str (metavar "ID")
<*> languageOption
where
languageOption :: Parser (Maybe Language)
languageOption =
optional $
option
(eitherReader parseLanguage)
( long "language"
<> metavar "LANG"
<> help "Output language: English or Chinese (default: English)"
)
parseLanguage :: String -> Either String Language
parseLanguage = \case
"English" -> Right English
"Chinese" -> Right Chinese
s ->
Left $
"Unknown language '"
<> s
<> "': expected 'English' or 'Chinese'"
decodeCommandResolver :: Applicative m => CommandResolver m DecodeCommand
decodeCommandResolver DecodeCommand {idText, language} =
pure
DecodeCommand
{ idText
, language = Identity (fromMaybe defaultLanguage language)
}
where
defaultLanguage :: Language
defaultLanguage = English
decodeCommandRunner :: CommandRunner DecodeCommand
decodeCommandRunner DecodeCommand {idText, language} =
case ID.fromText (resolve idText) of
Left err ->
CommandLineFailure $
renderIdFromTextError (resolve idText) err
Right i ->
CommandLineSuccess $
T.lines $
formatFields (resolve language) i
formatFields :: Language -> ID -> Text
formatFields language i =
T.unlines (map (formatField maxWidth language) fields)
where
maxWidth = maximum (map (T.length . fst) fields)
fields =
[ (issuerKey language, Issuer.toText language (ID.getIssuer i))
, (genderKey language, Gender.toText language (ID.getGender i))
, (regionKey language, Region.toText language (ID.getRegion i))
]
issuerKey = \case
English -> "Issuer"
Chinese -> "核發機關"
genderKey = \case
English -> "Gender"
Chinese -> "性別"
regionKey = \case
English -> "Region"
Chinese -> "地區"
formatField :: Int -> Language -> (Text, Text) -> Text
formatField maxWidth language (k, v) =
T.concat
[ k
, padding language
, separator language
, v
]
where
padding =
T.replicate (maxWidth - T.length k) . \case
English -> " "
Chinese -> "\x3000"
separator = \case
English -> ": "
Chinese -> ":"
--------------------------------------------------------------------------------
-- Generate
--------------------------------------------------------------------------------
generateCommandDescription :: CommandDescription
generateCommandDescription =
command
"generate"
( info
(Generate <$> generateCommandParser)
(progDesc "Generate one or more random identification numbers")
)
data GenerateCommand (s :: Stage) = GenerateCommand
{ count :: Optional s Int
, seed :: Optional s Int
}
deriving instance Eq (GenerateCommand Raw)
deriving instance Show (GenerateCommand Raw)
generateCommandParser :: CommandParser GenerateCommand
generateCommandParser =
GenerateCommand
<$> optional
( option
auto
( long "count"
<> metavar "N"
<> help
"Number of identification numbers to generate (default: 1)"
)
)
<*> optional
( option
auto
( long "seed"
<> metavar "N"
<> help "Random seed for reproducible generation"
)
)
generateCommandResolver :: MonadRandom m => CommandResolver m GenerateCommand
generateCommandResolver GenerateCommand {count, seed} = do
resolvedSeed <- maybe getRandom pure seed
pure
GenerateCommand
{ count = Identity (fromMaybe defaultCount count)
, seed = Identity resolvedSeed
}
where
defaultCount :: Int
defaultCount = 1
generateCommandRunner :: CommandRunner GenerateCommand
generateCommandRunner GenerateCommand {count, seed}
| count < 1 =
CommandLineFailure
["Count argument should be a non-zero natural number."]
| otherwise =
CommandLineSuccess $
map ID.toText $
evalRand
(replicateM (resolve count) ID.generate)
(mkStdGen (resolve seed))
--------------------------------------------------------------------------------
-- Validate
--------------------------------------------------------------------------------
validateCommandDescription :: CommandDescription
validateCommandDescription =
command
"validate"
( info
(Validate <$> validateCommandParser)
(progDesc "Validate an identification number")
)
data ValidateCommand (s :: Stage) = ValidateCommand
{ idText :: Required s Text
}
deriving instance Eq (ValidateCommand Raw)
deriving instance Show (ValidateCommand Raw)
validateCommandParser :: CommandParser ValidateCommand
validateCommandParser = ValidateCommand <$> argument str (metavar "ID")
validateCommandResolver :: Applicative m => CommandResolver m ValidateCommand
validateCommandResolver ValidateCommand {idText} = pure ValidateCommand {idText}
validateCommandRunner :: CommandRunner ValidateCommand
validateCommandRunner ValidateCommand {idText} =
case ID.fromText (resolve idText) of
Right _ ->
CommandLineSuccess []
Left err ->
CommandLineFailure $
renderIdFromTextError (resolve idText) err
--------------------------------------------------------------------------------
-- Common
--------------------------------------------------------------------------------
renderIdFromTextError :: Text -> ID.FromTextError -> [Text]
renderIdFromTextError input =
\case
ID.InvalidChecksum ->
[ "Invalid checksum."
]
ID.InvalidLength ->
[ "Invalid length."
, "An identification number must be exactly 10 characters in length."
]
ID.InvalidChar (CharIndex i) charSet ->
[ "Invalid character:"
, input
, Text.replicate i (Text.singleton ' ') <> "^"
, "Character at this position must be " <> describeCharSet charSet <> "."
]
describeCharSet :: CharSet -> Text
describeCharSet = \case
CharRange lo hi ->
"a character in the range "
<> "["
<> Text.singleton lo
<> " .. "
<> Text.singleton hi
<> "]"
CharSet neSet ->
"a character from the set "
<> "{"
<> Text.intercalate ", " (Text.singleton <$> Foldable.toList neSet)
<> "}"
resolve :: Identity a -> a
resolve = runIdentity