packages feed

vcs-ignore-0.1.0.0: test/Data/VCS/Ignore/Git/Internal/TraversalSpec.hs

{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}

module Data.VCS.Ignore.Git.Internal.TraversalSpec (spec) where

import Control.Exception (
    IOException,
    bracket_,
    try,
 )
import Control.Monad (when)
import Data.IORef (
    modifyIORef',
    newIORef,
    readIORef,
 )
import qualified Data.List as L
import Data.VCS.Ignore.Git.Internal.Repository (
    GitRepository,
    openGitRepository,
 )
import Data.VCS.Ignore.Git.Internal.Traversal
import Data.VCS.Ignore.Types
import System.Directory (
    createDirectoryIfMissing,
    createDirectoryLink,
    removeDirectoryLink,
    removeDirectoryRecursive,
    renameDirectory,
 )
import System.Environment (
    lookupEnv,
    setEnv,
    unsetEnv,
 )
import System.FilePath (
    takeDirectory,
    (</>),
 )
import System.IO.Temp (withSystemTempDirectory)
import Test.Hspec

spec :: Spec
spec = do
    describe "foldRepo" $ do
        it "visits entries in depth-first preorder without emitting the root"
            . withRepository
            $ \root repository -> do
                writeText (root </> "directory" </> "child.txt") "child"
                writeText (root </> "file.txt") "file"

                result <- foldRepo repository [] collectEntry

                entries <- completedValue result
                let paths = entryPath <$> reverse entries
                paths `shouldNotContain` [""]
                paths `shouldMatchList` ["directory", "directory" </> "child.txt", "file.txt"]
                reverse entries
                    `shouldMatchList` [ Entry "directory" Directory
                                      , Entry ("directory" </> "child.txt") RegularFile
                                      , Entry "file.txt" RegularFile
                                      ]
                L.elemIndex ("directory" </> "child.txt") paths
                    `shouldBe` ((+ 1) <$> L.elemIndex "directory" paths)

        it "automatically prunes ignored directories and repository metadata"
            . withRepository
            $ \root repository -> do
                writeText (root </> ".gitignore") "ignored/\n"
                writeText (root </> "ignored" </> "hidden.txt") "hidden"
                writeText (root </> "visible.txt") "visible"

                entries <- listRepo repository

                entryPath <$> entries `shouldMatchList` [".gitignore", "visible.txt"]

        it "loads a visible directory's rules before visiting its children"
            . withRepository
            $ \root repository -> do
                writeText (root </> "nested" </> ".gitignore") "*.tmp\n"
                writeText (root </> "nested" </> "drop.tmp") "ignored"
                writeText (root </> "nested" </> "keep.txt") "visible"

                entries <- listRepo repository

                entryPath <$> entries
                    `shouldMatchList` [ "nested"
                                      , "nested" </> ".gitignore"
                                      , "nested" </> "keep.txt"
                                      ]

        it "honours caller pruning without visiting descendants"
            . withRepository
            $ \root repository -> do
                writeText (root </> "pruned" </> "child.txt") "child"
                writeText (root </> "visible" </> "child.txt") "child"

                result <- foldRepo repository [] $ \entries entry ->
                    pure
                        ( entry : entries
                        , if entryPath entry == "pruned" then Prune else Continue
                        )

                entries <- reverse <$> completedValue result
                entryPath <$> entries
                    `shouldMatchList` ["pruned", "visible", "visible" </> "child.txt"]

        it "treats Prune on a file as Continue"
            . withRepository
            $ \root repository -> do
                writeText (root </> "first.txt") "first"
                writeText (root </> "second.txt") "second"

                result <- foldRepo repository [] $ \entries entry ->
                    pure (entryPath entry : entries, Prune)

                paths <- reverse <$> completedValue result
                paths `shouldMatchList` ["first.txt", "second.txt"]

        it "does not follow a directory replaced by a symlink in the callback"
            . withRepository
            $ \root repository ->
                withSystemTempDirectory "vcs-ignore-replacement" $ \outside -> do
                    let directory = root </> "directory"
                        moved = root </> "moved"
                    writeText (directory </> "inside.txt") "inside"
                    writeText (outside </> "outside.txt") "outside"

                    result <- try @IOException $ createDirectoryLink outside (root </> "probe")
                    case result of
                        Left _ -> pendingWith "directory symbolic links are unavailable"
                        Right _ -> do
                            removeDirectoryLink $ root </> "probe"
                            entries <- listWithReplacement repository directory moved outside
                            entryPath <$> entries `shouldBe` ["directory"]

        it "skips a directory removed by the callback"
            . withRepository
            $ \root repository -> do
                let directory = root </> "directory"
                writeText (directory </> "inside.txt") "inside"

                result <- foldRepo repository [] $ \entries entry -> do
                    when (entryPath entry == "directory") $
                        removeDirectoryRecursive directory
                    pure (entry : entries, Continue)

                reverse <$> completedValue result
                    `shouldReturn` [Entry "directory" Directory]

        it "propagates Stop through all remaining traversal levels"
            . withRepository
            $ \root repository -> do
                writeText (root </> "one" </> "child.txt") "child"
                writeText (root </> "two" </> "child.txt") "child"

                result <- foldRepo repository (0 :: Int) $ \count _ ->
                    pure (count + 1, Stop)

                result `shouldBe` WalkStopped 1

        it "forces the initial accumulator even for an empty repository"
            . withRepository
            $ \_ repository ->
                foldRepo repository (error "strict accumulator" :: Int) keepWalking
                    `shouldThrow` errorCall "strict accumulator"

        it "propagates callback failures"
            . withRepository
            $ \root repository -> do
                writeText (root </> "entry.txt") "entry"

                walkRepo repository (const . ioError $ userError "callback failure")
                    `shouldThrow` anyIOException

    describe "repository traversal wrappers" $ do
        it "does not follow directory symbolic links"
            . withRepository
            $ \root repository ->
                withSystemTempDirectory "vcs-ignore-target" $ \target -> do
                    writeText (target </> "outside.txt") "outside"
                    linkResult <- try @IOException $ createDirectoryLink target (root </> "link")
                    case linkResult of
                        Left _ -> pendingWith "directory symbolic links are unavailable"
                        Right _ -> do
                            entries <- listRepo repository
                            entries `shouldBe` [Entry "link" SymbolicLink]

        it "walkRepo reports normal completion"
            . withRepository
            $ \root repository -> do
                writeText (root </> "entry.txt") "entry"
                result <- walkRepo repository (const $ pure Continue)
                result `shouldBe` WalkCompleted ()

        it "forRepo_ performs an effect for every visible entry"
            . withRepository
            $ \root repository -> do
                writeText (root </> "first.txt") "first"
                writeText (root </> "second.txt") "second"
                visited <- newIORef []

                forRepo_ repository $ \entry ->
                    modifyIORef' visited (entryPath entry :)

                paths <- readIORef visited
                paths `shouldMatchList` ["first.txt", "second.txt"]

collectEntry :: [Entry] -> Entry -> IO ([Entry], WalkAction)
collectEntry entries entry = pure (entry : entries, Continue)

keepWalking :: Int -> Entry -> IO (Int, WalkAction)
keepWalking state _ = pure (state, Continue)

completedValue :: WalkResult a -> IO a
completedValue (WalkCompleted value) = pure value
completedValue (WalkStopped _) = do
    expectationFailure "expected completed traversal"
    error "unreachable"

listWithReplacement :: GitRepository -> FilePath -> FilePath -> FilePath -> IO [Entry]
listWithReplacement repository directory moved outside = do
    result <- foldRepo repository [] $ \entries entry -> do
        when (entryPath entry == "directory") $ do
            renameDirectory directory moved
            createDirectoryLink outside directory
        pure (entry : entries, Continue)
    reverse <$> completedValue result

withRepository :: (FilePath -> GitRepository -> IO a) -> IO a
withRepository action =
    withSystemTempDirectory "vcs-ignore-traversal" $ \sandbox ->
        withEnvironment "XDG_CONFIG_HOME" (sandbox </> "xdg") $ do
            let root = sandbox </> "repository"
            createDirectoryIfMissing True $ root </> ".git" </> "info"
            repository <- openGitRepository root
            action root repository

withEnvironment :: String -> String -> IO a -> IO a
withEnvironment name value action = do
    original <- lookupEnv name
    bracket_ (setEnv name value) (restore original) action
  where
    restore Nothing = unsetEnv name
    restore (Just originalValue) = setEnv name originalValue

writeText :: FilePath -> String -> IO ()
writeText path content = do
    createDirectoryIfMissing True $ takeDirectory path
    writeFile path content