packages feed

fs-sim-0.3.0.0: src/System/FS/Sim/FsTree.hs

{-# LANGUAGE BangPatterns        #-}
{-# LANGUAGE DeriveAnyClass      #-}
{-# LANGUAGE DeriveFunctor       #-}
{-# LANGUAGE DeriveGeneric       #-}
{-# LANGUAGE LambdaCase          #-}
{-# LANGUAGE OverloadedStrings   #-}
{-# LANGUAGE ScopedTypeVariables #-}

-- | Internal part of the mock file system
--
-- Intended for qualified import
--
-- > import           System.FS.Sim.FsTree (FsTree)
-- > import qualified System.FS.Sim.FsTree as FS
module System.FS.Sim.FsTree (
    -- * FsTree type and indexing functions
    FsTree (..)
  , FsTreeError (..)
  , example
    -- * Construction
  , empty
    -- * Indexing
  , getDir
  , getFile
  , index
    -- * File system operations
  , createDirIfMissing
  , createDirWithParents
  , openFile
  , removeDirRecursive
  , removeFile
  , renameFile
  , replace
    -- * Path-listing
  , find
    -- * Pretty-printing
  , pretty
  ) where

import           Data.Functor.Const
import           Data.List.NonEmpty (NonEmpty (..))
import           Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
import           Data.Maybe (fromMaybe)
import           Data.Text (Text)
import qualified Data.Text as Text
import           Data.Tree
import           GHC.Generics (Generic)
import           GHC.Stack

import           System.FS.API.Types

{-------------------------------------------------------------------------------
  FsTree type and general indexing functions
-------------------------------------------------------------------------------}

-- | Simple in-memory representation of a file system
data FsTree a = File !a | Folder !(Folder a)
  deriving (Show, Eq, Generic, Functor)

type Folder a = Map Text (FsTree a)

-- | Example
example :: Monoid a => FsTree a
example =
    Folder $ M.fromList [
        ("usr", Folder $ M.fromList [
            ("local", Folder $ M.fromList [
                ("bin", Folder mempty)
              ])
          ])
      , ("var", Folder $ M.fromList [
            ("log",  Folder mempty)
          , ("mail", Folder mempty)
          , ("run",  Folder mempty)
          , ("tmp",  Folder $ M.fromList [
                ("foo.txt", File mempty)
              ])
          ])
      ]

-- | File access error
data FsTreeError =
    -- | A path @../a/..@ where @a@ is a file rather than a dir
    --
    -- We record both the full path and the invalid suffix.
    FsExpectedDir FsPath (NonEmpty Text)

    -- | A path @../a/..@ where @a@ is a dir rather than a file
    --
    -- No suffix is specified (it /must/ be the last part of the file)
  | FsExpectedFile FsPath

    -- | A path @../a/..@ or @../a@ where directory or file @a@ is missing
    --
    -- We record both the full path and the missing suffix.
  | FsMissing FsPath (NonEmpty Text)

    -- | A file was opened with the O_EXCL flag, but it already existed.
  | FsExists FsPath
  deriving (Show)

setFsTreeErrorPath :: FsPath -> FsTreeError -> FsTreeError
setFsTreeErrorPath fp (FsExpectedDir  _ suffix) = FsExpectedDir  fp suffix
setFsTreeErrorPath fp (FsExpectedFile _)        = FsExpectedFile fp
setFsTreeErrorPath fp (FsMissing      _ suffix) = FsMissing      fp suffix
setFsTreeErrorPath fp (FsExists _)              = FsExists       fp

{-------------------------------------------------------------------------------
  Altering
-------------------------------------------------------------------------------}

-- | Most general indexing function
alterF :: forall f a. Functor f
       => FsPath                                -- ^ Path to look for
       -> (FsTreeError -> f (Maybe (FsTree a))) -- ^ Action on error
       -> (FsTree a    -> f (Maybe (FsTree a))) -- ^ Alter the tree when found
       -> (FsTree a    -> f (FsTree a))
alterF fp onErr f = fmap (fromMaybe empty) . go (fsPathToList fp)
  where
    go :: [Text] -> FsTree a -> f (Maybe (FsTree a))
    go []     t          = f t
    go (p:ps) (File   _) = onErr (FsExpectedDir fp (p :| ps))
    go (p:ps) (Folder m) = Just . Folder <$> M.alterF f' p m
      where
        f' :: Maybe (FsTree a) -> f (Maybe (FsTree a))
        f' Nothing  = onErr (FsMissing fp (p :| ps))
        f' (Just t) = go ps t

alterDir :: forall f a. Functor f
         => FsPath
         -> (FsTreeError -> f (FsTree a)) -- ^ Action on error
         -> f (Folder a)                  -- ^ If directory does not exist
         -> (Folder a -> f (Folder a))    -- ^ If directory exists
         -> (FsTree a -> f (FsTree a))
alterDir p onErr onNotExists onExists =
    alterDirMaybe p
      (fmap Just . onErr)
      (fmap Just onNotExists)
      (fmap Just . onExists)

-- | alterDirMaybe might remove a directory
alterDirMaybe :: forall f a. Functor f
              => FsPath
              -> (FsTreeError -> f (Maybe (FsTree a))) -- ^ Action on error
              -> f (Maybe (Folder a))                  -- ^ If directory does not exist
              -> (Folder a -> f (Maybe (Folder a)))    -- ^ If directory exists
              -> (FsTree a -> f (FsTree a))
alterDirMaybe p onErr onNotExists onExists = alterF p onErr' f
  where
    onErr' :: FsTreeError -> f (Maybe (FsTree a))
    onErr' (FsMissing _ (_ :| [])) = fmap Folder <$> onNotExists
    onErr' err                     = onErr err

    f :: FsTree a -> f (Maybe (FsTree a))
    f (Folder m) = fmap Folder <$> onExists m
    f (File   _) = onErr $ FsExpectedDir p (pathLast p :| [])

alterFileMaybe :: forall f a. Functor f
               => FsPath
               -> (FsTreeError -> f (Maybe (FsTree a))) -- ^ Action on error
               -> f (Maybe a)                           -- ^ If file does not exist
               -> (a -> f (Maybe a))                    -- ^ If file exists
               -> (FsTree a -> f (FsTree a))
alterFileMaybe p onErr onNotExists onExists = alterF p onErr' f
  where
    onErr' :: FsTreeError -> f (Maybe (FsTree a))
    onErr' (FsMissing _ (_ :| [])) = fmap File <$> onNotExists
    onErr' err                     = onErr err

    f :: FsTree a -> f (Maybe (FsTree a))
    f (File   a) = fmap File <$> onExists a
    f (Folder _) = onErr $ FsExpectedFile p

alterFile :: forall f a. Functor f
          => FsPath
          -> (FsTreeError -> f (FsTree a)) -- ^ Action on error
          -> f a                           -- ^ If file does not exist
          -> (a -> f a)                    -- ^ If file exists
          -> (FsTree a -> f (FsTree a))
alterFile p onErr onNotExists onExists =
    alterFileMaybe p (fmap Just . onErr) (fmap Just onNotExists)
      (fmap Just . onExists)


{-------------------------------------------------------------------------------
  Construction
-------------------------------------------------------------------------------}

empty :: FsTree a
empty = Folder M.empty

{-------------------------------------------------------------------------------
  Auxiliary: paths
-------------------------------------------------------------------------------}

pathLast :: HasCallStack => FsPath -> Text
pathLast fp = case fsPathSplit fp of
                Nothing     -> error "pathLast: empty path"
                Just (_, p) -> p

pathInits :: FsPath -> [FsPath]
pathInits = reverse . go
  where
    go :: FsPath -> [FsPath]
    go fp = fp : case fsPathSplit fp of
                   Nothing       -> []
                   Just (fp', _) -> go fp'

{-------------------------------------------------------------------------------
  Indexing
-------------------------------------------------------------------------------}

-- | Index the FsTree by the given FsPath.
index :: FsPath -> FsTree a -> Either FsTreeError (FsTree a)
index fp = getConst . alterF fp (Const . Left) (Const . Right)

getFile :: FsPath -> FsTree a -> Either FsTreeError a
getFile fp =
    getConst . alterFile fp (Const . Left) errNotExist (Const . Right)
  where
    errNotExist = Const . Left $ FsMissing fp (pathLast fp :| [])

getDir :: FsPath -> FsTree a -> Either FsTreeError (Folder a)
getDir fp =
    getConst . alterDir fp (Const . Left) errNotExist (Const . Right)
  where
    errNotExist = Const . Left $ FsMissing fp (pathLast fp :| [])

{-------------------------------------------------------------------------------
  Specific file system functions
-------------------------------------------------------------------------------}

-- | Open a file: create it if necessary or throw an error if it existed
-- already wile we were supposed to create it from scratch (when passed
-- 'MustBeNew').
openFile :: Monoid a
         => FsPath -> AllowExisting -> FsTree a -> Either FsTreeError (FsTree a)
openFile fp ex = alterFile fp Left (Right mempty) $ \a -> case ex of
    AllowExisting -> Right a
    MustBeNew     -> Left (FsExists fp)

-- | Replace the contents of the specified file (which must exist)
replace :: FsPath -> a -> FsTree a -> Either FsTreeError (FsTree a)
replace fp new =
    alterFile fp Left errNotExist (\_old -> Right new)
  where
    errNotExist = Left (FsMissing fp (pathLast fp :| []))

-- | Create a directory if it does not already exist
createDirIfMissing :: FsPath -> FsTree a -> Either FsTreeError (FsTree a)
createDirIfMissing fp = alterDir fp Left (Right M.empty) Right

-- | Create a directory and its parents if they do not already exist
createDirWithParents :: FsPath -> FsTree a -> Either FsTreeError (FsTree a)
createDirWithParents fp =
      -- Report full path in the error, not the prefix at the point of failure
      either (Left . setFsTreeErrorPath fp) Right
    . repeatedlyM createDirIfMissing (pathInits fp)
  where
    repeatedlyM :: Monad m => (a -> b -> m b) -> ([a] -> b -> m b)
    repeatedlyM = flip . foldlM' . flip

    foldlM' :: forall m a b. Monad m => (b -> a -> m b) -> b -> [a] -> m b
    foldlM' f = go
      where
        go :: b -> [a] -> m b
        go !acc []     = return acc
        go !acc (x:xs) = f acc x >>= \acc' -> go acc' xs

-- | Remove a directory (which must exist) and its contents
removeDirRecursive :: FsPath -> FsTree a -> Either FsTreeError (FsTree a)
removeDirRecursive fp =
    alterDirMaybe fp Left errNotExist (const (Right Nothing))
  where
    errNotExist = Left (FsMissing fp (pathLast fp :| []))

-- | Remove a file (which must exist)
removeFile :: FsPath -> FsTree a -> Either FsTreeError (FsTree a)
removeFile fp =
    alterFileMaybe fp Left errNotExist (const (Right Nothing))
  where
    errNotExist = Left (FsMissing fp (pathLast fp :| []))

-- | Rename the file (which must exist) from the first path to the second
-- path. If there is already a file at the latter path, it is replaced by the
-- new one.
renameFile :: FsPath -> FsPath -> FsTree a -> Either FsTreeError (FsTree a)
renameFile fpOld fpNew tree = do
    oldF  <- getFile fpOld tree
    -- Remove the old file
    tree' <- removeFile fpOld tree
    -- Overwrite the new file with the old one
    alterFile fpNew Left (Right oldF) (const (Right oldF)) tree'

{-------------------------------------------------------------------------------
  Path-listing
-------------------------------------------------------------------------------}

-- Find all the file paths reachable from fp. Similar to Unix's @find@.
--
-- The initial path will be prepended to the each item in the resulting list of
-- paths.
--
-- For instance, given the following file system, say @fs@:
--
-- > usr
-- >  |-- local
-- >        |-- bin
--
-- find ["usr"] fs will return:
--
-- > [usr, usr/local, usr/local/bin]
--
-- find ["usr", "local"] fs will return:
--
-- > [usr/local, usr/local/bin]
--
-- See the unit tests in @Test.Ouroboros.Storage.FsTree@ for additional
-- examples.
--
-- If the given file system path does not exist, a (Left FsMissing{}) is
-- returned.
find :: forall a . FsPath -> FsTree a -> Either FsTreeError [FsPath]
find fp fs = fmap (appendStartingDir . findTree) $ getDir fp fs
  where
    appendStartingDir :: [[Text]] -> [FsPath]
    appendStartingDir fps = fmap fsPathFromList
                          $ fmap (fsPathToList fp <>)
                          $ []: fps

    findTree :: Folder a -> [[Text]]
    findTree folder = concat
                    $ fmap appendFileNameAndFind
                    $ M.toList folder
      where
        appendFileNameAndFind :: (Text, FsTree a) -> [[Text]]
        appendFileNameAndFind (fileName, t) =
            [fileName] : (fmap ([fileName] <>) $ findFsTree t)

        findFsTree :: FsTree a -> [[Text]]
        findFsTree (File   _      ) = []
        findFsTree (Folder folder') = findTree folder'

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

pretty :: forall a. (a -> String) -> FsTree a -> String
pretty f = drawTree . fmap renderNode . toTree
  where
    renderNode :: (Text, Maybe a) -> String
    renderNode (fp, Nothing) = Text.unpack fp
    renderNode (fp, Just a)  = Text.unpack fp ++ ": " ++ f a

-- | Translate to a tree
toTree :: FsTree a -> Tree (Text, Maybe a)
toTree = \case
    File   _ -> error "toTree: root must be directory"
    Folder m -> Node ("/", Nothing) $ map go (M.toList m)
  where
    go :: (Text, FsTree a) -> Tree (Text, Maybe a)
    go (parent, File   a) = Node (parent, Just a) []
    go (parent, Folder m) = Node (parent, Nothing) $ map go (M.toList m)