diff --git a/lib/LiBro/Base.hs b/lib/LiBro/Base.hs index 8d17b48..8406ee2 100644 --- a/lib/LiBro/Base.hs +++ b/lib/LiBro/Base.hs @@ -2,19 +2,67 @@ module LiBro.Base where import LiBro.Config +import LiBro.Log +import LiBro.Util as Util +import Data.Csv +import Data.Time.Clock +import System.Directory as Dir import Control.Monad.Reader +import Control.Monad.Writer +import Control.Concurrent as Conc --- | Internal monad for 'Config'ured libro effects. -newtype LiBro a = LiBro - { unLiBro :: ReaderT Config IO a - } deriving ( Functor - , Applicative - , Monad +-- | Type class for 'Config'ured libro effects. +class (Monad m, MonadFail m) => MonadLiBro m where + + readConfig :: (Config -> a) -> m a + + logInfo, logWarning, logError, logFatal :: LogSource -> LogMessage -> m () + logInfo = addLog INFO + logWarning = addLog WARNING + logError = addLog ERROR + logFatal = addLog FATAL + addLog :: LogLevel -> LogSource -> LogMessage -> m () + failError, failFatal :: LogSource -> LogMessage -> m a + failError s m = logError s m >> fail m + failFatal s m = logFatal s m >> fail m + + doesFileExist :: FilePath -> m Bool + + loadFromXlsx :: FromNamedRecord a => FilePath -> m (Either String [a]) + storeAsXlsx :: (DefaultOrdered a, ToNamedRecord a) => FilePath -> [a] -> m () + + readMVar :: MVar a -> m a + putMVar :: MVar a -> a -> m () + takeMVar :: MVar a -> m a + isEmptyMVar :: MVar a -> m Bool + +-- | The default configured 'LiBro' effect using 'IO'. +newtype LiBroIO a = LiBro + { unLiBro :: ReaderT Config (WriterT [Log] IO) a + } deriving ( Functor, Applicative, Monad, MonadFail , MonadReader Config - , MonadFail + , MonadWriter [Log] , MonadIO ) +instance MonadLiBro LiBroIO where + readConfig = asks + addLog l s m = do {now <- liftIO getCurrentTime; tell [Log now l s m]} + doesFileExist fp = liftIO $ Dir.doesFileExist fp + loadFromXlsx fp = liftIO $ Util.loadFromXlsx fp + storeAsXlsx fp d = liftIO $ Util.storeAsXlsx fp d + readMVar mv = liftIO $ Conc.readMVar mv + putMVar mv d = liftIO $ Conc.putMVar mv d + takeMVar mv = liftIO $ Conc.takeMVar mv + isEmptyMVar mv = liftIO $ Conc.isEmptyMVar mv + -- | Run a 'Config'ured libro effect in 'IO'. -runLiBro :: Config -> LiBro a -> IO a -runLiBro config = flip runReaderT config . unLiBro +runLiBroIO :: Config -> LiBroIO a -> IO a +runLiBroIO config action = do + (result, logs) <- runLiBroIOLogs config action + mapM_ print logs + return result + +-- | Run a 'Config'ured libro effect in 'IO' with logs attached. +runLiBroIOLogs :: Config -> LiBroIO a -> IO (a, [Log]) +runLiBroIOLogs config = runWriterT . flip runReaderT config . unLiBro diff --git a/lib/LiBro/Config.hs b/lib/LiBro/Config.hs index 1e6a3c5..82e5808 100644 --- a/lib/LiBro/Config.hs +++ b/lib/LiBro/Config.hs @@ -39,8 +39,8 @@ parseConfig = flip parseIniFile $ do -- | Reads a 'Config' value from @config.ini@. -- Prints parsing error messages to @STDERR@ when failing. -readConfig :: IO (Maybe Config) -readConfig = readConfigFrom "config.ini" +readDefaultConfig :: IO (Maybe Config) +readDefaultConfig = readConfigFrom "config.ini" -- | Reads a 'Config' value from the given file path. -- Prints parsing error messages to @STDERR@ when failing. diff --git a/lib/LiBro/Control.hs b/lib/LiBro/Control.hs index ef89a53..4031d30 100644 --- a/lib/LiBro/Control.hs +++ b/lib/LiBro/Control.hs @@ -2,11 +2,9 @@ module LiBro.Control where import LiBro.Base -import LiBro.Config import LiBro.Data import LiBro.Data.Storage -import Control.Concurrent -import Control.Monad.Reader +import Control.Concurrent (MVar) -- | Represents a blocking action because the system is loading -- or saving data. @@ -17,23 +15,23 @@ data Blocking -- | Initially load data and put it into the shared state. -- Expects the given 'MVar' to be empty. -initData :: MVar Blocking -> MVar LiBroData -> LiBro () +initData :: MonadLiBro m => MVar Blocking -> MVar LiBroData -> m () initData blocking libroData = do - liftIO $ putMVar blocking Reading + putMVar blocking Reading ld <- loadData - _ <- liftIO $ putMVar libroData ld - _ <- liftIO $ takeMVar blocking + _ <- putMVar libroData ld + _ <- takeMVar blocking return () -- | Try to store shared state data. Expects the given blocking 'MVar' -- to be empty. Iff not, returns 'False'. -saveData :: MVar Blocking -> MVar LiBroData -> LiBro Bool +saveData :: MonadLiBro m => MVar Blocking -> MVar LiBroData -> m Bool saveData blocking libroData = do - isBlocked <- not <$> liftIO (isEmptyMVar blocking) + isBlocked <- not <$> isEmptyMVar blocking if isBlocked then return False else do - liftIO $ putMVar blocking Writing - storeData =<< liftIO (readMVar libroData) - _ <- liftIO $ takeMVar blocking + putMVar blocking Writing + storeData =<< readMVar libroData + _ <- takeMVar blocking return True diff --git a/lib/LiBro/Data/Storage.hs b/lib/LiBro/Data/Storage.hs index 250d37a..229539a 100644 --- a/lib/LiBro/Data/Storage.hs +++ b/lib/LiBro/Data/Storage.hs @@ -22,17 +22,15 @@ import LiBro.Base import LiBro.Config import LiBro.Data import LiBro.Data.SafeText -import LiBro.Util +import qualified LiBro.Util as Util import Data.Function import Data.Map ((!)) import qualified Data.Map as M import Data.Tree import Data.Csv import qualified Data.ByteString.Char8 as B -import Control.Monad.Reader import GHC.Generics import System.FilePath -import System.Directory -- | A thin wrapper around lists of 'Int' with a simple -- (space-separated) 'String' representation. @@ -89,7 +87,7 @@ taskRecordsToTasks :: Persons -> [TaskRecord] -> Tasks taskRecordsToTasks pmap trs = let tmap = M.fromList $ map ((,) =<< trid) trs parentList = map ((,) <$> trid <*> parentTid) trs - idForest = readForest parentList + idForest = Util.readForest parentList in map (fmap $ fromRecord . (tmap !)) idForest where fromRecord tr = Task { tid = trid tr @@ -99,54 +97,58 @@ taskRecordsToTasks pmap trs = } -- | Store 'Person's at the configured storage space -storePersons :: Persons -> LiBro () +storePersons :: MonadLiBro m => Persons -> m () storePersons pmap = do - sconf <- asks storage + sconf <- readConfig storage let fp = directory sconf personFile sconf - liftIO $ storeAsXlsx fp $ M.elems pmap + storeAsXlsx fp $ M.elems pmap -- | Load a list of 'Person's from the configured storage space. -- Returns empty data if no input file was found. -loadPersons :: LiBro Persons +loadPersons :: MonadLiBro m => m Persons loadPersons = do - sconf <- asks storage + sconf <- readConfig storage let fp = directory sconf personFile sconf - exists <- liftIO $ doesFileExist fp - if not exists then return M.empty + exists <- doesFileExist fp + if not exists then fail $ fp ++ " does not exist" else do - Right prs <- liftIO $ loadFromXlsx fp - return $ personMap prs + mprs <- loadFromXlsx fp + case mprs of + Right prs -> return $ personMap prs + Left e -> failFatal "XLSX persons loader" e -- | Store 'Tasks' at the configured storage space. -storeTasks :: Tasks -> LiBro () +storeTasks :: MonadLiBro m => Tasks -> m () storeTasks ts = do - sconf <- asks storage + sconf <- readConfig storage let fp = directory sconf tasksFile sconf - liftIO $ storeAsXlsx fp $ tasksToTaskRecords ts + storeAsXlsx fp $ tasksToTaskRecords ts -- | Load 'Tasks' from the configured storage space. -- Needs an additional 'Data.Map.Map' to find 'Person's for given -- person ids ('Int'). Returns empty data if no input file was found. -loadTasks :: Persons -> LiBro Tasks +loadTasks :: MonadLiBro m => Persons -> m Tasks loadTasks pmap = do - sconf <- asks storage + sconf <- readConfig storage let fp = directory sconf tasksFile sconf - exists <- liftIO $ doesFileExist fp - if not exists then return [] + exists <- doesFileExist fp + if not exists then fail $ fp ++ " does not exist" else do - Right records <- liftIO $ loadFromXlsx fp - return $ taskRecordsToTasks pmap records + mrecords <- loadFromXlsx fp + case mrecords of + Right records -> return $ taskRecordsToTasks pmap records + Left e -> failFatal "XLSX tasks loader" e -- | Store a complete dataset at the configured file system -- locations. -storeData :: LiBroData -> LiBro () +storeData :: MonadLiBro m => LiBroData -> m () storeData ld = do storePersons $ persons ld storeTasks $ tasks ld -- | Load a complete dataset from the configured file system -- locations. Returns empty data if no input files were found. -loadData :: LiBro LiBroData +loadData :: MonadLiBro m => m LiBroData loadData = do pmap <- loadPersons ts <- loadTasks pmap diff --git a/lib/LiBro/Log.hs b/lib/LiBro/Log.hs new file mode 100644 index 0000000..4d942c8 --- /dev/null +++ b/lib/LiBro/Log.hs @@ -0,0 +1,18 @@ +module LiBro.Log where + +import Text.Printf +import Data.Time.Clock + +data LogLevel = INFO | WARNING | ERROR | FATAL + deriving (Eq, Ord, Enum, Bounded, Show) +type LogSource = String +type LogMessage = String +data Log = Log + { time :: UTCTime + , level :: LogLevel + , source :: LogSource + , message :: LogMessage + } + +instance Show Log where + show (Log t l s m) = printf "%s [%s] (%s): %s" (show l) (show t) s m diff --git a/libro-backend.cabal b/libro-backend.cabal index 480480b..47d5265 100644 --- a/libro-backend.cabal +++ b/libro-backend.cabal @@ -33,10 +33,12 @@ common consumer library import: basic default-extensions: OverloadedStrings + , FlexibleContexts , GeneralizedNewtypeDeriving , DeriveGeneric exposed-modules: LiBro.Base , LiBro.Config + , LiBro.Log , LiBro.Data , LiBro.Data.Storage , LiBro.Data.SafeText @@ -57,6 +59,7 @@ library , QuickCheck , temporary , text + , time , unordered-containers , vector hs-source-dirs: lib @@ -76,6 +79,7 @@ test-suite libro-backend-test other-modules: LiBro.TestUtil , LiBro.TestUtilSpec , LiBro.ConfigSpec + , LiBro.LogSpec , LiBro.DataSpec , LiBro.Data.StorageSpec , LiBro.Data.SafeTextSpec @@ -99,5 +103,6 @@ test-suite libro-backend-test , silently , temporary , text + , time , transformers , vector diff --git a/test/LiBro/ControlSpec.hs b/test/LiBro/ControlSpec.hs index 489ccc3..ad87047 100644 --- a/test/LiBro/ControlSpec.hs +++ b/test/LiBro/ControlSpec.hs @@ -9,7 +9,7 @@ import LiBro.Data.Storage import LiBro.Control import Data.Default import Data.Tree -import Control.Concurrent +import qualified Control.Concurrent as Conc import System.IO.Temp spec :: Spec @@ -22,16 +22,16 @@ dataInitialization = describe "Blocking data loading" $ do context "With simple data files" $ do let config = def { storage = def { directory = "test/storage-files/data" }} - expectedData <- runIO $ runLiBro config loadData - blocking <- runIO $ newEmptyMVar - libroData <- runIO $ newEmptyMVar + expectedData <- runIO $ runLiBroIO config loadData + blocking <- runIO $ Conc.newEmptyMVar + libroData <- runIO $ Conc.newEmptyMVar (beb, bed, aeb, aned, ld) <- runIO $ do - beforeEmptyBlocking <- isEmptyMVar blocking - beforeEmptyData <- isEmptyMVar libroData - runLiBro config $ initData blocking libroData - afterEmptyBlocking <- isEmptyMVar blocking - afterNonEmptyData <- isEmptyMVar libroData - loadedData <- readMVar libroData + beforeEmptyBlocking <- Conc.isEmptyMVar blocking + beforeEmptyData <- Conc.isEmptyMVar libroData + runLiBroIO config $ initData blocking libroData + afterEmptyBlocking <- Conc.isEmptyMVar blocking + afterNonEmptyData <- Conc.isEmptyMVar libroData + loadedData <- Conc.readMVar libroData return ( beforeEmptyBlocking , beforeEmptyData @@ -55,24 +55,24 @@ dataStorage = describe "Storing complete LiBro data" $ do ldata = LBS (personMap [ldPerson]) [Node ldTask []] context "Manual saving while blocked" $ do - blocking <- runIO $ newMVar Reading - libroData <- runIO $ newMVar ldata + blocking <- runIO $ Conc.newMVar Reading + libroData <- runIO $ Conc.newMVar ldata rv <- runIO $ withSystemTempDirectory "storage" $ \tdir -> do let config = def { storage = def { directory = tdir }} - runLiBro config $ saveData blocking libroData + runLiBroIO config $ saveData blocking libroData it "Saving returns False" $ rv `shouldBe` False context "Manual saving of simple data" $ do - blocking <- runIO $ newEmptyMVar - libroData <- runIO $ newMVar ldata + blocking <- runIO $ Conc.newEmptyMVar + libroData <- runIO $ Conc.newMVar ldata testData <- runIO $ withSystemTempDirectory "storage" $ \tdir -> do let config = def { storage = def { directory = tdir }} - beforeEmptyBlocking <- isEmptyMVar blocking - beforeLibroData <- readMVar libroData - returnValue <- runLiBro config $ saveData blocking libroData - afterEmptyBlocking <- isEmptyMVar blocking - afterLibroData <- readMVar libroData - storedData <- runLiBro config loadData + beforeEmptyBlocking <- Conc.isEmptyMVar blocking + beforeLibroData <- Conc.readMVar libroData + returnValue <- runLiBroIO config $ saveData blocking libroData + afterEmptyBlocking <- Conc.isEmptyMVar blocking + afterLibroData <- Conc.readMVar libroData + storedData <- runLiBroIO config loadData return ( beforeEmptyBlocking , beforeLibroData diff --git a/test/LiBro/Data/StorageSpec.hs b/test/LiBro/Data/StorageSpec.hs index 7f1a63f..b07df3e 100644 --- a/test/LiBro/Data/StorageSpec.hs +++ b/test/LiBro/Data/StorageSpec.hs @@ -133,29 +133,28 @@ personStorage :: Spec personStorage = describe "XLSX storage of Person data" $ do describe "Loading without a file" $ do - result <- runIO $ withSystemTempDirectory "person-storage" $ \tdir -> do - let config = def { storage = def { directory = tdir }} - runLiBro config loadPersons - it "Empty Person map" $ - result `shouldBe` M.empty + it "Correct error thrown" $ + emptyPersonLoading `shouldThrow` anyException modifyMaxSuccess (const 20) $ prop "Load . store = id" $ forAll genPersons $ \pmap -> ioProperty $ do withSystemTempDirectory "person-storage" $ \tdir -> do let config = def { storage = def { directory = tdir }} - loadedPersons <- runLiBro config $ storePersons pmap >> loadPersons + loadedPersons <- runLiBroIO config $ storePersons pmap >> loadPersons return $ loadedPersons === pmap + where emptyPersonLoading = do + withSystemTempDirectory "person-storage" $ \tdir -> do + let config = def { storage = def { directory = tdir }} + runLiBroIO config loadPersons + taskStorage :: Spec taskStorage = describe "XLSX storage of Task data" $ do describe "Loading without a file" $ do - result <- runIO $ withSystemTempDirectory "task-storage" $ \tdir -> do - let config = def { storage = def { directory = tdir }} - runLiBro config $ loadTasks M.empty - it "Empty task list" $ - result `shouldBe` [] + it "Correct error thrown" $ + emptyTaskLoading `shouldThrow` anyException let pmap = personMap [ Person 17 "Nina Schreubenmyrthe" "foo@bar" @@ -172,17 +171,22 @@ taskStorage = describe "XLSX storage of Task data" $ do describe "Storing empty data" $ do loadedTasks <- runIO $ withSystemTempDirectory "task-storage" $ \tdir -> do let config = def { storage = def { directory = tdir }} - runLiBro config $ storeTasks [] >> loadTasks pmap + runLiBroIO config $ storeTasks [] >> loadTasks pmap it "Got empty task forest" $ loadedTasks `shouldBe` [] describe "Storing some task data" $ do loadedTasks <- runIO $ withSystemTempDirectory "task-storage" $ \tdir -> do let config = def { storage = def { directory = tdir }} - runLiBro config $ storeTasks ts >> loadTasks pmap + runLiBroIO config $ storeTasks ts >> loadTasks pmap it "Got the right task forest" $ loadedTasks `shouldBe` ts + where emptyTaskLoading = do + withSystemTempDirectory "task-storage" $ \tdir -> do + let config = def { storage = def { directory = tdir }} + runLiBroIO config $ loadTasks M.empty + dataStorage :: Spec dataStorage = describe "Complete dataset" $ do @@ -200,7 +204,7 @@ dataStorage = describe "Complete dataset" $ do ] ] let conf = def { storage = def { directory = "test/storage-files/data" }} - (LBS loadedPersons loadedTasks) <- runIO $ runLiBro conf loadData + (LBS loadedPersons loadedTasks) <- runIO $ runLiBroIO conf loadData it "Load correct persons" $ loadedPersons `shouldBe` personMap pmap it "Load correct task forest" $ @@ -213,5 +217,5 @@ dataStorage = describe "Complete dataset" $ do ioProperty $ do withSystemTempDirectory "storage" $ \tdir -> do let config = def { storage = def { directory = tdir }} - loadedData <- runLiBro config $ storeData d >> loadData + loadedData <- runLiBroIO config $ storeData d >> loadData return $ loadedData `shouldBe` d diff --git a/test/LiBro/LogSpec.hs b/test/LiBro/LogSpec.hs new file mode 100644 index 0000000..c4ce8a4 --- /dev/null +++ b/test/LiBro/LogSpec.hs @@ -0,0 +1,57 @@ +module LiBro.LogSpec where + +import Test.Hspec +import Test.QuickCheck +import Test.Hspec.QuickCheck + +import LiBro.Base +import LiBro.Log +import Data.Default +import Data.Time.Clock +import Data.Tuple.Extra + +spec :: Spec +spec = describe "Logging" $ do + format + collection + +format :: Spec +format = describe "Log format" $ do + + describe "Show instance" $ do + now <- runIO getCurrentTime + it "Correct log stringification" $ + show (Log now WARNING "foo" "bar") + `shouldBe` "WARNING [" ++ show now ++ "] (foo): bar" + + describe "Within LiBroIO monad" $ do + now <- runIO getCurrentTime + (_, logs) <- runIO $ runLiBroIOLogs def $ addLog WARNING "foo" "bar" + it "Exactly one log" $ length logs == 1 + let l = head logs + it "Correct log level" $ level l `shouldBe` WARNING + it "Correct log source" $ source l `shouldBe` "foo" + it "Correct log message" $ message l `shouldBe` "bar" + it "Logged time is close enough" $ + (time l `diffUTCTime` now) `shouldSatisfy` (< 0.42) + +genLogData :: Gen (LogLevel, LogSource, LogMessage) +genLogData = (,,) <$> chooseEnum (minBound, maxBound) + <*> arbitrary + <*> arbitrary + +collection :: Spec +collection = describe "Collection of logs" $ do + + prop "Correct list of logs" $ + forAll (listOf genLogData) $ \lds -> ioProperty $ do + (_, logs) <- runLiBroIOLogs def $ uncurry3 addLog `mapM_` lds + let logTuples = map ((,,) <$> level <*> source <*> message) logs + logTuples `shouldBe` lds + + prop "Ordered by time" $ + forAll (listOf genLogData) $ \lds -> ioProperty $ do + (_, logs) <- runLiBroIOLogs def $ uncurry3 addLog `mapM_` lds + time <$> logs `shouldSatisfy` isSorted + + where isSorted = and . (zipWith (<=) <*> tail) diff --git a/test/run-all-tests.hs b/test/run-all-tests.hs index d5459a5..8b34c81 100644 --- a/test/run-all-tests.hs +++ b/test/run-all-tests.hs @@ -3,6 +3,7 @@ module Main where import Test.Hspec import LiBro.Util +import qualified LiBro.LogSpec as Log import qualified LiBro.DataSpec as Data import qualified LiBro.Data.SafeTextSpec as Data.SafeText import qualified LiBro.Data.StorageSpec as Data.Storage @@ -24,6 +25,7 @@ withLibreOffice runTests = do main :: IO () main = hspec $ aroundAll_ withLibreOffice $ do + Log.spec Data.spec Data.SafeText.spec Data.Storage.spec