Initial checkin.

Change-Id: Ib0f503f39cedb6fcc11f80a3b309e4cbb7ed438f
diff --git a/.ghcid b/.ghcid
new file mode 100644
index 0000000..1c46585
--- /dev/null
+++ b/.ghcid
@@ -0,0 +1 @@
+--warnings --command "stack ghci mulkup:lib mulkup:test:mulkup-test --ghci-options=-fobject-code" --test="Main.main"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4353b27
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+*~
+*.iml
+
+/config.dhall
+
+/.idea
+/.stack-work
diff --git a/cabal.project b/cabal.project
new file mode 100644
index 0000000..5c3d38e
--- /dev/null
+++ b/cabal.project
@@ -0,0 +1,11 @@
+packages: .
+
+optional-packages: vendor/*/*.cabal
+
+documentation: True
+haddock-hoogle: True
+
+allow-newer:
+  , co-log:ansi-terminal
+  , co-log-polysemy:polysemy
+  , polysemy:Cabal
diff --git a/config.example.dhall b/config.example.dhall
new file mode 100644
index 0000000..cdf87ae
--- /dev/null
+++ b/config.example.dhall
@@ -0,0 +1,54 @@
+let home = "/Users/mulk" in
+
+{ host = "mulkinator"
+, stashes =
+    [ { name = "mulk.tar"
+      , baseDir = home
+
+      , tiers =
+          { hourly  = { keep = 24 }
+          , daily   = { keep =  7 }
+          , weekly  = { keep =  4 }
+          , monthly = { keep = 12 }
+          }
+
+      , exclusions =
+          [
+          , "**/.stack-work"
+          , "**/dist-newstyle"
+
+          , "${home}/.boot/cache"
+          , "${home}/.cabal/bin"
+          , "${home}/.cabal/packages"
+          , "${home}/.cache"
+          , "${home}/.cargo/bin"
+          , "${home}/.cargo/registry"
+          , "${home}/.codestream/agent"
+          , "${home}/.conan/data"
+          , "${home}/.cpanm"
+          , "${home}/.cpanplus"
+          , "${home}/.ghcup"
+          , "${home}/.gradle"
+          , "${home}/.hoogle"
+          , "${home}/.ivy2"
+          , "${home}/.m2"
+          , "${home}/.npm"
+          , "${home}/.rustup"
+          , "${home}/.sbt"
+          , "${home}/.stack"
+
+          , "${home}/Library/Caches"
+
+          , "${home}/Library/Containers/com.apple.Safari/Data/Library/Caches"
+          , "${home}/Library/Containers/com.atlassian.jira.mac/Data/Library/Caches"
+          , "${home}/Library/Containers/com.docker.docker"
+          , "${home}/Library/Containers/com.tinyspeck.slackmacgap"
+
+          , "${home}/Library/Metadata/CoreSpotlight"
+
+          , "${home}/Library/Safari/*.db"
+          , "${home}/Library/Safari/*.db-*"
+          ]
+      }
+    ]
+}
diff --git a/hie.cabal.yaml b/hie.cabal.yaml
new file mode 100644
index 0000000..7a03436
--- /dev/null
+++ b/hie.cabal.yaml
@@ -0,0 +1,4 @@
+cradle:
+  cabal:
+    - path: "src"
+      component: "mulkup:exe:mulkup"
diff --git a/hie.stack.yaml b/hie.stack.yaml
new file mode 100644
index 0000000..786a980
--- /dev/null
+++ b/hie.stack.yaml
@@ -0,0 +1,4 @@
+cradle:
+  stack:
+    - path: "./src"
+      component: "mulkup:exe:mulkup"
diff --git a/hie.yaml b/hie.yaml
new file mode 120000
index 0000000..b99c078
--- /dev/null
+++ b/hie.yaml
@@ -0,0 +1 @@
+hie.stack.yaml
\ No newline at end of file
diff --git a/mulkup.cabal b/mulkup.cabal
new file mode 100644
index 0000000..e5f5976
--- /dev/null
+++ b/mulkup.cabal
@@ -0,0 +1,133 @@
+cabal-version:      3.0
+name:               mulkup
+version:            0.1.0.0
+license:            NONE
+copyright:          2021 Matthias Andreas Benkard
+maintainer:         code@mail.matthias.benkard.de
+author:             Matthias Andreas Benkard
+
+
+common shared-properties
+  default-language: Haskell2010
+  -- tested-with:
+  --     GHC == 9.0.1
+
+  build-depends:
+    , base                   ^>= 4.15.0
+    , relude                 ^>= 1.0.0.0
+    , aeson
+    , bytestring             ^>= 0.10
+    , co-log                 ^>= 0.4
+    , co-log-core            ^>= 0.2.1
+    , co-log-polysemy        ^>= 0.0.1
+    , dhall                  ^>= 1.39
+    , optparse-applicative    >= 0.15     && < 0.17
+    , optics                 ^>= 0.4
+    , polysemy                >= 1.3       && < 1.7
+    , recursion-schemes      ^>= 5.2
+    , time
+    , text                   ^>= 1.2
+    , turtle                 ^>= 1.5.20
+
+  default-extensions:
+      BangPatterns
+      BinaryLiterals
+      ConstraintKinds
+      DataKinds
+      DefaultSignatures
+      DeriveAnyClass
+      DeriveDataTypeable
+      DeriveFoldable
+      DeriveFunctor
+      DeriveGeneric
+      DeriveTraversable
+      DerivingStrategies
+      DoAndIfThenElse
+      EmptyDataDecls
+      EmptyDataDeriving
+      ExistentialQuantification
+      FlexibleContexts
+      FlexibleInstances
+      FunctionalDependencies
+      GADTSyntax
+      GADTs
+      GeneralizedNewtypeDeriving
+      InstanceSigs
+      KindSignatures
+      LambdaCase
+      MultiParamTypeClasses
+      MultiWayIf
+      NamedFieldPuns
+      NoImplicitPrelude
+      OverloadedStrings
+      OverloadedLabels
+      PartialTypeSignatures
+      PatternGuards
+      PolyKinds
+      RankNTypes
+      RecordWildCards
+      ScopedTypeVariables
+      StandaloneDeriving
+      TemplateHaskell
+      TupleSections
+      TypeApplications
+      TypeFamilies
+      TypeSynonymInstances
+      ViewPatterns
+
+  ghc-options:
+      -Wall
+      -Wcompat
+      -Widentities
+      -Wincomplete-record-updates
+      -Wincomplete-uni-patterns
+      -Wmissing-deriving-strategies
+      -Wpartial-fields
+      -Wredundant-constraints
+      -fprint-explicit-foralls
+      -fprint-unicode-syntax
+
+
+library
+  import:           shared-properties
+  default-language: Haskell2010
+
+  exposed-modules:
+      Mulkup.Bupstash
+      Mulkup.Config
+      Mulkup.Flags
+      Mulkup.Logging
+      Mulkup.Main
+      Mulkup.Prelude
+
+  hs-source-dirs:
+      src
+
+
+executable mulkup
+  import:           shared-properties
+  default-language: Haskell2010
+  main-is:          Main.hs
+
+  hs-source-dirs:
+      src/bin
+
+  build-depends:
+    , mulkup
+
+
+test-suite mulkup-test
+  type:                exitcode-stdio-1.0
+  default-language:    Haskell2010
+  hs-source-dirs:      test
+  main-is:             Main.hs
+
+  other-modules:
+      Mulkup.ConfigSpec
+
+  build-depends:
+    , base                   ^>= 4.15.0
+    , tasty                  ^>= 1.4
+    , tasty-hunit            ^>= 0.10
+    , tasty-smallcheck       ^>= 0.8
+    , mulkup
diff --git a/src/Mulkup/Bupstash.hs b/src/Mulkup/Bupstash.hs
new file mode 100644
index 0000000..8804593
--- /dev/null
+++ b/src/Mulkup/Bupstash.hs
@@ -0,0 +1,118 @@
+{-# LANGUAGE BlockArguments #-}
+{-# LANGUAGE DuplicateRecordFields #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE TypeOperators #-}
+{-# LANGUAGE UndecidableInstances #-}
+
+module Mulkup.Bupstash (BupItem(..), Bupstash (..), bupPut, bupGc, bupList, bupRemove, BupFilter (..), runBupstash, bupItemUTCTime) where
+
+import Mulkup.Config (MulkupConfig (..))
+import Mulkup.Prelude hiding (put)
+import Optics
+import Polysemy
+import Polysemy.Reader (Reader, asks)
+import Turtle hiding (err, x)
+import Data.Aeson (FromJSON, eitherDecode)
+import Polysemy.Error (Error, throw)
+import Data.Text (pack, unpack)
+import Mulkup.Logging
+import Colog.Polysemy (Log)
+import Colog (Message)
+import Data.Time
+import Data.Time.Clock.POSIX
+
+-- * API
+
+data BupItem = BupItem
+  { id :: Text,
+    unix_timestamp_millis :: Integer,
+    tags :: Map String String
+  }
+  deriving stock (Generic, Show)
+  deriving anyclass (FromJSON)
+
+data BupFilter = BupFilter
+  { labels :: [(Text, Text)],
+    minimumAge :: Maybe Text
+  }
+
+makeFieldLabelsNoPrefix ''BupFilter
+
+data Bupstash m a where
+  BupGc :: Bupstash m ()
+  BupPut :: Text -> [Text] -> [(Text, Text)] -> Bupstash m ()
+  BupList :: BupFilter -> Bupstash m [BupItem]
+  BupRemove :: [Text] -> Bupstash m ()
+
+makeSem ''Bupstash
+
+-- * Smart Accessors
+
+bupItemUTCTime :: BupItem -> UTCTime
+bupItemUTCTime item =
+  posixSecondsToUTCTime $
+    secondsToNominalDiffTime $
+      fromInteger (item ^. #unix_timestamp_millis) / 1000
+
+-- * Implementation
+
+-- | Runs a 'Bupstash' using the “bupstash” CLI command.
+runBupstash :: (Member (Error Text) r, Member (Log Message) r, Member (Embed IO) r, Member (Reader MulkupConfig) r) => Sem (Bupstash ': r) a -> Sem r a
+runBupstash = interpret \case
+  BupGc ->
+    procs "bupstash" ["gc"] empty
+
+  BupPut baseDir exclusions labels -> do
+    host <- getHost
+    procs "bupstash" (["put", "--xattrs"] ++ map exclusionArg exclusions ++ map labelArg labels ++ [labelArg ("host", host)] ++ [baseDir]) empty
+
+  BupList bupFilter -> do
+    host <- getHost
+    out <- strict $ inproc "bupstash" (["list", "--format=jsonl1"] ++ filterArgs host bupFilter) empty
+    let parsedItems = map parseItem (patchLines (lines out))
+    forM parsedItems \case
+      Left err -> do
+        let errtext = pack err
+        logError errtext
+        throw errtext
+      Right x ->
+        return x
+
+  BupRemove ids -> do
+    procs "bupstash" ["rm", "--ids-from-stdin"] (select (map unsafeTextToLine ids))
+
+  where
+    getHost :: Member (Reader MulkupConfig) r => Sem r Text
+    getHost =
+      asks @MulkupConfig (^. #host)
+
+-- | Fixes up the buggy two-line output that Buptash produces in
+-- jsonl1 output mode.
+--
+-- See: https://github.com/andrewchambers/bupstash/pull/241
+patchLines :: [Text] -> [Text]
+patchLines = concatMap patchLine
+  where
+    patchLine :: Text -> [Text]
+    patchLine line
+      | line == "}" =
+        []
+      | length (filter (== '}') $ unpack line) < length (filter (== '{') $ unpack line) =
+        [line <> "}"]
+      | otherwise =
+        [line]
+
+labelArg :: (Text, Text) -> Text
+labelArg (key, value) = key <> "=" <> value
+
+exclusionArg :: Text -> Text
+exclusionArg = ("--exclude=" <>)
+
+filterArgs :: Text -> BupFilter -> [Text]
+filterArgs host (BupFilter labels minimumAge) =
+  [labelArg ("host", host)] ++
+    concatMap (\label -> ["and", labelArg label]) labels ++
+    concatMap (\x -> ["and", "older-than", x]) minimumAge
+
+parseItem :: Text -> Either String BupItem
+parseItem = eitherDecode . encodeUtf8
diff --git a/src/Mulkup/Config.hs b/src/Mulkup/Config.hs
new file mode 100644
index 0000000..f3d9fee
--- /dev/null
+++ b/src/Mulkup/Config.hs
@@ -0,0 +1,44 @@
+{-# LANGUAGE UndecidableInstances #-}
+
+module Mulkup.Config (TierConfig (..), MulkupConfig (..), readConfig) where
+
+import Dhall
+import Mulkup.Prelude
+import Optics.TH
+
+--- TierConfig ---
+
+data TierConfig = TierConfig {keep :: Natural}
+  deriving stock (Generic, Show)
+  deriving anyclass (FromDhall)
+
+makeFieldLabelsNoPrefix ''TierConfig
+
+--- TierConfigs ---
+
+data TierConfigs = TierConfigs {hourly :: TierConfig, daily :: TierConfig, weekly :: TierConfig, monthly :: TierConfig}
+  deriving stock (Generic, Show)
+  deriving anyclass (FromDhall)
+
+makeFieldLabelsNoPrefix ''TierConfigs
+
+--- StashConfigs ---
+
+data StashConfig = StashConfig {name :: Text, baseDir :: Text, tiers :: TierConfigs, exclusions :: [Text]}
+  deriving stock (Generic, Show)
+  deriving anyclass (FromDhall)
+
+makeFieldLabelsNoPrefix ''StashConfig
+
+--- MulkupConfig ---
+
+data MulkupConfig = MulkupConfig {host :: Text, stashes :: [StashConfig]}
+  deriving stock (Generic, Show)
+  deriving anyclass (FromDhall)
+
+makeFieldLabelsNoPrefix ''MulkupConfig
+
+--- readConfig ---
+
+readConfig :: Text -> IO MulkupConfig
+readConfig = Dhall.input auto
diff --git a/src/Mulkup/Flags.hs b/src/Mulkup/Flags.hs
new file mode 100644
index 0000000..c5aa758
--- /dev/null
+++ b/src/Mulkup/Flags.hs
@@ -0,0 +1,23 @@
+{-# LANGUAGE TypeApplications #-}
+{-# LANGUAGE UndecidableInstances #-}
+
+module Mulkup.Flags (Flags (..), flagParser) where
+
+import Mulkup.Prelude
+import Optics.TH
+import Options.Applicative
+  ( Parser,
+    help,
+    long,
+    short,
+    switch,
+  )
+
+data Flags = Flags
+  {verbose :: Bool}
+
+makeFieldLabelsNoPrefix ''Flags
+
+flagParser :: Parser Flags
+flagParser =
+  Flags <$> switch (long "verbose" <> short 'v' <> help "Log verbosely.")
diff --git a/src/Mulkup/Logging.hs b/src/Mulkup/Logging.hs
new file mode 100644
index 0000000..cc6a1cc
--- /dev/null
+++ b/src/Mulkup/Logging.hs
@@ -0,0 +1,42 @@
+{-# LANGUAGE RecordWildCards #-}
+
+module Mulkup.Logging where
+
+import Colog (Message, Msg (..), Severity (..))
+import Colog.Polysemy.Effect (Log, log)
+import Data.Text (pack)
+import Mulkup.Prelude
+import Polysemy (Member, Sem)
+
+msg :: Severity -> Text -> Message
+msg msgSeverity msgText = withFrozenCallStack (Msg {msgStack = callStack, ..})
+
+debugMsg :: Text -> Message
+debugMsg = withFrozenCallStack (msg Debug)
+
+infoMsg :: Text -> Message
+infoMsg = withFrozenCallStack (msg Info)
+
+warningMsg :: Text -> Message
+warningMsg = withFrozenCallStack (msg Warning)
+
+errorMsg :: Text -> Message
+errorMsg = withFrozenCallStack (msg Error)
+
+exceptionMsg :: Exception e => e -> Message
+exceptionMsg = withFrozenCallStack (msg Error . pack . displayException)
+
+logDebug :: Member (Log Message) r => Text -> Sem r ()
+logDebug = withFrozenCallStack (log . debugMsg)
+
+logInfo :: Member (Log Message) r => Text -> Sem r ()
+logInfo = withFrozenCallStack (log . infoMsg)
+
+logWarning :: Member (Log Message) r => Text -> Sem r ()
+logWarning = withFrozenCallStack (log . warningMsg)
+
+logError :: Member (Log Message) r => Text -> Sem r ()
+logError = withFrozenCallStack (log . errorMsg)
+
+logException :: Exception e => Member (Log Message) r => e -> Sem r ()
+logException = withFrozenCallStack (log . exceptionMsg)
diff --git a/src/Mulkup/Main.hs b/src/Mulkup/Main.hs
new file mode 100644
index 0000000..3b080c4
--- /dev/null
+++ b/src/Mulkup/Main.hs
@@ -0,0 +1,118 @@
+{-# LANGUAGE PatternSynonyms #-}
+{-# LANGUAGE TypeApplications #-}
+{-# LANGUAGE UndecidableInstances #-}
+
+module Mulkup.Main where
+
+import Colog (Message, richMessageAction, simpleMessageAction)
+import Colog.Polysemy.Effect (Log, runLogAction)
+import qualified Data.List.NonEmpty as NonEmpty
+import qualified Data.Set as Set
+import Data.Time
+import Mulkup.Bupstash
+import Mulkup.Config
+import Mulkup.Flags
+import Mulkup.Logging
+import Mulkup.Prelude
+import Optics
+import Options.Applicative
+  ( execParser,
+    fullDesc,
+    helper,
+    info,
+  )
+import Polysemy (Member, Sem)
+import Polysemy.Error
+import Polysemy.Final
+import Polysemy.Reader (Reader, asks, runReader)
+
+main :: IO ()
+main = do
+  flags <- execParser $ info (flagParser <**> helper) fullDesc
+  let messageAction =
+        if verbose flags
+          then richMessageAction
+          else simpleMessageAction
+
+  config <- readConfig "./config.dhall"
+
+  result <-
+    main'
+      & runBupstash
+      & runLogAction @IO messageAction
+      & runReader (config :: MulkupConfig)
+      & errorToIOFinal @Text
+      & embedToFinal @IO
+      & runFinal @IO
+
+  case result of
+    Left err -> do
+      error err
+    Right () ->
+      return ()
+
+main' :: (Member (Log Message) r, Member (Reader MulkupConfig) r, Member Bupstash r) => Sem r ()
+main' = do
+  stashes <- asks @MulkupConfig (^. #stashes)
+  forM_ stashes $ \stash -> do
+    let labels = [("name", stash ^. #name)]
+
+    currentItems <- bupList (BupFilter labels Nothing)
+
+    let tiers =
+          [ (utctHour . bupItemUTCTime, #hourly),
+            (utctJulianDay . bupItemUTCTime, #daily),
+            (utctWeek . bupItemUTCTime, #weekly),
+            (utctMonth . bupItemUTCTime, #monthly)
+          ]
+
+    let keepIds =
+          Set.unions $
+            map
+              ( \(discriminator, cfgLens) ->
+                  let tierCfg = stash ^. #tiers ^. cfgLens
+                   in tierKeepIds discriminator (tierCfg ^. #keep) currentItems
+              )
+              tiers
+
+    let currentIds = Set.fromList (map (^. #id) currentItems)
+    let rmIds = Set.difference currentIds keepIds
+
+    logInfo (show labels <> " Keeping: " <> show (Set.toList keepIds))
+    logInfo (show labels <> " Removing: " <> show (Set.toList rmIds))
+    bupRemove (Set.toList rmIds)
+
+    logInfo (show labels <> " Creating backup.")
+    bupPut
+      (stash ^. #baseDir)
+      (stash ^. #exclusions)
+      labels
+
+tierKeepIds :: (BupItem -> Integer) -> Natural -> [BupItem] -> Set Text
+tierKeepIds discriminator keep items =
+  fromList $
+    take (fromIntegral keep) $
+      map (^. #id) $
+        reverse $
+          sortWith bupItemUTCTime $
+            map (head . NonEmpty.sortWith bupItemUTCTime) $
+              elems $
+                (groupBy discriminator items :: HashMap Integer (NonEmpty BupItem))
+
+utctHour :: UTCTime -> Integer
+utctHour (UTCTime (ModifiedJulianDay julianDay) tdiff) = (julianDay * 24) + (diffTimeToPicoseconds tdiff `div` (10 ^ (12 :: Integer)) `div` 3600)
+
+utctJulianDay :: UTCTime -> Integer
+utctJulianDay (UTCTime (ModifiedJulianDay julianDay) _) = julianDay
+
+utctWeek :: UTCTime -> Integer
+utctWeek (UTCTime day _) =
+  julianDay + fromIntegral (dayOfWeekDiff (dayOfWeek day) Sunday)
+  where
+    (ModifiedJulianDay julianDay) = day
+    dayOfWeekDiff a b = mod (fromEnum a - fromEnum b) 7
+
+utctMonth :: UTCTime -> Integer
+utctMonth (UTCTime day _) = fromIntegral m
+  where
+    (_, m, _) = toGregorian day
diff --git a/src/Mulkup/Prelude.hs b/src/Mulkup/Prelude.hs
new file mode 100644
index 0000000..632f059
--- /dev/null
+++ b/src/Mulkup/Prelude.hs
@@ -0,0 +1,10 @@
+module Mulkup.Prelude
+  ( module Relude,
+    module Relude.Extra.Group,
+    module Relude.Extra.Map,
+  )
+where
+
+import Relude hiding (Reader, ask, asks, local, runReader)
+import Relude.Extra.Group
+import Relude.Extra.Map
diff --git a/src/bin/Main.hs b/src/bin/Main.hs
new file mode 100644
index 0000000..04507b8
--- /dev/null
+++ b/src/bin/Main.hs
@@ -0,0 +1,7 @@
+module Main where
+
+import qualified Mulkup.Main
+import Mulkup.Prelude
+
+main :: IO ()
+main = Mulkup.Main.main
diff --git a/stack.yaml b/stack.yaml
new file mode 100644
index 0000000..9695a32
--- /dev/null
+++ b/stack.yaml
@@ -0,0 +1,21 @@
+resolver: nightly-2021-08-20
+
+packages:
+  - .
+
+ghc-options:
+  "$everything": -haddock
+
+system-ghc: true
+compiler-check: newer-minor
+
+require-stack-version: ">=2.3"
+
+allow-newer: true
+
+extra-deps:
+  - co-log-0.4.0.1@sha256:3d4c17f37693c80d1aa2c41669bc3438fac3e89dc5f479e57d79bc3ddc4dfcc5,5087
+  - co-log-core-0.2.1.1@sha256:1209cb589cf431c640f79d63b38b6cc8912a9f8397502b8c13e0959b13212bd6,3574
+  - co-log-polysemy-0.0.1.2@sha256:a6ea6103f5ca806dc16bcdbb666ee2ebafdbbf36a9626f33103105a41a55d561,3645
+  - chronos-1.1.2@sha256:b38c48edd4f63069bbb6646f0f39d9a5ae21567ef3e031ddee514f7147cfd0b0,3886
+  - typerep-map-0.3.3.0@sha256:20510efab770158414ac7828d1eca063d795bcc724ac634dd13bc7a50b476f42,4681
diff --git a/stack.yaml.lock b/stack.yaml.lock
new file mode 100644
index 0000000..b8d2bc2
--- /dev/null
+++ b/stack.yaml.lock
@@ -0,0 +1,47 @@
+# This file was autogenerated by Stack.
+# You should not edit this file by hand.
+# For more information, please see the documentation at:
+#   https://docs.haskellstack.org/en/stable/lock_files
+
+packages:
+- completed:
+    hackage: co-log-0.4.0.1@sha256:3d4c17f37693c80d1aa2c41669bc3438fac3e89dc5f479e57d79bc3ddc4dfcc5,5087
+    pantry-tree:
+      size: 1126
+      sha256: e73165ff8f744709428e2e87984c9d60ca1cec43d8455c413181c7c466e7497c
+  original:
+    hackage: co-log-0.4.0.1@sha256:3d4c17f37693c80d1aa2c41669bc3438fac3e89dc5f479e57d79bc3ddc4dfcc5,5087
+- completed:
+    hackage: co-log-core-0.2.1.1@sha256:1209cb589cf431c640f79d63b38b6cc8912a9f8397502b8c13e0959b13212bd6,3574
+    pantry-tree:
+      size: 584
+      sha256: f893bd4a8cc16ca1fdd1f6dedefd2239d261c0f926eafb444d01364d5898a0e2
+  original:
+    hackage: co-log-core-0.2.1.1@sha256:1209cb589cf431c640f79d63b38b6cc8912a9f8397502b8c13e0959b13212bd6,3574
+- completed:
+    hackage: co-log-polysemy-0.0.1.2@sha256:a6ea6103f5ca806dc16bcdbb666ee2ebafdbbf36a9626f33103105a41a55d561,3645
+    pantry-tree:
+      size: 400
+      sha256: 56ddcbaf126c9e557d7e1336c6d1a9044567676dcdbc24fab34907d9a5725f73
+  original:
+    hackage: co-log-polysemy-0.0.1.2@sha256:a6ea6103f5ca806dc16bcdbb666ee2ebafdbbf36a9626f33103105a41a55d561,3645
+- completed:
+    hackage: chronos-1.1.2@sha256:b38c48edd4f63069bbb6646f0f39d9a5ae21567ef3e031ddee514f7147cfd0b0,3886
+    pantry-tree:
+      size: 637
+      sha256: 9e25587117ee3aa7d9de26085cfb1b464275b45a33952985d44182892c6ef3e5
+  original:
+    hackage: chronos-1.1.2@sha256:b38c48edd4f63069bbb6646f0f39d9a5ae21567ef3e031ddee514f7147cfd0b0,3886
+- completed:
+    hackage: typerep-map-0.3.3.0@sha256:20510efab770158414ac7828d1eca063d795bcc724ac634dd13bc7a50b476f42,4681
+    pantry-tree:
+      size: 1487
+      sha256: 30d61ac00807c3229b7b6ee274853f0ec1e272503a1b210ce10aad3db19f443a
+  original:
+    hackage: typerep-map-0.3.3.0@sha256:20510efab770158414ac7828d1eca063d795bcc724ac634dd13bc7a50b476f42,4681
+snapshots:
+- completed:
+    size: 576543
+    url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/nightly/2021/8/20.yaml
+    sha256: 17a600058a2092ff77b40d563b9dc1d7b98f0f7b7b75d54dc7d49dc17b1fb26a
+  original: nightly-2021-08-20
diff --git a/test/Main.hs b/test/Main.hs
new file mode 100644
index 0000000..2dd6130
--- /dev/null
+++ b/test/Main.hs
@@ -0,0 +1,12 @@
+import qualified Mulkup.ConfigSpec
+import Mulkup.Prelude
+import Test.Tasty
+
+main :: IO ()
+main = defaultMain tests
+
+tests :: TestTree
+tests =
+  testGroup
+    "Tests"
+    [Mulkup.ConfigSpec.cases]
diff --git a/test/Mulkup/ConfigSpec.hs b/test/Mulkup/ConfigSpec.hs
new file mode 100644
index 0000000..142d130
--- /dev/null
+++ b/test/Mulkup/ConfigSpec.hs
@@ -0,0 +1,48 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Mulkup.ConfigSpec (cases) where
+
+import Mulkup.Config
+import Mulkup.Prelude
+import Test.Tasty
+import Test.Tasty.HUnit
+
+cases :: TestTree
+cases =
+  testGroup
+    "ConfigSpec"
+    [unit_simpleConfig]
+
+unit_simpleConfig :: TestTree
+unit_simpleConfig = testCase "unit_simpleConfig" $ do
+  void $ readConfig exampleConfigText
+  where
+    exampleConfigText =
+      "\
+      \{ host = \"atmon\" \
+      \ \
+      \, stashes = \
+      \    [ { name = \"mulk\" \
+      \       \
+      \      , baseDir = \"/Users/mulk\" \
+      \       \
+      \      , tiers = \
+      \          { hourly  = { keep = 48 } \
+      \          , daily   = { keep =  4 } \
+      \          , weekly  = { keep =  4 } \
+      \          , monthly = { keep = 12 } \
+      \          } \
+      \       \
+      \      , exclusions = \
+      \          [ \
+      \          , \"**/.stack-work\" \
+      \          , \"**/dist-newstyle\" \
+      \         \
+      \          , \"~/.boot/cache\" \
+      \          , \"~/.cabal/bin\" \
+      \          , \"~/.cabal/packages\" \
+      \          , \"~/.cache\" \
+      \          ] \
+      \      } \
+      \    ] \
+      \}"