packages feed

fs-sim-0.5.0.0: src/System/FS/Sim/MockFS.hs

{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE DeriveFunctor #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TupleSections #-}

-- | Mock file system implementation
--
-- Intended for qualified import
--
-- > import           System.FS.Sim.MockFS (MockFS)
-- > import qualified System.FS.Sim.MockFS as Mock
module System.FS.Sim.MockFS
  ( empty
  , example
  , handleIsOpen
  , numOpenHandles
  , openHandles
  , pretty

    -- * Debugging
  , dumpState

    -- * Operations on files
  , hClose
  , hGetSize
  , hGetSome
  , hGetSomeAt
  , hIsOpen
  , hOpen
  , hPutSome
  , hSeek
  , hTruncate

    -- * Operations on directories
  , createDirectory
  , createDirectoryIfMissing
  , doesDirectoryExist
  , doesFileExist
  , listDirectory
  , removeDirectoryRecursive
  , removeFile
  , renameFile

    -- * Exported for the benefit of tests only
  , Files
  , mockFiles

    -- ** opaque
  , ClosedHandleState
  , FilePtr
  , HandleState
  , OpenHandleState

    -- * opaque
  , HandleMock
  , MockFS

    -- * HasBufFS
  , fromBuffer
  , intoBuffer
  , hGetBufSome
  , hGetBufSomeAt
  , hPutBufSome
  , hPutBufSomeAt
  ) where

import Control.Monad (forM, forM_, unless, when)
import Control.Monad.Except (MonadError, throwError)
import Control.Monad.Primitive (PrimMonad (..))
import Control.Monad.State.Strict (MonadState, get, gets, put)
import Data.Bifunctor
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base16 as B16
import Data.Int (Int64)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
import Data.Maybe (mapMaybe)
import qualified Data.Primitive as P
import Data.Primitive.ByteArray
import Data.Set (Set)
import qualified Data.Set as S
import qualified Data.Text as Text
import Data.Word (Word64, Word8)
import GHC.Generics (Generic)
import System.FS.API (BufferOffset (..))
import System.FS.API.Types
import System.FS.CallStack
import System.FS.Sim.FsTree (FsTree (..), FsTreeError (..))
import qualified System.FS.Sim.FsTree as FS
import System.Posix.Types (ByteCount)

{-------------------------------------------------------------------------------
  Mock FS types
-------------------------------------------------------------------------------}

data MockFS = MockFS
  { mockFiles :: !Files
  , mockHandles :: !(Map HandleMock HandleState)
  , mockNextHandle :: !HandleMock
  }
  deriving (Generic, Show)

-- | We store the files as an 'FsTree' of the file contents
type Files = FsTree ByteString

-- | A mock handle to a file on disk.
--
-- This is only meaningful when interpreted against a 'MockFS'.
newtype HandleMock = HandleMock Int
  deriving stock (Show, Eq, Ord, Generic)
  deriving newtype Enum

-- | Instantiate 'Handle' with the mock handle
type Handle' = Handle HandleMock

-- | Mock handle internal state
data HandleState
  = HandleOpen !OpenHandleState
  | HandleClosed !ClosedHandleState
  deriving (Show, Generic)

data OpenHandleState = OpenHandle
  { openFilePath :: !FsPath
  , openPtr :: !FilePtr
  }
  deriving (Show, Generic)

-- | Check whether the file handle is in write/append mode.
isWriteHandle :: OpenHandleState -> Bool
isWriteHandle OpenHandle{..} = case openPtr of
  RW _ True _ -> True
  Append -> True
  _ -> False

-- | File pointer
--
-- This is purely an internal abstraction.
data FilePtr
  = -- | Read/write pointer
    --
    -- We record if we can read and/or write, and the current offset
    RW !Bool !Bool !Word64
  | -- | Append-only pointer
    --
    -- Offset is always the end of the file in append mode
    Append
  deriving (Show, Generic)

data ClosedHandleState = ClosedHandle
  { closedFilePath :: FsPath
  }
  deriving (Show, Generic)

-- | Monads in which we can simulate the file system
type CanSimFS m = (HasCallStack, MonadState MockFS m, MonadError FsError m)

empty :: MockFS
empty = MockFS FS.empty M.empty (HandleMock 0)

example :: MockFS
example = empty{mockFiles = FS.example}

{-------------------------------------------------------------------------------
  Auxiliary
-------------------------------------------------------------------------------}

-- | Return 'True' iff the handle is open.
--
-- Throws an exception if the handle is unknown.
handleIsOpen :: MockFS -> HandleMock -> Bool
handleIsOpen MockFS{..} h =
  case M.lookup h mockHandles of
    Nothing ->
      error "handleIOMode: unknown handle"
    Just (HandleOpen OpenHandle{}) -> True
    Just (HandleClosed _) -> False

openHandles :: MockFS -> [OpenHandleState]
openHandles MockFS{..} = mapMaybe isOpen $ M.elems mockHandles
 where
  isOpen :: HandleState -> Maybe OpenHandleState
  isOpen (HandleOpen hs) = Just hs
  isOpen (HandleClosed _) = Nothing

-- | A set containing each file path that some open handle refers to.
openFilePaths :: MockFS -> Set FsPath
openFilePaths MockFS{..} = foldMap handleOpenFilePath $ M.elems mockHandles
 where
  handleOpenFilePath :: HandleState -> Set FsPath
  handleOpenFilePath (HandleOpen hs) = S.singleton $ openFilePath hs
  handleOpenFilePath (HandleClosed _) = S.empty

-- | Number of open handles
numOpenHandles :: MockFS -> Int
numOpenHandles = length . openHandles

-- | Updated file pointer
--
-- We lift this out as a separate concept primarily for the benefit of tests.
--
-- See 'hSeek' for limitations.
seekFilePtr ::
  MonadError FsError m =>
  MockFS -> Handle' -> SeekMode -> Int64 -> m FilePtr
seekFilePtr MockFS{..} (Handle h _) seekMode o = do
  case mockHandles M.! h of
    HandleClosed ClosedHandle{..} ->
      throwError
        FsError
          { fsErrorType = FsIllegalOperation
          , fsErrorPath = fsToFsErrorPathUnmounted closedFilePath
          , fsErrorString = "handle closed"
          , fsErrorNo = Nothing
          , fsErrorStack = prettyCallStack
          , fsLimitation = False
          }
    HandleOpen OpenHandle{..} -> do
      file <- checkFsTree $ FS.getFile openFilePath mockFiles
      let fsize = fromIntegral (BS.length file) :: Word64
      case (openPtr, seekMode, sign64 o) of
        (RW r w _cur, AbsoluteSeek, Positive o') -> do
          when (o' > fsize) $ throwError (errPastEnd openFilePath)
          return $ RW r w o'
        (_, AbsoluteSeek, Negative _) ->
          throwError $ errNegative openFilePath
        (RW r w cur, RelativeSeek, Positive o') -> do
          let cur' = cur + o'
          when (cur' > fsize) $ throwError (errPastEnd openFilePath)
          return $ RW r w cur'
        (RW r w cur, RelativeSeek, Negative o') -> do
          when (o' > cur) $ throwError (errNegative openFilePath)
          let cur' = cur - o'
          return $ RW r w cur'
        (RW r w _cur, SeekFromEnd, Positive 0) ->
          return $ RW r w fsize
        (RW _ _ _, SeekFromEnd, Positive _) ->
          throwError (errPastEnd openFilePath)
        (RW r w _, SeekFromEnd, Negative o') -> do
          when (o' > fsize) $ throwError (errNegative openFilePath)
          let cur' = fsize - o'
          return $ RW r w cur'
        (Append, _, _) ->
          throwError (errAppend openFilePath)
 where
  errPastEnd fp =
    FsError
      { fsErrorType = FsInvalidArgument
      , fsErrorPath = fsToFsErrorPathUnmounted fp
      , fsErrorString = "seek past EOF not supported"
      , fsErrorNo = Nothing
      , fsErrorStack = prettyCallStack
      , fsLimitation = True
      }
  errAppend fp =
    FsError
      { fsErrorType = FsInvalidArgument
      , fsErrorPath = fsToFsErrorPathUnmounted fp
      , fsErrorString = "seek in append mode not supported"
      , fsErrorNo = Nothing
      , fsErrorStack = prettyCallStack
      , fsLimitation = True
      }
  errNegative fp =
    FsError
      { fsErrorType = FsInvalidArgument
      , fsErrorPath = fsToFsErrorPathUnmounted fp
      , fsErrorString = "seek past beginning of file"
      , fsErrorNo = Nothing
      , fsErrorStack = prettyCallStack
      , fsLimitation = False
      }

{-------------------------------------------------------------------------------
  Internal utilities for implementing the mock FS
-------------------------------------------------------------------------------}

-- | Modify the mock file system without a file handle
modifyMockFS ::
  CanSimFS m =>
  (MockFS -> m (a, MockFS)) -> m a
modifyMockFS f = do
  st <- get
  (a, st') <- f st
  put st'
  return a

-- | Access but do not modify the mock file system state without a file handle
readMockFS ::
  CanSimFS m =>
  (Files -> m a) -> m a
readMockFS f = modifyMockFS (\fs -> (,fs) <$> f (mockFiles fs))

-- | Require a file handle and may modify the mock file system
withHandleModify ::
  CanSimFS m =>
  Handle' ->
  ( MockFS ->
    HandleState ->
    m (a, (Files, HandleState))
  ) ->
  m a
withHandleModify (Handle h _) f = do
  st <- get
  case M.lookup h (mockHandles st) of
    Just hs -> do
      (a, (fs', hs')) <- f st hs
      put $
        st
          { mockHandles = M.insert h hs' (mockHandles st)
          , mockFiles = fs'
          }
      return a
    Nothing ->
      error "withHandleModify: handle not found"

-- | Require a file handle but do not modify the mock file system
withHandleRead ::
  CanSimFS m =>
  Handle' ->
  ( MockFS ->
    HandleState ->
    m (a, HandleState)
  ) ->
  m a
withHandleRead h f =
  withHandleModify h $ \fs hs ->
    second (mockFiles fs,) <$> f fs hs

-- | Require an open file handle to modify the mock file system
withOpenHandleModify ::
  CanSimFS m =>
  Handle' ->
  ( MockFS ->
    OpenHandleState ->
    m (a, (Files, OpenHandleState))
  ) ->
  m a
withOpenHandleModify h f =
  withHandleModify h $ \fs -> \case
    HandleOpen hs ->
      second (second HandleOpen) <$> f fs hs
    HandleClosed ClosedHandle{..} ->
      throwError
        FsError
          { fsErrorType = FsIllegalOperation
          , fsErrorPath = fsToFsErrorPathUnmounted closedFilePath
          , fsErrorString = "handle closed"
          , fsErrorNo = Nothing
          , fsErrorStack = prettyCallStack
          , fsLimitation = False
          }

-- | Require an open file handle but do not modify the mock file system
withOpenHandleRead ::
  CanSimFS m =>
  Handle' ->
  ( MockFS ->
    OpenHandleState ->
    m (a, OpenHandleState)
  ) ->
  m a
withOpenHandleRead h f =
  withHandleRead h $ \fs -> \case
    HandleOpen hs ->
      second HandleOpen <$> f fs hs
    HandleClosed ClosedHandle{..} ->
      throwError
        FsError
          { fsErrorType = FsIllegalOperation
          , fsErrorPath = fsToFsErrorPathUnmounted closedFilePath
          , fsErrorString = "handle closed"
          , fsErrorNo = Nothing
          , fsErrorStack = prettyCallStack
          , fsLimitation = False
          }

{-------------------------------------------------------------------------------
  Debugging
-------------------------------------------------------------------------------}

dumpState :: CanSimFS m => m String
dumpState = pretty <$> get

{-------------------------------------------------------------------------------
  Internal auxiliary
-------------------------------------------------------------------------------}

checkFsTree' ::
  (MonadError FsError m, HasCallStack) =>
  Either FsTreeError a -> m (Either FsPath a)
checkFsTree' = go
 where
  go (Left (FsExpectedDir fp _)) =
    throwError
      FsError
        { fsErrorType = FsResourceInappropriateType
        , fsErrorPath = fsToFsErrorPathUnmounted fp
        , fsErrorString = "expected directory"
        , fsErrorNo = Nothing
        , fsErrorStack = prettyCallStack
        , fsLimitation = False
        }
  go (Left (FsExpectedFile fp)) =
    throwError
      FsError
        { fsErrorType = FsResourceInappropriateType
        , fsErrorPath = fsToFsErrorPathUnmounted fp
        , fsErrorString = "expected file"
        , fsErrorNo = Nothing
        , fsErrorStack = prettyCallStack
        , fsLimitation = False
        }
  go (Left (FsMissing fp _)) =
    return (Left fp)
  go (Left (FsExists fp)) =
    throwError
      FsError
        { fsErrorType = FsResourceAlreadyExist
        , fsErrorPath = fsToFsErrorPathUnmounted fp
        , fsErrorString = "file exists"
        , fsErrorNo = Nothing
        , fsErrorStack = prettyCallStack
        , fsLimitation = False
        }
  go (Right a) =
    return (Right a)

checkFsTree ::
  (MonadError FsError m, HasCallStack) =>
  Either FsTreeError a -> m a
checkFsTree ma = do
  ma' <- checkFsTree' ma
  case ma' of
    Left fp ->
      throwError
        FsError
          { fsErrorType = FsResourceDoesNotExist
          , fsErrorPath = fsToFsErrorPathUnmounted fp
          , fsErrorString = "does not exist"
          , fsErrorNo = Nothing
          , fsErrorStack = prettyCallStack
          , fsLimitation = False
          }
    Right a -> return a

checkDoesNotExist ::
  (MonadError FsError m, HasCallStack) =>
  MockFS -> FsPath -> m ()
checkDoesNotExist fs fp = do
  exists <- fmap pathExists $ checkFsTree' $ FS.index fp (mockFiles fs)
  if exists
    then
      throwError
        FsError
          { fsErrorType = FsResourceAlreadyExist
          , fsErrorPath = fsToFsErrorPathUnmounted fp
          , fsErrorString = "already exists"
          , fsErrorNo = Nothing
          , fsErrorStack = prettyCallStack
          , fsLimitation = False
          }
    else return ()
 where
  pathExists :: Either a b -> Bool
  pathExists (Left _) = False
  pathExists (Right _) = True

newHandle :: MockFS -> OpenHandleState -> (Handle', MockFS)
newHandle fs hs =
  ( Handle (mockNextHandle fs) (openFilePath hs)
  , fs
      { mockNextHandle = succ (mockNextHandle fs)
      , mockHandles =
          M.insert
            (mockNextHandle fs)
            (HandleOpen hs)
            (mockHandles fs)
      }
  )

{-------------------------------------------------------------------------------
  Operations on files
-------------------------------------------------------------------------------}

-- | Mock implementation of 'hOpen'.
--
-- NOTE: Differences from Posix:
--
-- * We do not support opening directories.
-- * We do not support more than one concurrent writer
--   (we do however allow a writer and multiple concurrent readers)
-- * We do not support create file on ReadMode.
hOpen :: CanSimFS m => FsPath -> OpenMode -> m Handle'
hOpen fp openMode = do
  dirExists <- doesDirectoryExist fp
  when dirExists $
    throwError
      FsError
        { fsErrorType = FsResourceInappropriateType
        , fsErrorPath = fsToFsErrorPathUnmounted fp
        , fsErrorString = "hOpen: directories not supported"
        , fsErrorNo = Nothing
        , fsErrorStack = prettyCallStack
        , fsLimitation = True
        }
  modifyMockFS $ \fs -> do
    let alreadyHasWriter =
          any (\hs -> openFilePath hs == fp && isWriteHandle hs) $
            openHandles fs
    when (openMode /= ReadMode && alreadyHasWriter) $
      throwError
        FsError
          { fsErrorType = FsInvalidArgument
          , fsErrorPath = fsToFsErrorPathUnmounted fp
          , fsErrorString = "more than one concurrent writer not supported"
          , fsErrorNo = Nothing
          , fsErrorStack = prettyCallStack
          , fsLimitation = True
          }
    files' <- checkFsTree $ FS.openFile fp ex (mockFiles fs)
    return $
      newHandle
        (fs{mockFiles = files'})
        (OpenHandle fp (filePtr openMode))
 where
  ex :: AllowExisting
  ex = allowExisting openMode

  filePtr :: OpenMode -> FilePtr
  filePtr ReadMode = RW True False 0
  filePtr (WriteMode _) = RW False True 0
  filePtr (ReadWriteMode _) = RW True True 0
  filePtr (AppendMode _) = Append

-- | Mock implementation of 'hClose'
hClose :: CanSimFS m => Handle' -> m ()
hClose h = withHandleRead h $ \_fs -> \case
  HandleOpen hs ->
    return ((), HandleClosed (ClosedHandle (openFilePath hs)))
  HandleClosed hs ->
    return ((), HandleClosed hs)

-- | Mock implementation of 'hIsOpen'
hIsOpen :: CanSimFS m => Handle' -> m Bool
hIsOpen h = gets (`handleIsOpen` handleRaw h)

-- | Mock implementation of 'hSeek'
--
-- NOTE: This is more restricted than the IO version, because seek has some
-- odd properties:
--
-- * We do not allow seeking at all on files in append mode
-- * We do not allow seeking past the end of the file
--   (this means that when using 'IO.SeekFromEnd', the only valid offset is 0)
-- * We do /not/ return the new file offset
hSeek ::
  CanSimFS m =>
  Handle' -> SeekMode -> Int64 -> m ()
hSeek h seekMode o = withOpenHandleRead h $ \fs hs -> do
  openPtr' <- seekFilePtr fs h seekMode o
  return ((), hs{openPtr = openPtr'})

-- | Get bytes from handle
--
-- NOTE: Unlike real I/O, we disallow 'hGetSome' on a handle in append mode.
hGetSome :: CanSimFS m => Handle' -> Word64 -> m ByteString
hGetSome h n =
  withOpenHandleRead h $ \fs hs@OpenHandle{..} -> do
    file <- checkFsTree $ FS.getFile openFilePath (mockFiles fs)
    case openPtr of
      RW r w o -> do
        unless r $ throwError (errNoReadAccess openFilePath "write")
        let bs = BS.take (fromIntegral n) . BS.drop (fromIntegral o) $ file
        return (bs, hs{openPtr = RW True w (o + fromIntegral (BS.length bs))})
      Append -> throwError (errNoReadAccess openFilePath "append")
 where
  errNoReadAccess fp mode =
    FsError
      { fsErrorType = FsInvalidArgument
      , fsErrorPath = fsToFsErrorPathUnmounted fp
      , fsErrorString = "cannot hGetSome in " <> mode <> " mode"
      , fsErrorNo = Nothing
      , fsErrorStack = prettyCallStack
      , fsLimitation = True
      }

-- | Thread safe version of 'hGetSome', which doesn't modify or read the file
-- offset.
hGetSomeAt ::
  CanSimFS m =>
  Handle' ->
  Word64 ->
  AbsOffset ->
  m ByteString
hGetSomeAt h n o =
  withOpenHandleRead h $ \fs hs@OpenHandle{..} -> do
    file <- checkFsTree $ FS.getFile openFilePath (mockFiles fs)
    let o' = unAbsOffset o
    let fsize = fromIntegral (BS.length file) :: Word64
    case openPtr of
      RW r _ _ -> do
        unless r $ throwError (errNoReadAccess openFilePath "write")
        let bs = BS.take (fromIntegral n) . BS.drop (fromIntegral o') $ file
        -- This is the same fsLimitation we get when we seek past the end of
        -- EOF, in AbsoluteSeek mode.
        when (o' > fsize) $ throwError (errPastEnd openFilePath)
        return (bs, hs)
      Append -> throwError (errNoReadAccess openFilePath "append")
 where
  errNoReadAccess fp mode =
    FsError
      { fsErrorType = FsInvalidArgument
      , fsErrorPath = fsToFsErrorPathUnmounted fp
      , fsErrorString = "cannot hGetSomeAt in " <> mode <> " mode"
      , fsErrorNo = Nothing
      , fsErrorStack = prettyCallStack
      , fsLimitation = True
      }

  errPastEnd fp =
    FsError
      { fsErrorType = FsInvalidArgument
      , fsErrorPath = fsToFsErrorPathUnmounted fp
      , fsErrorString = "hGetSomeAt offset past EOF not supported"
      , fsErrorNo = Nothing
      , fsErrorStack = prettyCallStack
      , fsLimitation = True
      }

hPutSome :: CanSimFS m => Handle' -> ByteString -> m Word64
hPutSome h toWrite =
  withOpenHandleModify h $ \fs hs@OpenHandle{..} -> do
    case openPtr of
      RW r w o -> do
        unless w $ throwError (errReadOnly openFilePath)
        file <- checkFsTree $ FS.getFile openFilePath (mockFiles fs)
        let file' = replace o toWrite file
        files' <- checkFsTree $ FS.replace openFilePath file' (mockFiles fs)
        return (written, (files', hs{openPtr = RW r w (o + written)}))
      Append -> do
        file <- checkFsTree $ FS.getFile openFilePath (mockFiles fs)
        let file' = file <> toWrite
        files' <- checkFsTree $ FS.replace openFilePath file' (mockFiles fs)
        return (written, (files', hs))
 where
  written = toEnum $ BS.length toWrite

  errReadOnly fp =
    FsError
      { fsErrorType = FsInvalidArgument
      , fsErrorPath = fsToFsErrorPathUnmounted fp
      , fsErrorString = "handle is read-only"
      , fsErrorNo = Nothing
      , fsErrorStack = prettyCallStack
      , fsLimitation = False
      }

-- | Truncate a file
--
-- NOTE: Differences from Posix:
--
-- * Although this corresponds to Posix @ftruncate@, this can only be used
--   to make files /smaller/, not larger.
-- * We only support this in append mode. The reason is that Posix
--   @ftruncate@ does not modify the file offset, and adds padding with zeroes
--   on subsequent writes. This is however not behaviour we want to emulate.
--   In append mode however the Posix file offset is not used (and we don't
--   even record it at all), appends always happen at the end of the file.
hTruncate :: CanSimFS m => Handle' -> Word64 -> m ()
hTruncate h sz =
  withOpenHandleModify h $ \fs hs@OpenHandle{..} -> do
    file <- checkFsTree $ FS.getFile openFilePath (mockFiles fs)
    ptr' <- case (sz > fromIntegral (BS.length file), openPtr) of
      (True, _) ->
        throwError
          FsError
            { fsErrorType = FsInvalidArgument
            , fsErrorPath = fsToFsErrorPathUnmounted openFilePath
            , fsErrorString = "truncate cannot make the file larger"
            , fsErrorNo = Nothing
            , fsErrorStack = prettyCallStack
            , fsLimitation = True
            }
      (False, RW{}) ->
        throwError
          FsError
            { fsErrorType = FsInvalidArgument
            , fsErrorPath = fsToFsErrorPathUnmounted openFilePath
            , fsErrorString = "truncate only supported in append mode"
            , fsErrorNo = Nothing
            , fsErrorStack = prettyCallStack
            , fsLimitation = True
            }
      (False, Append) ->
        return Append
    let file' = BS.take (fromIntegral sz) file
    files' <- checkFsTree $ FS.replace openFilePath file' (mockFiles fs)
    -- TODO: Don't replace the file pointer (not changed)
    return ((), (files', hs{openPtr = ptr'}))

-- | Get file size
--
-- NOTE: In the mock implementation this is thread safe, because there can be
-- only one writer, so concurrent threads cannot change the size of the file.
hGetSize :: CanSimFS m => Handle' -> m Word64
hGetSize h =
  withOpenHandleRead h $ \fs hs@OpenHandle{..} -> do
    file <- checkFsTree $ FS.getFile openFilePath (mockFiles fs)
    return (fromIntegral (BS.length file), hs)

{-------------------------------------------------------------------------------
  Operations on directories
-------------------------------------------------------------------------------}

createDirectory :: CanSimFS m => FsPath -> m ()
createDirectory dir = modifyMockFS $ \fs -> do
  checkDoesNotExist fs dir
  files' <- checkFsTree $ FS.createDirIfMissing dir (mockFiles fs)
  return ((), fs{mockFiles = files'})

createDirectoryIfMissing ::
  CanSimFS m =>
  Bool -> FsPath -> m ()
createDirectoryIfMissing createParents dir = do
  -- Although @createDirectoryIfMissing /a/b/c@ will fail ("inappropriate
  -- type") if @b@ is a file (not a directory), for some strange reason it
  -- throws "already exists" if @c@ is is a file
  fileExists <- doesFileExist dir
  if fileExists
    then
      throwError
        FsError
          { fsErrorType = FsResourceAlreadyExist
          , fsErrorPath = fsToFsErrorPathUnmounted dir
          , fsErrorString = "a file with that name already exists"
          , fsErrorNo = Nothing
          , fsErrorStack = prettyCallStack
          , fsLimitation = False
          }
    else modifyMockFS $ \fs -> do
      files' <- checkFsTree $ go createParents (mockFiles fs)
      return ((), fs{mockFiles = files'})
 where
  go :: Bool -> Files -> Either FsTreeError Files
  go True = FS.createDirWithParents dir
  go False = FS.createDirIfMissing dir

listDirectory ::
  CanSimFS m =>
  FsPath -> m (Set String)
listDirectory fp =
  readMockFS $
    fmap (S.fromList . map Text.unpack . M.keys)
      . checkFsTree
      . FS.getDir fp

-- | Check if directory exists
--
-- It seems real I/O maps what would be "inapproriate device" errors to False.
doesDirectoryExist :: CanSimFS m => FsPath -> m Bool
doesDirectoryExist fp = readMockFS $ \fs ->
  return $ case FS.getDir fp fs of
    Left _ -> False
    Right _ -> True

-- | Check if file exists
--
-- See comments for 'doesDirectoryExist'.
doesFileExist :: CanSimFS m => FsPath -> m Bool
doesFileExist fp = readMockFS $ \fs ->
  return $ case FS.getFile fp fs of
    Left _ -> False
    Right _ -> True

-- | Remove a directory and its contents
--
-- Same limitations as 'removeFile'.
removeDirectoryRecursive :: CanSimFS m => FsPath -> m ()
removeDirectoryRecursive fp = do
  modifyMockFS $ \fs -> do
    reachablePaths <- fmap S.fromList $ checkFsTree $ FS.find fp (mockFiles fs)
    let openReachablePaths = reachablePaths `S.intersection` openFilePaths fs
    case fsPathToList fp of
      [] ->
        throwError
          FsError
            { fsErrorType = FsIllegalOperation
            , fsErrorPath = fsToFsErrorPathUnmounted fp
            , fsErrorString = "cannot remove the root directory"
            , fsErrorNo = Nothing
            , fsErrorStack = prettyCallStack
            , fsLimitation = True
            }
      _
        | openReachablePaths /= mempty ->
            throwError
              FsError
                { fsErrorType = FsIllegalOperation
                , fsErrorPath = fsToFsErrorPathUnmounted fp
                , fsErrorString =
                    "cannot remove an open file. "
                      ++ "The following files are reachable from "
                      ++ show fp
                      ++ "and are still open: "
                      ++ show openReachablePaths
                , fsErrorNo = Nothing
                , fsErrorStack = prettyCallStack
                , fsLimitation = True
                }
      _ -> do
        files' <- checkFsTree $ FS.removeDirRecursive fp (mockFiles fs)
        return ((), fs{mockFiles = files'})

-- | Remove a file
--
-- The behaviour of @unlink@ is to remove the file after all open file handles
-- that refer to it are closed. The open file handles referring to the file
-- can still be used to write\/read to\/from, while at the same time, the file
-- is invisible for all other operations.
--
-- We do not implement this behaviour and consider this a limitation of the
-- mock file system, and throw an error when removing a file that still has
-- open file handles to it.
--
-- In the state machine tests, removing the root directory may cause the IO
-- implementation to throw an 'FsInsufficientPermissions' error, depending on
-- the permissions of the temporary directory used to run the tests in. In
-- theory it should throw a 'FsResourceInappropriateType' error. To avoid this
-- mismatch during testing, we also consider removing the root folder a
-- limitation of the mock file system.
removeFile :: CanSimFS m => FsPath -> m ()
removeFile fp =
  modifyMockFS $ \fs -> case fsPathToList fp of
    [] ->
      throwError
        FsError
          { fsErrorType = FsIllegalOperation
          , fsErrorPath = fsToFsErrorPathUnmounted fp
          , fsErrorString = "cannot remove the root directory"
          , fsErrorNo = Nothing
          , fsErrorStack = prettyCallStack
          , fsLimitation = True
          }
    _
      | fp `S.member` openFilePaths fs ->
          throwError
            FsError
              { fsErrorType = FsIllegalOperation
              , fsErrorPath = fsToFsErrorPathUnmounted fp
              , fsErrorString = "cannot remove an open file"
              , fsErrorNo = Nothing
              , fsErrorStack = prettyCallStack
              , fsLimitation = True
              }
    _ -> do
      files' <- checkFsTree $ FS.removeFile fp (mockFiles fs)
      return ((), fs{mockFiles = files'})

renameFile :: CanSimFS m => FsPath -> FsPath -> m ()
renameFile fpOld fpNew =
  modifyMockFS $ \fs ->
    if
      | not (sameDir fpOld fpNew) ->
          throwError $ errDifferentDir fpOld
      | fpOld `S.member` openFilePaths fs ->
          throwError $ errRenameOpenFile fpOld
      | fpNew `S.member` openFilePaths fs ->
          throwError $ errRenameOpenFile fpNew
      | Right _ <- FS.getDir fpNew (mockFiles fs) ->
          throwError $ errRenameDir fpNew
      | otherwise -> do
          files' <- checkFsTree $ FS.renameFile fpOld fpNew (mockFiles fs)
          return ((), fs{mockFiles = files'})
 where
  sameDir fp1 fp2 =
    (fst <$> fsPathSplit fp1) == (fst <$> fsPathSplit fp2)

  errRenameOpenFile fp =
    FsError
      { fsErrorType = FsIllegalOperation
      , fsErrorPath = fsToFsErrorPathUnmounted fp
      , fsErrorString = "cannot rename opened file"
      , fsErrorNo = Nothing
      , fsErrorStack = prettyCallStack
      , fsLimitation = True
      }

  errRenameDir fp =
    FsError
      { fsErrorType = FsResourceInappropriateType
      , fsErrorPath = fsToFsErrorPathUnmounted fp
      , fsErrorString = "is a directory"
      , fsErrorNo = Nothing
      , fsErrorStack = prettyCallStack
      , fsLimitation = True
      }

  errDifferentDir fp =
    FsError
      { fsErrorType = FsIllegalOperation
      , fsErrorPath = fsToFsErrorPathUnmounted fp
      , fsErrorString = "files must be in the same directory"
      , fsErrorNo = Nothing
      , fsErrorStack = prettyCallStack
      , fsLimitation = True
      }

{-------------------------------------------------------------------------------
  Pretty-printing
-------------------------------------------------------------------------------}

-- | Renders the 'MockFS' in a human-readable fashion.
pretty :: MockFS -> String
-- TODO: Right now does this not show the state of the handles.
pretty = FS.pretty renderFile . mockFiles
 where
  renderFile :: ByteString -> String
  renderFile = show . hexDump . B16.encode

  hexDump :: ByteString -> ByteString
  hexDump =
    fst
      . BS.foldl'
        ( \(acc, n) w8 ->
            if n == 2
              then (acc <> " " <> BS.singleton w8, 1)
              else (acc <> BS.singleton w8, n + 1)
        )
        (mempty, 0 :: Int)

{-------------------------------------------------------------------------------
  Auxiliary
-------------------------------------------------------------------------------}

data Sign a = Negative a | Positive a
  deriving Functor

sign :: (Num a, Ord a) => a -> Sign a
sign a
  | a < 0 = Negative (negate a)
  | otherwise = Positive a

sign64 :: Int64 -> Sign Word64
sign64 = fmap fromIntegral . sign

{-------------------------------------------------------------------------------
  ByteString
-------------------------------------------------------------------------------}

-- Given
--
-- >        A        B         C
-- > |-----------|-------.-----------|
-- >             n       .
-- >                     .
-- >                 D   .
-- >             |-------|
--
-- return A <> D <> C
replace :: Word64 -> ByteString -> ByteString -> ByteString
replace n d abc = a <> d <> c
 where
  (a, c) = snip (fromIntegral n) (BS.length d) abc

-- Given
--
-- >       A         B         C
-- > |-----------|-------|-----------|
-- >             n
-- >             <------->
-- >                 m
--
-- return (A, C)
snip :: Int -> Int -> ByteString -> (ByteString, ByteString)
snip n m bs = (a, c)
 where
  (a, bc) = BS.splitAt n bs
  c = BS.drop m bc

{-------------------------------------------------------------------------------
  HasBufFS
-------------------------------------------------------------------------------}

packMutableByteArray ::
  PrimMonad m => P.MutableByteArray (PrimState m) -> BufferOffset -> [Word8] -> m ()
packMutableByteArray mba i bytes = forM_ (zip [unBufferOffset i ..] bytes) $ uncurry (P.writeByteArray mba)

intoBuffer ::
  PrimMonad m => P.MutableByteArray (PrimState m) -> BufferOffset -> ByteString -> m Bool
intoBuffer buf bufOff bs = do
  bufSize <- P.getSizeofMutableByteArray buf
  let remaining = bufSize - unBufferOffset bufOff
  if BS.length bs > remaining
    then pure False
    else
      packMutableByteArray buf bufOff (BS.unpack bs)
        >> pure True

unpackMutableByteArray ::
  PrimMonad m => P.MutableByteArray (PrimState m) -> BufferOffset -> ByteCount -> m [Word8]
unpackMutableByteArray mba i c = forM [unBufferOffset i .. unBufferOffset i + fromIntegral c - 1] $ P.readByteArray mba

fromBuffer ::
  PrimMonad m => P.MutableByteArray (PrimState m) -> BufferOffset -> ByteCount -> m (Maybe ByteString)
fromBuffer buf bufOff c = do
  bufSize <- P.getSizeofMutableByteArray buf
  let remaining = bufSize - unBufferOffset bufOff
  if fromIntegral c > remaining
    then pure Nothing
    else Just . BS.pack <$> unpackMutableByteArray buf bufOff c

hGetBufSome ::
  (CanSimFS m, PrimMonad m) =>
  Handle' -> MutableByteArray (PrimState m) -> BufferOffset -> ByteCount -> m ByteCount
hGetBufSome h buf bufOff n =
  withOpenHandleRead h $ \fs hs@OpenHandle{..} -> do
    file <- checkFsTree $ FS.getFile openFilePath (mockFiles fs)
    case openPtr of
      RW r w o -> do
        unless r $ throwError (errNoReadAccess openFilePath "write")
        let bs = BS.take (fromIntegral n) . BS.drop (fromIntegral o) $ file
        success <- intoBuffer buf bufOff bs
        -- we can't read more bytes than the buffer size
        unless success $ throwError (errWritePastBufEnd openFilePath)
        let readBytes = fromIntegral (BS.length bs)
        return (readBytes, hs{openPtr = RW True w (o + fromIntegral readBytes)})
      Append -> throwError (errNoReadAccess openFilePath "append")
 where
  errNoReadAccess fp mode =
    FsError
      { fsErrorType = FsInvalidArgument
      , fsErrorPath = fsToFsErrorPathUnmounted fp
      , fsErrorString = "cannot hGetBufSomeAt in " <> mode <> " mode"
      , fsErrorNo = Nothing
      , fsErrorStack = prettyCallStack
      , fsLimitation = True
      }

  errWritePastBufEnd fp =
    FsError
      { fsErrorType = FsInvalidArgument
      , fsErrorPath = fsToFsErrorPathUnmounted fp
      , fsErrorString = "hPutBufSomeAt: writing into buffer past end not supported"
      , fsErrorNo = Nothing
      , fsErrorStack = prettyCallStack
      , fsLimitation = True
      }

hGetBufSomeAt ::
  (CanSimFS m, PrimMonad m) =>
  Handle' -> MutableByteArray (PrimState m) -> BufferOffset -> ByteCount -> AbsOffset -> m ByteCount
hGetBufSomeAt h buf bufOff n o =
  withOpenHandleRead h $ \fs hs@OpenHandle{..} -> do
    file <- checkFsTree $ FS.getFile openFilePath (mockFiles fs)
    let o' = unAbsOffset o
    let fsize = fromIntegral (BS.length file) :: Word64
    case openPtr of
      RW r _ _ -> do
        unless r $ throwError (errNoReadAccess openFilePath "write")
        -- This is the same fsLimitation we get when we seek past the end of
        -- EOF, in AbsoluteSeek mode.
        when (o' > fsize) $ throwError (errPastEnd openFilePath)
        let bs = BS.take (fromIntegral n) . BS.drop (fromIntegral o') $ file
        success <- intoBuffer buf bufOff bs
        -- we can't read more bytes than the buffer size
        unless success $ throwError (errWritePastBufEnd openFilePath)
        return (fromIntegral (BS.length bs), hs)
      Append -> throwError (errNoReadAccess openFilePath "append")
 where
  errNoReadAccess fp mode =
    FsError
      { fsErrorType = FsInvalidArgument
      , fsErrorPath = fsToFsErrorPathUnmounted fp
      , fsErrorString = "cannot hGetBufSomeAt in " <> mode <> " mode"
      , fsErrorNo = Nothing
      , fsErrorStack = prettyCallStack
      , fsLimitation = True
      }

  errPastEnd fp =
    FsError
      { fsErrorType = FsInvalidArgument
      , fsErrorPath = fsToFsErrorPathUnmounted fp
      , fsErrorString = "hGetBufSomeAt offset past EOF not supported"
      , fsErrorNo = Nothing
      , fsErrorStack = prettyCallStack
      , fsLimitation = True
      }

  errWritePastBufEnd fp =
    FsError
      { fsErrorType = FsInvalidArgument
      , fsErrorPath = fsToFsErrorPathUnmounted fp
      , fsErrorString = "hPutBufSomeAt: writing into buffer past end not supported"
      , fsErrorNo = Nothing
      , fsErrorStack = prettyCallStack
      , fsLimitation = True
      }

hPutBufSome ::
  (CanSimFS m, PrimMonad m) =>
  Handle' -> MutableByteArray (PrimState m) -> BufferOffset -> ByteCount -> m ByteCount
hPutBufSome h buf bufOff n = do
  withOpenHandleModify h $ \fs hs@OpenHandle{..} -> do
    file <- checkFsTree $ FS.getFile openFilePath (mockFiles fs)
    case openPtr of
      RW r w o -> do
        unless w $ throwError (errNoWriteAccess openFilePath "read")
        -- We can't write more bytes than the buffer size
        toWrite <-
          fromBuffer buf bufOff n >>= \case
            Nothing -> throwError (errReadPastBufEnd openFilePath)
            Just bs -> pure bs
        let file' = replace o toWrite file
        files' <- checkFsTree $ FS.replace openFilePath file' (mockFiles fs)
        let written = fromIntegral $ BS.length toWrite
        return (written, (files', hs{openPtr = RW r w (o + fromIntegral written)}))
      Append -> do
        -- We can't write more bytes than the buffer size
        toWrite <-
          fromBuffer buf bufOff n >>= \case
            Nothing -> throwError (errReadPastBufEnd openFilePath)
            Just bs -> pure bs
        let file' = file <> toWrite
        files' <- checkFsTree $ FS.replace openFilePath file' (mockFiles fs)
        let written = fromIntegral $ BS.length toWrite
        return (written, (files', hs))
 where
  errNoWriteAccess fp mode =
    FsError
      { fsErrorType = FsInvalidArgument
      , fsErrorPath = fsToFsErrorPathUnmounted fp
      , fsErrorString = "cannot hPutBufSomeAt in " <> mode <> " mode"
      , fsErrorNo = Nothing
      , fsErrorStack = prettyCallStack
      , fsLimitation = True
      }

  errReadPastBufEnd fp =
    FsError
      { fsErrorType = FsInvalidArgument
      , fsErrorPath = fsToFsErrorPathUnmounted fp
      , fsErrorString = "hPutBufSomeAt: reading from buffer past end not supported"
      , fsErrorNo = Nothing
      , fsErrorStack = prettyCallStack
      , fsLimitation = True
      }

hPutBufSomeAt ::
  (CanSimFS m, PrimMonad m) =>
  Handle' -> MutableByteArray (PrimState m) -> BufferOffset -> ByteCount -> AbsOffset -> m ByteCount
hPutBufSomeAt h buf bufOff n o = do
  withOpenHandleModify h $ \fs hs@OpenHandle{..} -> do
    file <- checkFsTree $ FS.getFile openFilePath (mockFiles fs)
    let o' = unAbsOffset o
    let fsize = fromIntegral (BS.length file)
    case openPtr of
      RW _ w _ -> do
        unless w $ throwError (errNoWriteAccess openFilePath "read")
        -- This is the same fsLimitation we get when we seek past the end of
        -- EOF, in AbsoluteSeek mode.
        when (o' > fsize) $ throwError (errPastEnd openFilePath)
        -- We can't write more bytes than the buffer size
        toWrite <-
          fromBuffer buf bufOff n >>= \case
            Nothing -> throwError (errReadPastBufEnd openFilePath)
            Just bs -> pure bs
        let file' = replace o' toWrite file
        files' <- checkFsTree $ FS.replace openFilePath file' (mockFiles fs)
        let written = fromIntegral $ BS.length toWrite
        return (written, (files', hs))
      Append -> throwError (errNoWriteAccess openFilePath "append")
 where
  errNoWriteAccess fp mode =
    FsError
      { fsErrorType = FsInvalidArgument
      , fsErrorPath = fsToFsErrorPathUnmounted fp
      , fsErrorString = "cannot hPutBufSomeAt in " <> mode <> " mode"
      , fsErrorNo = Nothing
      , fsErrorStack = prettyCallStack
      , fsLimitation = True
      }

  errPastEnd fp =
    FsError
      { fsErrorType = FsInvalidArgument
      , fsErrorPath = fsToFsErrorPathUnmounted fp
      , fsErrorString = "hPutBufSomeAt offset past EOF not supported"
      , fsErrorNo = Nothing
      , fsErrorStack = prettyCallStack
      , fsLimitation = True
      }

  errReadPastBufEnd fp =
    FsError
      { fsErrorType = FsInvalidArgument
      , fsErrorPath = fsToFsErrorPathUnmounted fp
      , fsErrorString = "hPutBufSomeAt: reading from buffer past end not supported"
      , fsErrorNo = Nothing
      , fsErrorStack = prettyCallStack
      , fsLimitation = True
      }