atelier-core-0.1.0.0: src/Atelier/Effects/FileSystem.hs
-- | An effect for filesystem operations: reading files, listing and creating
-- directories, and querying paths.
--
-- 'runFileSystemIO' performs real filesystem IO; 'runFileSystemNoOp' returns
-- inert results; and 'runFileSystemState' simulates a filesystem backed by an
-- in-memory 'Map' for tests. 'followFile' tails a file as it grows.
module Atelier.Effects.FileSystem
( FileSystem (..)
, readFileBs
, readFileLbsFrom
, readFileLbs
, followFile
, doesFileExist
, doesPathExist
, listDirectory
, createDirectoryIfMissing
, removeFile
, canonicalizePath
, getCurrentDirectory
, getXdgRuntimeDir
, runFileSystemIO
, runFileSystemNoOp
, runFileSystemState
) where
import Control.Exception (bracket)
import Effectful (Effect, IOE)
import Effectful.Dispatch.Dynamic (interpret_)
import Effectful.Exception (throwIO)
import Effectful.State.Static.Shared (State, gets, modify)
import Effectful.TH (makeEffect)
import System.Environment (lookupEnv)
import System.IO.Error (userError)
import System.Posix.Types (COff (..), FileOffset)
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as LBS
import Data.Map.Strict qualified as M
import System.Directory qualified as Dir
import System.Posix.IO qualified as Posix
import Atelier.Effects.Delay (Delay)
import Atelier.Effects.Posix.IO (readFdFrom)
import Atelier.Time (Millisecond)
import Atelier.Effects.Delay qualified as Delay
-- | Effect for filesystem access.
data FileSystem :: Effect where
-- | Read a file's full contents strictly.
ReadFileBs :: FilePath -> FileSystem m ByteString
-- | Read a file from the given byte offset to the end, lazily.
ReadFileLbsFrom :: FilePath -> FileOffset -> FileSystem m LBS.ByteString
-- | Does a regular file exist at the path?
DoesFileExist :: FilePath -> FileSystem m Bool
-- | Does anything (file or directory) exist at the path?
DoesPathExist :: FilePath -> FileSystem m Bool
-- | List the entries of a directory.
ListDirectory :: FilePath -> FileSystem m [FilePath]
-- | Create a directory. The 'Bool' requests creation of missing parents.
CreateDirectoryIfMissing :: Bool -> FilePath -> FileSystem m ()
-- | Delete a file.
RemoveFile :: FilePath -> FileSystem m ()
-- | Resolve a path to a canonical, absolute form.
CanonicalizePath :: FilePath -> FileSystem m FilePath
-- | The process's current working directory.
GetCurrentDirectory :: FileSystem m FilePath
-- | The XDG runtime directory (@$XDG_RUNTIME_DIR@), falling back to @\/tmp@.
GetXdgRuntimeDir :: FileSystem m FilePath
makeEffect ''FileSystem
-- | Read a file's full contents lazily (equivalent to reading from offset 0).
readFileLbs :: (FileSystem :> es) => FilePath -> Eff es LBS.ByteString
readFileLbs path = readFileLbsFrom path 0
-- | Follow a file as it grows, calling @onChunk@ with each new chunk of
-- content. Polls every 200ms. Does not return.
followFile
:: (Delay :> es, FileSystem :> es)
=> FilePath
-> (ByteString -> Eff es ())
-> Eff es a
followFile path onChunk = go 0
where
go offset = do
newContent <- readFileLbsFrom path offset
if LBS.null newContent then pure () else onChunk (LBS.toStrict newContent)
Delay.wait (200 :: Millisecond)
go (offset + fromIntegral (LBS.length newContent))
-- | Interpret 'FileSystem' against the real filesystem.
runFileSystemIO :: (IOE :> es) => Eff (FileSystem : es) a -> Eff es a
runFileSystemIO = interpret_ $ \case
ReadFileBs path -> liftIO $ BS.readFile path
ReadFileLbsFrom path offset ->
liftIO
$ bracket
(Posix.openFd path Posix.ReadOnly Posix.defaultFileFlags)
Posix.closeFd
(readFdFrom offset)
DoesFileExist path -> liftIO $ Dir.doesFileExist path
DoesPathExist path -> liftIO $ Dir.doesPathExist path
ListDirectory path -> liftIO $ Dir.listDirectory path
CreateDirectoryIfMissing p path -> liftIO $ Dir.createDirectoryIfMissing p path
RemoveFile path -> liftIO $ Dir.removeFile path
CanonicalizePath path -> liftIO $ Dir.canonicalizePath path
GetCurrentDirectory -> liftIO Dir.getCurrentDirectory
GetXdgRuntimeDir -> liftIO $ fromMaybe "/tmp" <$> lookupEnv "XDG_RUNTIME_DIR"
-- | Interpret 'FileSystem' with inert results: reads return empty, existence
-- checks return 'False', and mutations do nothing. Useful for tests.
runFileSystemNoOp :: Eff (FileSystem : es) a -> Eff es a
runFileSystemNoOp = interpret_ $ \case
ReadFileBs _ -> pure mempty
ReadFileLbsFrom _ _ -> pure mempty
DoesFileExist _ -> pure False
DoesPathExist _ -> pure False
ListDirectory _ -> pure []
CreateDirectoryIfMissing _ _ -> pure ()
RemoveFile _ -> pure ()
CanonicalizePath path -> pure path
GetCurrentDirectory -> pure "."
GetXdgRuntimeDir -> pure "/tmp"
-- | Run `FileSystem` effect backed by a `State` effect with a `Map`. The keys
-- of the `Map` are treated as file names. Only the `dirname` of files are
-- counted as existing directories.
runFileSystemState
:: (State (Map FilePath ByteString) :> es)
=> Eff (FileSystem : es) a -> Eff es a
runFileSystemState = interpret_ \case
ReadFileBs fp ->
maybe (throwIO $ userError "No such file") pure
=<< gets (M.lookup fp)
ReadFileLbsFrom fp (COff offset) -> do
contents <-
maybe (throwIO $ userError "No such file") pure
=<< gets (M.lookup fp)
pure
. toLazy
. BS.drop (fromIntegral offset)
$ contents
DoesFileExist fp -> gets $ M.member fp
DoesPathExist fp -> gets $ any (\p -> fp `isPrefixOf` p && fp /= p) . M.keys
ListDirectory fp -> gets $ filter (\p -> fp `isPrefixOf` p && fp /= p) . M.keys
CreateDirectoryIfMissing _ _ -> pure ()
RemoveFile fp -> modify $ M.delete fp
CanonicalizePath fp -> pure fp
GetCurrentDirectory -> pure "/"
GetXdgRuntimeDir -> pure "/tmp"