packages feed

ansido-1.0.0: src/Ansido/Persistence.hs

-- | Plain text persistence with errors as data.
--
-- The format is line based and tab separated, with a versioned header. File
-- IO happens only in 'loadWorkspace' and 'saveWorkspace'; parsing and
-- rendering are pure and testable.
module Ansido.Persistence
    ( PersistenceError (..)
    , persistenceErrorMessage
    , encodeWorkspace
    , decodeWorkspace
    , defaultDataFile
    , loadWorkspace
    , saveWorkspace
    ) where

--------------------------------------------------------------------------------
import Control.Exception (IOException, evaluate, try)
import Data.List (intercalate)
import Data.Maybe (catMaybes)
import Numeric.Natural (Natural)
import System.Directory
    ( createDirectoryIfMissing
    , doesFileExist
    , getHomeDirectory
    , renameFile
    )
import System.Environment (lookupEnv)
import System.FilePath (takeDirectory, (</>))
import System.IO
    ( IOMode (ReadMode, WriteMode)
    , hGetContents
    , hPutStr
    , hSetEncoding
    , hSetNewlineMode
    , noNewlineTranslation
    , utf8
    , withFile
    )
import Text.Read (readMaybe)

import Ansido.Domain

-- Errors.
--------------------------------------------------------------------------------

-- | Everything that can go wrong while reading or writing the data file.
data PersistenceError
    = ReadFailed FilePath String
    | WriteFailed FilePath String
    | FilePathUnavailable String
    | UnsupportedHeader String
    | MalformedLine Int String
    | InvalidWorkspace DomainError
    deriving (Eq, Show)

-- | English message for a persistence error.
persistenceErrorMessage :: PersistenceError -> String
persistenceErrorMessage err =
    case err of
        ReadFailed path reason ->
            "Could not read " <> path <> ": " <> reason
        WriteFailed path reason ->
            "Could not write " <> path <> ": " <> reason
        FilePathUnavailable reason -> reason
        UnsupportedHeader header ->
            "Unsupported file header: " <> header
        MalformedLine lineNumber reason ->
            "Malformed line " <> show lineNumber <> ": " <> reason
        InvalidWorkspace domainError -> domainErrorMessage domainError

-- Format.
--------------------------------------------------------------------------------

formatHeader :: String
formatHeader = headerFor 2

headerFor :: Int -> String
headerFor version = "ansido\t" <> show version

headerVersion :: String -> Maybe Int
headerVersion header =
    lookup header [(headerFor 1, 1), (headerFor 2, 2)]

-- | Renders a workspace into the plain text format.
encodeWorkspace :: Workspace -> String
encodeWorkspace workspace =
    unlines
        ( formatHeader
            : map encodeCategory (workspaceCategories workspace)
                <> map encodeTask (workspaceTasks workspace)
        )

encodeCategory :: Category -> String
encodeCategory category =
    joinTabs
        [ "category"
        , show (categoryIdValue (categoryId category))
        , categoryNameText (categoryName category)
        ]

encodeTask :: Task -> String
encodeTask task =
    joinTabs
        [ "task"
        , show (taskIdValue (taskId task))
        , show (categoryIdValue (taskCategory task))
        , statusKey (taskStatus task)
        , taskTitleText (taskTitle task)
        , taskContentText (taskContent task)
        ]

joinTabs :: [String] -> String
joinTabs = intercalate "\t"

-- | Parses the plain text format into a workspace.
--
-- Carriage returns are removed first, so files written or copied on Windows
-- still parse on every platform.
decodeWorkspace :: String -> Either PersistenceError Workspace
decodeWorkspace raw =
    case lines (filter (/= '\r') raw) of
        [] -> Right emptyWorkspace
        (header : body) ->
            case headerVersion header of
                Nothing -> Left (UnsupportedHeader header)
                Just version -> decodeEntries version body

decodeEntries :: Int -> [String] -> Either PersistenceError Workspace
decodeEntries version body = do
    entries <- traverse decodeAt (zip [2 ..] body)
    let records = catMaybes entries
        categories = [category | Left category <- records]
        tasks = [task | Right task <- records]
    case mkWorkspace categories tasks of
        Left domainError -> Left (InvalidWorkspace domainError)
        Right workspace -> Right workspace
  where
    decodeAt (lineNumber, line)
        | null line = Right Nothing
        | otherwise = Just <$> decodeLine version lineNumber line

decodeLine ::
    Int ->
    Int ->
    String ->
    Either PersistenceError (Either Category Task)
decodeLine version lineNumber line =
    case (version, splitTabs line) of
        (_, ["category", idText, nameText]) -> do
            identifier <- parseCategoryId lineNumber idText
            name <- parseCategoryName lineNumber nameText
            Right (Left (mkCategory identifier name))
        (1, ["task", idText, categoryText, statusText, titleText]) ->
            decodeTask lineNumber idText categoryText statusText titleText ""
        (2, ["task", idText, categoryText, statusText, titleText, contentText]) ->
            decodeTask lineNumber idText categoryText statusText titleText contentText
        _ -> Left (MalformedLine lineNumber "Expected a category or task record.")

decodeTask ::
    Int ->
    String ->
    String ->
    String ->
    String ->
    String ->
    Either PersistenceError (Either Category Task)
decodeTask lineNumber idText categoryText statusText titleText contentText = do
    identifier <- parseTaskId lineNumber idText
    categoryIdentifier <- parseCategoryId lineNumber categoryText
    status <- parseStatus lineNumber statusText
    title <- parseTaskTitle lineNumber titleText
    content <- parseTaskContent lineNumber contentText
    Right (Right (mkTask identifier title content status categoryIdentifier))

splitTabs :: String -> [String]
splitTabs value =
    case break (== '\t') value of
        (field, []) -> [field]
        (field, _ : rest) -> field : splitTabs rest

parseCategoryId :: Int -> String -> Either PersistenceError CategoryId
parseCategoryId lineNumber text =
    case readNatural text of
        Nothing ->
            Left (MalformedLine lineNumber ("Invalid category id: " <> text))
        Just value ->
            case mkCategoryId value of
                Left domainError ->
                    Left
                        ( MalformedLine
                            lineNumber
                            (domainErrorMessage domainError)
                        )
                Right identifier -> Right identifier

parseTaskId :: Int -> String -> Either PersistenceError TaskId
parseTaskId lineNumber text =
    case readNatural text of
        Nothing ->
            Left (MalformedLine lineNumber ("Invalid task id: " <> text))
        Just value ->
            case mkTaskId value of
                Left domainError ->
                    Left
                        ( MalformedLine
                            lineNumber
                            (domainErrorMessage domainError)
                        )
                Right identifier -> Right identifier

parseCategoryName :: Int -> String -> Either PersistenceError CategoryName
parseCategoryName lineNumber text =
    case mkCategoryName text of
        Left domainError -> Left (MalformedLine lineNumber (domainErrorMessage domainError))
        Right name -> Right name

parseTaskTitle :: Int -> String -> Either PersistenceError TaskTitle
parseTaskTitle lineNumber text =
    case mkTaskTitle text of
        Left domainError -> Left (MalformedLine lineNumber (domainErrorMessage domainError))
        Right title -> Right title

parseTaskContent :: Int -> String -> Either PersistenceError TaskContent
parseTaskContent lineNumber text =
    case mkTaskContent text of
        Left domainError -> Left (MalformedLine lineNumber (domainErrorMessage domainError))
        Right content -> Right content

parseStatus :: Int -> String -> Either PersistenceError Status
parseStatus lineNumber text =
    case statusFromKey text of
        Just status -> Right status
        Nothing -> Left (MalformedLine lineNumber ("Unknown status: " <> text))

readNatural :: String -> Maybe Natural
readNatural = readMaybe

-- File access.
--------------------------------------------------------------------------------

-- | Resolves the data file, honouring @ANSIDO_FILE@ and falling back to the
-- user home directory with @.ansido/board.tsv@.
--
-- 'getHomeDirectory' reads @HOME@ on Unix and the platform profile directory on
-- Windows, so the same code works on every system.
defaultDataFile :: IO (Either PersistenceError FilePath)
defaultDataFile = do
    override <- lookupEnv "ANSIDO_FILE"
    case override of
        Just path | not (null path) -> pure (Right path)
        _ -> do
            homeResult <- tryIO getHomeDirectory
            pure $ case homeResult of
                Right directory
                    | not (null directory) ->
                        Right (directory </> ".ansido" </> "board.tsv")
                _ ->
                    Left
                        ( FilePathUnavailable
                            "Set ANSIDO_FILE or a home directory to choose a data file."
                        )

-- | Loads the data file, treating a missing file as an empty workspace.
loadWorkspace :: FilePath -> IO (Either PersistenceError Workspace)
loadWorkspace path = do
    existsResult <- tryIO (doesFileExist path)
    case existsResult of
        Left exception -> pure (Left (ReadFailed path (show exception)))
        Right False -> pure (Right emptyWorkspace)
        Right True -> do
            result <- tryIO (readTextFile path)
            pure $ case result of
                Left exception -> Left (ReadFailed path (show exception))
                Right fileContent -> decodeWorkspace fileContent

-- | Saves the data file atomically through a temporary sibling file.
--
-- 'renameFile' replaces an existing destination on every supported platform.
-- It is atomic on POSIX and uses @MoveFileEx@ with @MOVEFILE_REPLACE_EXISTING@
-- on Windows.
saveWorkspace :: FilePath -> Workspace -> IO (Either PersistenceError ())
saveWorkspace path workspace = do
    let directory = takeDirectory path
    directoryResult <- tryIO (createDirectoryIfMissing True directory)
    case directoryResult of
        Left exception -> pure (Left (WriteFailed path (show exception)))
        Right () -> do
            let temporary = path <> ".tmp"
            writeResult <- tryIO (writeTextFile temporary (encodeWorkspace workspace))
            case writeResult of
                Left exception -> pure (Left (WriteFailed path (show exception)))
                Right () -> do
                    renameResult <- tryIO (renameFile temporary path)
                    pure $ case renameResult of
                        Left exception -> Left (WriteFailed path (show exception))
                        Right () -> Right ()

-- | Reads a UTF-8 file without newline translation.
--
-- The whole content is forced before the handle closes, so the result does not
-- depend on lazy IO and the format stays identical on every platform.
readTextFile :: FilePath -> IO String
readTextFile path =
    withFile path ReadMode $ \handle -> do
        hSetEncoding handle utf8
        hSetNewlineMode handle noNewlineTranslation
        contents <- hGetContents handle
        _ <- evaluate (length contents)
        pure contents

-- | Writes a UTF-8 file without newline translation.
writeTextFile :: FilePath -> String -> IO ()
writeTextFile path contents =
    withFile path WriteMode $ \handle -> do
        hSetEncoding handle utf8
        hSetNewlineMode handle noNewlineTranslation
        hPutStr handle contents

tryIO :: IO a -> IO (Either IOException a)
tryIO = try