seihou-core-0.11.0.0: src/Seihou/Effect/FilesystemPure.hs
module Seihou.Effect.FilesystemPure
( runFilesystemPure,
PureFS (..),
emptyFS,
)
where
import Data.Generics.Labels ()
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Effectful.State.Static.Local (State, get, modify, put, runState)
import Seihou.Effect.Filesystem (ExclusiveCreateResult (..), Filesystem (..), PathEntry (..))
import Seihou.Prelude
-- | In-memory filesystem state for testing.
data PureFS = PureFS
{ files :: !(Map FilePath Text),
dirs :: !(Set FilePath),
links :: !(Map FilePath FilePath)
}
deriving stock (Eq, Generic, Show)
-- | An empty in-memory filesystem.
emptyFS :: PureFS
emptyFS = PureFS Map.empty Set.empty Map.empty
-- | Pure in-memory interpreter for the Filesystem effect.
-- Useful for testing without touching the real filesystem.
runFilesystemPure :: PureFS -> Eff (Filesystem : es) a -> Eff es (a, PureFS)
runFilesystemPure initial = reinterpret (runState initial) handler
where
handler :: (State PureFS :> es') => EffectHandler Filesystem es'
handler _ = \case
CreateFileExclusive path content -> do
fs <- get @PureFS
if Map.member path (fs ^. #files) || Map.member path (fs ^. #links) || Set.member path (fs ^. #dirs)
then pure ExclusiveOccupied
else modify @PureFS (#files %~ Map.insert path content) >> pure ExclusiveCreated
InspectPath path -> do
fs <- get @PureFS
pure $ case Map.lookup path (fs ^. #links) of
Just target -> EntrySymlink target
Nothing
| Map.member path (fs ^. #files) -> EntryFile
| Set.member path (fs ^. #dirs) -> EntryDirectory
| otherwise -> EntryMissing
CreateDirectoryLink target path -> do
fs <- get @PureFS
if Map.member path (fs ^. #links) || Map.member path (fs ^. #files) || Set.member path (fs ^. #dirs)
then error ("runFilesystemPure: destination exists: " <> path)
else modify @PureFS (#links %~ Map.insert path target)
RemoveLink path -> modify @PureFS (#links %~ Map.delete path)
NormalizePath path -> pure path
ReadFileText path -> do
fs <- get @PureFS
case Map.lookup path (fs ^. #files) of
Just content -> pure content
Nothing -> error ("runFilesystemPure: file not found: " <> path)
WriteFileText path content -> do
modify @PureFS (\fs -> fs & #files . at path ?~ content)
CopyFile src dest -> do
fs <- get @PureFS
case Map.lookup src (fs ^. #files) of
Just content ->
put (fs & #files . at dest ?~ content)
Nothing -> error ("runFilesystemPure: source file not found: " <> src)
ListDirectory path -> do
fs <- get @PureFS
let prefix = if null path then "" else path <> "/"
filesInDir =
[ drop (length prefix) fp
| fp <- Map.keys (fs ^. #files) <> Map.keys (fs ^. #links),
isDirectChild prefix fp
]
dirsInDir =
[ drop (length prefix) d
| d <- Set.toList (fs ^. #dirs),
isDirectChild prefix d
]
pure (filesInDir <> dirsInDir)
CreateDirectoryIfMissing _parents path -> do
modify @PureFS (\fs -> fs & #dirs %~ Set.insert path)
DoesFileExist path -> do
fs <- get @PureFS
pure (Map.member path (fs ^. #files))
DoesDirectoryExist path -> do
fs <- get @PureFS
pure (Set.member path (fs ^. #dirs))
GetCurrentDirectory -> pure "/pure-fs"
RemoveFile path -> do
modify @PureFS (\fs -> fs & #files . at path .~ Nothing)
RemoveDirectoryIfEmpty path -> do
fs <- get @PureFS
let hasChildren =
any (\fp -> (path <> "/") `isPrefixOfPath` fp) (Map.keys (fs ^. #files))
|| any (\d -> (path <> "/") `isPrefixOfPath` d) (Set.toList (fs ^. #dirs))
if hasChildren
then pure ()
else modify @PureFS (\fs' -> fs' & #dirs %~ Set.delete path)
RenamePath src dest -> do
modify @PureFS (renameInPureFS src dest)
RemoveDirectoryRecursive path -> do
modify @PureFS (removeRecursivelyFromPureFS path)
-- | Check if a path is a direct child of a prefix directory.
isDirectChild :: String -> String -> Bool
isDirectChild "" fp = '/' `notElem` fp
isDirectChild prefix fp =
prefix `isPrefixOfPath` fp
&& '/' `notElem` drop (length prefix) fp
isPrefixOfPath :: String -> String -> Bool
isPrefixOfPath [] _ = True
isPrefixOfPath _ [] = False
isPrefixOfPath (x : xs) (y : ys)
| x == y = isPrefixOfPath xs ys
| otherwise = False
-- | Rename @src@ to @dest@ in the pure filesystem. Handles two cases:
--
-- * A single file at exactly @src@ (rename the entry).
-- * A directory at @src@ (rewrite every key under @src/@ to @dest/@,
-- and rewrite the @dirs@ set similarly).
--
-- If @src@ does not exist as either a file or a directory prefix, this
-- is a no-op (mirrors the in-memory model; the IO interpreter would
-- raise an exception, which is expected behavior at the engine layer
-- when callers have already validated existence).
renameInPureFS :: FilePath -> FilePath -> PureFS -> PureFS
renameInPureFS src dest fs =
let renamedFiles = Map.mapKeys (renameKey src dest) (fs ^. #files)
renamedDirs = Set.map (renameKey src dest) (fs ^. #dirs)
in fs
& #files
.~ renamedFiles
& #dirs
.~ renamedDirs
& #links
%~ Map.mapKeys (renameKey src dest)
where
renameKey s d k
| k == s = d
| (s <> "/") `isPrefixOfPath` k = d <> drop (length s) k
| otherwise = k
-- | Recursively delete every entry under @path@ (and @path@ itself,
-- treated as a directory).
removeRecursivelyFromPureFS :: FilePath -> PureFS -> PureFS
removeRecursivelyFromPureFS path fs =
let prefix = path <> "/"
keepFile k = k /= path && not (prefix `isPrefixOfPath` k)
keepDir d = d /= path && not (prefix `isPrefixOfPath` d)
in fs
& #files
%~ Map.filterWithKey (\k _ -> keepFile k)
& #links
%~ Map.filterWithKey (\k _ -> keepFile k)
& #dirs
%~ Set.filter keepDir