Initial checkin.

Change-Id: Ib0f503f39cedb6fcc11f80a3b309e4cbb7ed438f
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