packages feed

sizes-2.4.4: test/Spec.hs

{-# LANGUAGE TemplateHaskell #-}

module Main where

import Control.Exception (bracket)
import Control.Monad (unless)
import qualified Data.List as List
import Hedgehog
import qualified Hedgehog.Gen as Gen
import qualified Hedgehog.Range as Range
import Sizes (
    EntryInfo (..),
    combineEntryResults,
    crossesFileSystemBoundary,
    emptyReportEntries,
    humanReadable,
    reportEntriesToList,
 )
import System.Directory (createDirectory, getTemporaryDirectory, removeFile, removePathForcibly)
import System.Exit (ExitCode (ExitSuccess), exitFailure)
import System.FilePath ((</>))
import System.IO (hClose, openTempFile)
import System.Posix.Types (DeviceID)
import System.Process (readProcessWithExitCode)

main :: IO ()
main = do
    passed <- checkParallel $$(discover)
    unless passed exitFailure

-- | Allocate a unique scratch directory and remove it after the test.
withScratchDirectory :: (FilePath -> IO a) -> IO a
withScratchDirectory = bracket create removePathForcibly
  where
    create = do
        temporaryRoot <- getTemporaryDirectory
        (path, handle) <- openTempFile temporaryRoot "sizes-test"
        hClose handle
        removeFile path
        createDirectory path
        pure path

-- | Generate an EntryInfo with random count and size.
genEntryInfo :: Gen EntryInfo
genEntryInfo = do
    count <- Gen.int (Range.linear 0 10000)
    size <- Gen.int (Range.linear 0 (1024 * 1024 * 1024))
    isDir <- Gen.bool
    pure $ mempty{_entryCount = count, _entryAllocSize = size, _entryIsDir = isDir}

-- humanReadable: bytes
prop_humanReadable_bytes :: Property
prop_humanReadable_bytes =
    withTests 1 . property $
        humanReadable 500 1024 === "500b"

-- humanReadable: kilobytes
prop_humanReadable_kilobytes :: Property
prop_humanReadable_kilobytes =
    withTests 1 . property $
        humanReadable 2048 1024 === "2K"

-- humanReadable: megabytes (base-2)
prop_humanReadable_megabytes :: Property
prop_humanReadable_megabytes =
    withTests 1 . property $
        humanReadable (5 * 1024 * 1024) 1024 === "5.0M"

-- humanReadable: gigabytes (base-2)
prop_humanReadable_gigabytes :: Property
prop_humanReadable_gigabytes =
    withTests 1 . property $
        humanReadable (2 * 1024 * 1024 * 1024) 1024 === "2.00G"

-- humanReadable: megabytes (base-10)
prop_humanReadable_base10 :: Property
prop_humanReadable_base10 =
    withTests 1 . property $
        humanReadable 5000000 1000 === "5.0M"

-- Semigroup: count accumulation is associative
prop_semigroup_count_associative :: Property
prop_semigroup_count_associative = property $ do
    a <- forAll genEntryInfo
    b <- forAll genEntryInfo
    c <- forAll genEntryInfo
    _entryCount ((a <> b) <> c) === _entryCount (a <> (b <> c))

-- Semigroup: size accumulation is associative
prop_semigroup_size_associative :: Property
prop_semigroup_size_associative = property $ do
    a <- forAll genEntryInfo
    b <- forAll genEntryInfo
    c <- forAll genEntryInfo
    _entryAllocSize ((a <> b) <> c) === _entryAllocSize (a <> (b <> c))

-- Monoid: right identity preserves count
prop_monoid_right_identity_count :: Property
prop_monoid_right_identity_count = property $ do
    e <- forAll genEntryInfo
    _entryCount (e <> mempty) === _entryCount e

-- Monoid: right identity preserves size
prop_monoid_right_identity_size :: Property
prop_monoid_right_identity_size = property $ do
    e <- forAll genEntryInfo
    _entryAllocSize (e <> mempty) === _entryAllocSize e

-- Wide directory aggregation must not require stack proportional to entry count.
prop_wide_directory_aggregation_stack_safe :: Property
prop_wide_directory_aggregation_stack_safe =
    withTests 1 . property $ do
        let entryCount = 200000
            leaf = mempty{_entryCount = 1, _entryAllocSize = 2}
            step aggregate _ =
                combineEntryResults
                    True
                    aggregate
                    (leaf, emptyReportEntries)
            (total, reports) =
                List.foldl'
                    step
                    (mempty, emptyReportEntries)
                    [1 .. entryCount]
        _entryCount total === entryCount
        _entryAllocSize total === 2 * entryCount
        length (reportEntriesToList reports) === entryCount

-- Report accumulation preserves sibling/preorder content and drops it past depth.
prop_report_entries_preserve_preorder :: Property
prop_report_entries_preserve_preorder =
    withTests 1 . property $ do
        let first = mempty{_entryCount = 1, _entryAllocSize = 10}
            child = mempty{_entryCount = 2, _entryAllocSize = 20}
            grandchild = mempty{_entryCount = 3, _entryAllocSize = 30}
            firstResult =
                combineEntryResults
                    True
                    (mempty, emptyReportEntries)
                    (first, emptyReportEntries)
            (_, grandchildReports) =
                combineEntryResults
                    True
                    (mempty, emptyReportEntries)
                    (grandchild, emptyReportEntries)
            (_, retained) =
                combineEntryResults
                    True
                    firstResult
                    (child, grandchildReports)
            (_, dropped) =
                combineEntryResults
                    False
                    firstResult
                    (child, grandchildReports)
            entryIdentity entry = (_entryCount entry, _entryAllocSize entry)
        fmap entryIdentity (reportEntriesToList retained)
            === [(1, 10), (2, 20), (3, 30)]
        reportEntriesToList dropped === []

-- The packaged executable traverses a real nested tree in report preorder.
prop_cli_traverses_nested_directory :: Property
prop_cli_traverses_nested_directory =
    withTests 1 . property $ do
        (root, child, file, exitCode, stdout, stderr) <- evalIO $
            withScratchDirectory $ \root -> do
                let child = root </> "child"
                    file = child </> "file"
                createDirectory child
                writeFile file "abc"
                (exitCode, stdout, stderr) <-
                    readProcessWithExitCode
                        "sizes"
                        ["-j1", "-a", "-s", "-d3", root]
                        ""
                pure (root, child, file, exitCode, stdout, stderr)
        annotate stderr
        exitCode === ExitSuccess
        fmap (last . words) (lines stdout)
            === [root ++ "/", child ++ "/", file]

-- A single -x pattern excludes matching paths but keeps the rest.
prop_cli_single_exclude_filters :: Property
prop_cli_single_exclude_filters =
    withTests 1 . property $ do
        (exitCode, stdout, stderr) <- evalIO $
            withScratchDirectory $ \root -> do
                let keepDir = root </> "keep"
                    dropDir = root </> "drop-me"
                mapM_
                    (\dir -> createDirectory dir >> writeFile (dir </> "marker") "x")
                    [keepDir, dropDir]
                readProcessWithExitCode
                    "sizes"
                    ["-j1", "-a", "-s", "-d3", "-x", "drop-me", root]
                    ""
        annotate stderr
        exitCode === ExitSuccess
        let reported = map (last . words) (lines stdout)
        annotate (unlines reported)
        assert $ any (List.isSuffixOf "keep/marker") reported
        assert $ not $ any (List.isSuffixOf "drop-me/marker") reported

-- Repeated -x flags accumulate: every provided pattern excludes its matches,
-- not just the last one.
prop_cli_multiple_excludes_accumulate :: Property
prop_cli_multiple_excludes_accumulate =
    withTests 1 . property $ do
        (exitCode, stdout, stderr) <- evalIO $
            withScratchDirectory $ \root -> do
                let keepDir = root </> "keep"
                    firstDir = root </> "drop-first"
                    secondDir = root </> "drop-second"
                mapM_
                    (\dir -> createDirectory dir >> writeFile (dir </> "marker") "x")
                    [keepDir, firstDir, secondDir]
                readProcessWithExitCode
                    "sizes"
                    [ "-j1"
                    , "-a"
                    , "-s"
                    , "-d3"
                    , "-x"
                    , "drop-first"
                    , "-x"
                    , "drop-second"
                    , root
                    ]
                    ""
        annotate stderr
        exitCode === ExitSuccess
        let reported = map (last . words) (lines stdout)
        annotate (unlines reported)
        -- The first -x pattern must still exclude: both flags accumulate
        -- rather than the last one winning.
        assert $ any (List.isSuffixOf "keep/marker") reported
        assert $ not $ any (List.isSuffixOf "drop-first/marker") reported
        assert $ not $ any (List.isSuffixOf "drop-second/marker") reported

-- humanReadable always returns a non-empty string
prop_humanReadable_nonempty :: Property
prop_humanReadable_nonempty = property $ do
    x <- forAll $ Gen.int (Range.linear 0 (1024 * 1024 * 1024 * 1024))
    d <- forAll $ Gen.element [1000, 1024]
    assert $ not (null (humanReadable x d))

-- | Generate an arbitrary device ID.
genDevice :: Gen DeviceID
genDevice = fromIntegral <$> Gen.int (Range.linear 0 100000)

-- crossesFileSystemBoundary: with the option disabled, nothing is ever a crossing
prop_oneFS_disabled_never_crosses :: Property
prop_oneFS_disabled_never_crosses = property $ do
    root <- forAll $ Gen.maybe genDevice
    dev <- forAll genDevice
    crossesFileSystemBoundary False root dev === False

-- crossesFileSystemBoundary: the traversal root only establishes the boundary
prop_oneFS_root_establishes_boundary :: Property
prop_oneFS_root_establishes_boundary = property $ do
    dev <- forAll genDevice
    crossesFileSystemBoundary True Nothing dev === False

-- crossesFileSystemBoundary: same device as the root is not a crossing
prop_oneFS_same_device_stays :: Property
prop_oneFS_same_device_stays = property $ do
    dev <- forAll genDevice
    crossesFileSystemBoundary True (Just dev) dev === False

-- crossesFileSystemBoundary: a different device than the root is a crossing
prop_oneFS_different_device_crosses :: Property
prop_oneFS_different_device_crosses = property $ do
    root <- forAll genDevice
    delta <- forAll $ Gen.int (Range.linear 1 100000)
    let dev = root + fromIntegral delta
    crossesFileSystemBoundary True (Just root) dev === True