packages feed

takedouble-0.0.2.0: src/Takedouble.hs

module Takedouble (findPossibleDuplicates, getFileNames, checkFullDuplicates, File (..)) where

import Control.Monad.Extra (filterM, forM, ifM)
import qualified Data.ByteString as BS
import Data.List (group, sort, sortOn)
import Data.List.Extra (groupOn)
import System.Directory (doesDirectoryExist, doesPathExist, listDirectory)
import System.FilePattern
import System.FilePath.Posix ((</>))
import System.IO
  ( Handle,
    IOMode (ReadMode),
    SeekMode (SeekFromEnd),
    hFileSize,
    hSeek,
    withFile,
  )
import System.Posix.Files
  ( getFileStatus,
    isDirectory,
    isRegularFile,
  )

data File = File
  { filepath :: FilePath,
    filesize :: Integer,
    firstchunk :: BS.ByteString,
    lastchunk :: BS.ByteString
  }

-- | File needs a custom Eq instance so filepath is not part of the comparison
instance Eq File where
  (File _ fs1 firstchunk1 lastchunk1) == (File _ fs2 firstchunk2 lastchunk2) =
    (fs1, firstchunk1, lastchunk1) == (fs2, firstchunk2, lastchunk2)

instance Ord File where
  compare (File _ fs1 firstchunk1 lastchunk1) (File _ fs2 firstchunk2 lastchunk2) =
    compare (fs1, firstchunk1, lastchunk1) (fs2, firstchunk2, lastchunk2)

instance Show File where
  show (File fp _ _ _) = show fp

-- | (hopefully) lazy comparison of files by size, first, and last chunk.
findPossibleDuplicates :: [FilePath] -> Maybe String -> IO [[File]]
findPossibleDuplicates filenames glob = do
  files <- mapM loadFile filteredFilenames
  pure $ filter (\x -> 1 < length x) $ group (sort files)
  where filteredFilenames =
          case glob of
            Nothing -> filenames
            Just g -> filter (\fn -> not (g ?== fn)) filenames

checkFullDuplicates :: [FilePath] -> IO [[FilePath]]
checkFullDuplicates fps = do
  allContents <- mapM BS.readFile fps
  let pairs = zip fps allContents
      sorted = sortOn snd pairs
      dups = filter (\x -> length x > 1) $ groupOn snd sorted
      res = (fst <$>) `fmap` dups
  pure res

loadFile :: FilePath -> IO File
loadFile fp = do
  (fsize, firstchunk, lastchunk) <- withFile fp ReadMode getChunks
  pure $ File fp fsize firstchunk lastchunk

-- | chunkSize is 4096 so NVMe drives will be especially happy
chunkSize :: Int
chunkSize = 4 * 1024

-- | fetch the file size and first and last 4k chunks of the file
getChunks :: Handle -> IO (Integer, BS.ByteString, BS.ByteString)
getChunks h = do
  fsize <- hFileSize h
  begin <- BS.hGet h chunkSize
  hSeek h SeekFromEnd (fromIntegral chunkSize) -- [TODO] needs to be read from filesize - filesize % 4096
  end <- BS.hGet h chunkSize
  pure (fsize, begin, end)

-- | get all the FilePath values
getFileNames :: FilePath -> IO [FilePath]
getFileNames curDir = do
  names <- listDirectory curDir
  let names' = (curDir </>) <$> names
  names'' <- filterM saneFile names'
  files <- forM names'' $ \path -> do
    let path' = curDir </> path
    exists <- doesDirectoryExist path'
    if exists
      then getFileNames path'
      else pure $ pure path'
  pure $ concat files

-- | Check if the file exists, and is not a fifo or broken symbolic link
saneFile :: FilePath -> IO Bool
saneFile fp =
  ifM
    (doesPathExist fp)
    ( do
        stat <- getFileStatus fp
        pure $ isRegularFile stat || isDirectory stat
    )
    (pure False)