packages feed

ansido-1.0.0: src/Ansido/Domain.hs

-- | Domain types and pure transitions for the task board.
--
-- Every value here is immutable. Transitions return a new 'Workspace' and
-- report failures as data through 'DomainError'.
module Ansido.Domain
    ( -- * Identifiers and text.
      CategoryId
    , categoryIdValue
    , CategoryName
    , categoryNameText
    , TaskId
    , taskIdValue
    , TaskTitle
    , taskTitleText
    , TaskContent
    , taskContentText

      -- * Entities.
    , Category
    , categoryId
    , categoryName
    , Status (..)
    , Task
    , taskId
    , taskTitle
    , taskContent
    , taskStatus
    , taskCategory
    , Workspace

      -- * Errors.
    , DomainError (..)
    , domainErrorMessage

      -- * Construction.
    , emptyWorkspace
    , mkCategoryId
    , mkTaskId
    , mkCategory
    , mkTask
    , mkCategoryName
    , mkTaskTitle
    , mkTaskContent
    , mkWorkspace

      -- * Queries.
    , workspaceCategories
    , workspaceTasks
    , findCategory
    , findTask
    , categoryTasks
    , tasksByStatus
    , taskCount
    , statusLabel
    , statusKey
    , statusFromKey
    , nextStatus
    , indexMaybe

      -- * Transitions.
    , addCategory
    , editCategory
    , deleteCategory
    , addTask
    , moveTask
    , advanceTask
    , editTask
    , deleteTask
    ) where

--------------------------------------------------------------------------------
import Data.Char (isControl, isSpace)
import Data.List (dropWhileEnd, find, nub, sort)
import Data.Maybe (listToMaybe)
import Numeric.Natural (Natural)

-- Identifiers.
--------------------------------------------------------------------------------

-- | Stable identifier of a category. Zero is not a valid identifier.
newtype CategoryId = CategoryId {categoryIdValue :: Natural}
    deriving (Eq, Ord, Show)

-- | Validated name of a category.
newtype CategoryName = CategoryName {categoryNameText :: String}
    deriving (Eq, Ord, Show)

-- | Stable identifier of a task. Zero is not a valid identifier.
newtype TaskId = TaskId {taskIdValue :: Natural}
    deriving (Eq, Ord, Show)

-- | Validated title of a task.
newtype TaskTitle = TaskTitle {taskTitleText :: String}
    deriving (Eq, Ord, Show)

-- | Validated note attached to a task. It may be empty.
newtype TaskContent = TaskContent {taskContentText :: String}
    deriving (Eq, Ord, Show)

-- Entities.
--------------------------------------------------------------------------------

-- | A category groups tasks and scopes the board.
data Category = Category
    { categoryId :: !CategoryId
    , categoryName :: !CategoryName
    }
    deriving (Eq, Show)

-- | The three statuses of the board.
data Status = Todo | Doing | Done
    deriving (Eq, Ord, Show)

-- | A task with a title, a note, a status and the category that owns it.
data Task = Task
    { taskId :: !TaskId
    , taskTitle :: !TaskTitle
    , taskContent :: !TaskContent
    , taskStatus :: !Status
    , taskCategory :: !CategoryId
    }
    deriving (Eq, Show)

-- | The whole persisted state, with hidden constructors to keep invariants.
data Workspace = Workspace
    { workspaceCategories :: [Category]
    , workspaceTasks :: [Task]
    }
    deriving (Eq, Show)

--------------------------------------------------------------------------------

-- | Human readable label of a status.
statusLabel :: Status -> String
statusLabel Todo = "Todo"
statusLabel Doing = "Doing"
statusLabel Done = "Done"

-- | Stable key used by the persistence format.
statusKey :: Status -> String
statusKey Todo = "todo"
statusKey Doing = "doing"
statusKey Done = "done"

-- | Parses a stable status key.
statusFromKey :: String -> Maybe Status
statusFromKey key =
    case key of
        "todo" -> Just Todo
        "doing" -> Just Doing
        "done" -> Just Done
        _ -> Nothing

-- | The next status in the cycle used by the status key.
nextStatus :: Status -> Status
nextStatus Todo = Doing
nextStatus Doing = Done
nextStatus Done = Todo

--------------------------------------------------------------------------------

-- | Builds a category identifier, rejecting zero.
mkCategoryId :: Natural -> Either DomainError CategoryId
mkCategoryId raw
    | raw == 0 = Left InvalidCategoryId
    | otherwise = Right (CategoryId raw)

-- | Builds a task identifier, rejecting zero.
mkTaskId :: Natural -> Either DomainError TaskId
mkTaskId raw
    | raw == 0 = Left InvalidTaskId
    | otherwise = Right (TaskId raw)

-- | Builds a category from validated parts.
mkCategory :: CategoryId -> CategoryName -> Category
mkCategory = Category

-- | Builds a task from validated parts.
mkTask ::
    TaskId ->
    TaskTitle ->
    TaskContent ->
    Status ->
    CategoryId ->
    Task
mkTask = Task

-- | A workspace with no categories and no tasks.
emptyWorkspace :: Workspace
emptyWorkspace = Workspace [] []

-- | Validates a category name.
mkCategoryName :: String -> Either DomainError CategoryName
mkCategoryName raw =
    CategoryName
        <$> validateText
            EmptyCategoryName
            categoryNameLimit
            CategoryNameTooLong
            raw

-- | Validates a task title.
mkTaskTitle :: String -> Either DomainError TaskTitle
mkTaskTitle raw =
    TaskTitle
        <$> validateText
            EmptyTaskTitle
            taskTitleLimit
            TaskTitleTooLong
            raw

-- | Validates a task note. An empty note is allowed.
mkTaskContent :: String -> Either DomainError TaskContent
mkTaskContent raw =
    let cleaned = trim raw
     in if any isControl cleaned
            then Left ControlCharacterInText
            else
                if length cleaned > taskContentLimit
                    then Left (TaskContentTooLong taskContentLimit)
                    else Right (TaskContent cleaned)

validateText ::
    DomainError ->
    Int ->
    (Int -> DomainError) ->
    String ->
    Either DomainError String
validateText emptyError limit tooLongError raw =
    let cleaned = trim raw
     in if null cleaned
            then Left emptyError
            else
                if any isControl cleaned
                    then Left ControlCharacterInText
                    else
                        if length cleaned > limit
                            then Left (tooLongError limit)
                            else Right cleaned

-- | Checks the workspace invariants.
--
-- Categories must have unique identifiers and names, tasks must have unique
-- identifiers and every task must point at an existing category.
mkWorkspace :: [Category] -> [Task] -> Either DomainError Workspace
mkWorkspace categories tasks
    | hasDuplicate (map categoryId categories) = Left DuplicateCategoryId
    | hasDuplicate (map categoryName categories) = Left DuplicateCategoryName
    | hasDuplicate (map taskId tasks) = Left DuplicateTaskId
    | otherwise =
        case firstUnknownCategory categories tasks of
            Just unknown -> Left (UnknownCategory unknown)
            Nothing -> Right (Workspace categories tasks)
  where
    hasDuplicate values = length values /= length (nub (sort values))

firstUnknownCategory :: [Category] -> [Task] -> Maybe CategoryId
firstUnknownCategory categories tasks =
    listToMaybe (map taskCategory (filter unknown tasks))
  where
    unknown task = not (any ((== taskCategory task) . categoryId) categories)

--------------------------------------------------------------------------------

-- | Finds a category by identifier.
findCategory :: CategoryId -> Workspace -> Maybe Category
findCategory identifier =
    find ((== identifier) . categoryId) . workspaceCategories

-- | Finds a task by identifier.
findTask :: TaskId -> Workspace -> Maybe Task
findTask identifier =
    find ((== identifier) . taskId) . workspaceTasks

-- | Tasks of a category, in insertion order.
categoryTasks :: CategoryId -> Workspace -> [Task]
categoryTasks identifier =
    filter ((== identifier) . taskCategory) . workspaceTasks

-- | Tasks with a given status, preserving the input order.
tasksByStatus :: Status -> [Task] -> [Task]
tasksByStatus status = filter ((== status) . taskStatus)

-- | Number of tasks in a category and status.
taskCount :: CategoryId -> Status -> Workspace -> Int
taskCount identifier status =
    length . tasksByStatus status . categoryTasks identifier

-- | Safe list indexing, used instead of the partial '!!' operator.
indexMaybe :: Int -> [a] -> Maybe a
indexMaybe index values
    | index < 0 = Nothing
    | otherwise =
        case drop index values of
            (value : _) -> Just value
            [] -> Nothing

--------------------------------------------------------------------------------

-- | Adds a category and returns it together with the new workspace.
addCategory :: CategoryName -> Workspace -> Either DomainError (Category, Workspace)
addCategory name workspace
    | any ((== name) . categoryName) (workspaceCategories workspace) =
        Left (CategoryAlreadyExists name)
    | otherwise =
        let category = Category (nextCategoryId workspace) name
         in do
                updated <-
                    mkWorkspace
                        (workspaceCategories workspace <> [category])
                        (workspaceTasks workspace)
                Right (category, updated)

-- | Renames a category.
--
-- The new name must not belong to another category.
editCategory ::
    CategoryId ->
    CategoryName ->
    Workspace ->
    Either DomainError Workspace
editCategory identifier name workspace
    | not (any ((== identifier) . categoryId) categories) =
        Left (UnknownCategory identifier)
    | any sameNameOther categories =
        Left (CategoryAlreadyExists name)
    | otherwise =
        mkWorkspace (map rename categories) (workspaceTasks workspace)
  where
    categories = workspaceCategories workspace
    sameNameOther category =
        categoryId category /= identifier && categoryName category == name
    rename category
        | categoryId category == identifier = category{categoryName = name}
        | otherwise = category

-- | Removes a category together with every task it owns.
deleteCategory :: CategoryId -> Workspace -> Either DomainError Workspace
deleteCategory identifier workspace
    | any ((== identifier) . categoryId) (workspaceCategories workspace) =
        mkWorkspace
            (filter ((/= identifier) . categoryId) (workspaceCategories workspace))
            (filter ((/= identifier) . taskCategory) (workspaceTasks workspace))
    | otherwise = Left (UnknownCategory identifier)

-- | Adds a task to a category, always starting at 'Todo'.
addTask ::
    CategoryId ->
    TaskTitle ->
    TaskContent ->
    Workspace ->
    Either DomainError (Task, Workspace)
addTask identifier title content workspace
    | not (any ((== identifier) . categoryId) (workspaceCategories workspace)) =
        Left (UnknownCategory identifier)
    | otherwise =
        let task = Task (nextTaskId workspace) title content Todo identifier
         in do
                updated <-
                    mkWorkspace
                        (workspaceCategories workspace)
                        (workspaceTasks workspace <> [task])
                Right (task, updated)

-- | Sets the status of a task.
moveTask :: TaskId -> Status -> Workspace -> Either DomainError Workspace
moveTask identifier status workspace =
    case findTask identifier workspace of
        Nothing -> Left (UnknownTask identifier)
        Just _ ->
            mkWorkspace
                (workspaceCategories workspace)
                (replaceStatus identifier status (workspaceTasks workspace))

replaceStatus :: TaskId -> Status -> [Task] -> [Task]
replaceStatus identifier status =
    map update
  where
    update task
        | taskId task == identifier = task{taskStatus = status}
        | otherwise = task

-- | Moves a task to the next status in the cycle.
advanceTask :: TaskId -> Workspace -> Either DomainError Workspace
advanceTask identifier workspace =
    case findTask identifier workspace of
        Nothing -> Left (UnknownTask identifier)
        Just task -> moveTask identifier (nextStatus (taskStatus task)) workspace

-- | Replaces the title and note of a task.
editTask ::
    TaskId ->
    TaskTitle ->
    TaskContent ->
    Workspace ->
    Either DomainError Workspace
editTask identifier title content workspace =
    case findTask identifier workspace of
        Nothing -> Left (UnknownTask identifier)
        Just _ ->
            mkWorkspace
                (workspaceCategories workspace)
                (map update (workspaceTasks workspace))
  where
    update task
        | taskId task == identifier =
            task{taskTitle = title, taskContent = content}
        | otherwise = task

-- | Removes a task.
deleteTask :: TaskId -> Workspace -> Either DomainError Workspace
deleteTask identifier workspace =
    if any ((== identifier) . taskId) (workspaceTasks workspace)
        then
            mkWorkspace
                (workspaceCategories workspace)
                (filter ((/= identifier) . taskId) (workspaceTasks workspace))
        else Left (UnknownTask identifier)

nextCategoryId :: Workspace -> CategoryId
nextCategoryId workspace =
    CategoryId
        ( nextIdentifier
            (categoryIdValue . categoryId)
            (workspaceCategories workspace)
        )

nextTaskId :: Workspace -> TaskId
nextTaskId workspace =
    TaskId (nextIdentifier (taskIdValue . taskId) (workspaceTasks workspace))

nextIdentifier :: (a -> Natural) -> [a] -> Natural
nextIdentifier get values = 1 + foldr (max . get) 0 values

--------------------------------------------------------------------------------

-- | Failures of domain construction and transitions.
data DomainError
    = EmptyCategoryName
    | EmptyTaskTitle
    | CategoryNameTooLong Int
    | TaskTitleTooLong Int
    | TaskContentTooLong Int
    | ControlCharacterInText
    | InvalidCategoryId
    | InvalidTaskId
    | UnknownCategory CategoryId
    | UnknownTask TaskId
    | DuplicateCategoryId
    | DuplicateTaskId
    | DuplicateCategoryName
    | CategoryAlreadyExists CategoryName
    deriving (Eq, Show)

-- | English message for a domain error.
domainErrorMessage :: DomainError -> String
domainErrorMessage err =
    case err of
        EmptyCategoryName -> "The category name cannot be empty."
        EmptyTaskTitle -> "The task title cannot be empty."
        CategoryNameTooLong limit ->
            "The category name must have at most " <> show limit <> " characters."
        TaskTitleTooLong limit ->
            "The task title must have at most " <> show limit <> " characters."
        TaskContentTooLong limit ->
            "The task note must have at most " <> show limit <> " characters."
        ControlCharacterInText ->
            "The text cannot contain control characters."
        InvalidCategoryId ->
            "The category identifier must be positive."
        InvalidTaskId ->
            "The task identifier must be positive."
        UnknownCategory identifier ->
            "Unknown category: " <> show (categoryIdValue identifier) <> "."
        UnknownTask identifier ->
            "Unknown task: " <> show (taskIdValue identifier) <> "."
        DuplicateCategoryId -> "Two categories share the same identifier."
        DuplicateTaskId -> "Two tasks share the same identifier."
        DuplicateCategoryName -> "Two categories share the same name."
        CategoryAlreadyExists name ->
            "The category already exists: " <> categoryNameText name <> "."

--------------------------------------------------------------------------------

categoryNameLimit :: Int
categoryNameLimit = 40

taskTitleLimit :: Int
taskTitleLimit = 120

taskContentLimit :: Int
taskContentLimit = 500

trim :: String -> String
trim = dropWhileEnd isSpace . dropWhile isSpace