clod-0.1.13: test/Clod/FileSystem/ChecksumsSpec.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleContexts #-}
-- |
-- Module : Clod.FileSystem.ChecksumsSpec
-- Description : Tests for checksum operations
-- Copyright : (c) Fuzz Leonard, 2025
-- License : MIT
-- Maintainer : ink@fuzz.ink
-- Stability : experimental
--
-- This module contains tests for checksum-based file tracking functionality.
module Clod.FileSystem.ChecksumsSpec (spec) where
import Test.Hspec
import System.FilePath
import System.IO.Temp (withSystemTempDirectory)
import System.Directory (createDirectoryIfMissing, doesFileExist)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BC
import qualified Data.Map.Strict as Map
import Data.Time.Clock (getCurrentTime)
import Data.List (isInfixOf)
import Clod.Types (ClodDatabase(..), FileEntry(..), OptimizedName(..), Checksum(..),
runClodM, fileReadCap, liftIO, IgnorePattern(..))
import Clod.TestHelpers (defaultTestConfig)
import qualified Clod.IgnorePatterns
import Clod.FileSystem.Checksums
( calculateChecksum
, checksumFile
, initializeDatabase
, loadDatabase
, saveDatabase
, updateDatabase
, detectFileChanges
, FileStatus(..)
)
-- | Test specification for FileSystem.Checksums module
spec :: Spec
spec = do
checksumCalculationSpec
databaseOperationsSpec
changeDetectionSpec
-- | Tests for checksum calculation
checksumCalculationSpec :: Spec
checksumCalculationSpec = describe "Checksum calculation" $ do
it "produces consistent checksums for identical content" $ do
let content1 = "Test content"
let content2 = "Test content"
calculateChecksum (BC.pack content1) `shouldBe` calculateChecksum (BC.pack content2)
it "produces different checksums for different content" $ do
let content1 = "Test content"
let content2 = "Different content"
calculateChecksum (BC.pack content1) `shouldNotBe` calculateChecksum (BC.pack content2)
it "refuses to checksum binary files" $ do
withSystemTempDirectory "clod-test" $ \tmpDir -> do
-- Create a binary file
let binaryFile = tmpDir </> "test.bin"
BS.writeFile binaryFile $ BS.pack [0x00, 0x01, 0x02, 0x03, 0xFF, 0xFE, 0xFD]
-- Create a config and capabilities
let config = defaultTestConfig tmpDir
readCap = fileReadCap [tmpDir]
-- Attempt to calculate checksum should fail
result <- runClodM config $ checksumFile readCap binaryFile
-- Should fail with appropriate error
case result of
Left err -> "Cannot checksum binary" `shouldSatisfy` (\msg -> msg `isInfixOf` show err)
Right _ -> expectationFailure "Should not be able to checksum binary files"
it "can calculate a file checksum" $ do
withSystemTempDirectory "clod-test" $ \tmpDir -> do
-- Create a text file
let textFile = tmpDir </> "test.txt"
writeFile textFile "Test content for checksumming"
-- Create a config and capabilities
let config = defaultTestConfig tmpDir
readCap = fileReadCap [tmpDir]
-- Calculate checksum
result <- runClodM config $ checksumFile readCap textFile
-- Should succeed and match direct calculation
case result of
Left err -> expectationFailure $ "Failed to calculate checksum: " ++ show err
Right checksum -> do
content <- BS.readFile textFile
checksum `shouldBe` calculateChecksum content
-- | Tests for database operations
databaseOperationsSpec :: Spec
databaseOperationsSpec = describe "Database operations" $ do
it "correctly initializes an empty database" $ do
withSystemTempDirectory "clod-test" $ \tmpDir -> do
let config = defaultTestConfig tmpDir
result <- runClodM config $ initializeDatabase
case result of
Left err -> expectationFailure $ "Failed to initialize database: " ++ show err
Right db -> Map.size (dbFiles db) `shouldBe` 0
it "successfully saves and loads a database" $ do
withSystemTempDirectory "clod-test" $ \tmpDir -> do
-- Create a config and directories
let config = defaultTestConfig tmpDir
dbPath = tmpDir </> ".clod" </> "db.dhall"
createDirectoryIfMissing True (tmpDir </> ".clod")
-- Create time and entries for testing
currentTime <- getCurrentTime
let checksum = Checksum "abc123"
optName = OptimizedName "test.txt"
_entry = FileEntry "test.txt" checksum currentTime optName -- Used for reference but not directly
-- Create a database
result <- runClodM config $ do
-- Initialize database
db <- initializeDatabase
-- Add an entry
let updatedDb = updateDatabase db "test.txt" checksum currentTime optName
-- Save database
saveDatabase dbPath updatedDb
-- Load database and return
loadDatabase dbPath
-- Check database contents
case result of
Left err -> expectationFailure $ "Database operation failed: " ++ show err
Right db -> do
-- Check that the database file was created
doesFileExist dbPath >>= (`shouldBe` True)
-- Now with proper Dhall parsing, we should have one file entry
Map.size (dbFiles db) `shouldBe` 1
-- Verify the entry is correct
Map.member "test.txt" (dbFiles db) `shouldBe` True
-- Get the entry and check its attributes
let entry = Map.lookup "test.txt" (dbFiles db)
case entry of
Nothing -> expectationFailure "Entry for test.txt not found in database"
Just e -> do
entryPath e `shouldBe` "test.txt"
entryChecksum e `shouldBe` checksum
-- | Tests for change detection
changeDetectionSpec :: Spec
changeDetectionSpec = describe "File change detection" $ do
it "respects ignore patterns from .gitignore and .clodignore" $ do
withSystemTempDirectory "clod-test" $ \tmpDir -> do
-- Create test files: normal text file, ignored text file
let normalFile = tmpDir </> "normal.txt"
ignoredFile = tmpDir </> "ignored.txt"
gitignoreFile = tmpDir </> ".gitignore"
clodignoreFile = tmpDir </> ".clodignore"
-- Write content to the files
writeFile normalFile "This is a normal text file"
writeFile ignoredFile "This file should be ignored"
-- Create .gitignore and .clodignore files
writeFile gitignoreFile "# Git ignore patterns\nignored.txt"
writeFile clodignoreFile "# Clod ignore patterns\n*.ignored"
-- Create a config that includes the ignore patterns
let config = defaultTestConfig tmpDir
readCap = fileReadCap [tmpDir]
-- Create an empty database
currentTime <- getCurrentTime
let emptyDb = ClodDatabase Map.empty Map.empty Nothing currentTime
-- First verify that without ignore patterns, both files are detected
result1 <- runClodM config $ do
detectFileChanges readCap emptyDb ["normal.txt", "ignored.txt"] tmpDir
-- Now create a test that directly uses matchesIgnorePattern
let ignorePatterns = [IgnorePattern "ignored.txt"]
-- Now check if the pattern would match our ignored file
let ignoredMatches = Clod.IgnorePatterns.matchesIgnorePattern ignorePatterns "ignored.txt"
normalMatches = Clod.IgnorePatterns.matchesIgnorePattern ignorePatterns "normal.txt"
-- Check the pattern matching (static test)
ignoredMatches `shouldBe` True -- "ignored.txt" should match the pattern
normalMatches `shouldBe` False -- "normal.txt" should not match the pattern
-- Verify that without special handling, both files are included
case result1 of
Left err -> expectationFailure $ "Change detection failed: " ++ show err
Right (changes, _) -> do
-- Without integration of ignore patterns directly in the test, both files will be processed
-- This test confirms the pattern matching logic works correctly for future integration
length changes `shouldBe` 2
-- Verify both files are present in the results
any (("normal.txt" ==) . fst) changes `shouldBe` True
any (("ignored.txt" ==) . fst) changes `shouldBe` True
-- Check that both files are marked as new
all ((== New) . snd) changes `shouldBe` True
it "ignores binary files during change detection" $ do
withSystemTempDirectory "clod-test" $ \tmpDir -> do
-- Create text and binary files
let textFile = tmpDir </> "text.txt"
binaryFile = tmpDir </> "binary.bin"
-- Write content to the files
writeFile textFile "This is a text file"
BS.writeFile binaryFile $ BS.pack [0x00, 0x01, 0x02, 0x03, 0xFF, 0xFE, 0xFD]
-- Create a config and capabilities
let config = defaultTestConfig tmpDir
readCap = fileReadCap [tmpDir]
-- Create an empty database
currentTime <- getCurrentTime
let emptyDb = ClodDatabase Map.empty Map.empty Nothing currentTime
-- Detect changes
result <- runClodM config $ detectFileChanges readCap emptyDb ["text.txt", "binary.bin"] tmpDir
-- Should only detect the text file and ignore the binary file
case result of
Left err -> expectationFailure $ "Change detection failed: " ++ show err
Right (changes, _) -> do
-- Should have one change (just the text file)
length changes `shouldBe` 1
-- The change should be for the text file only
case changes of
(path, status):_ -> do
path `shouldBe` "text.txt"
status `shouldBe` New
[] -> expectationFailure "Expected at least one change"
-- The binary file should not be present in the changes
any (("binary.bin" ==) . fst) changes `shouldBe` False
it "identifies new files" $ do
withSystemTempDirectory "clod-test" $ \tmpDir -> do
-- Create files
let textFile = tmpDir </> "new.txt"
writeFile textFile "This is a new file"
-- Create a config and capabilities
let config = defaultTestConfig tmpDir
readCap = fileReadCap [tmpDir]
-- Create an empty database
currentTime <- getCurrentTime
let emptyDb = ClodDatabase Map.empty Map.empty Nothing currentTime
-- Detect changes
result <- runClodM config $ detectFileChanges readCap emptyDb ["new.txt"] tmpDir
-- Should identify the file as new
case result of
Left err -> expectationFailure $ "Change detection failed: " ++ show err
Right (changes, _) -> do
-- Should have one change
length changes `shouldBe` 1
-- The change should be a new file
case changes of
(path, status):_ -> do
path `shouldBe` "new.txt"
status `shouldBe` New
[] -> expectationFailure "Expected at least one change"
it "identifies modified files" $ do
withSystemTempDirectory "clod-test" $ \tmpDir -> do
-- Create a file
let textFile = tmpDir </> "test.txt"
writeFile textFile "Initial content"
-- Create a config and capabilities
let config = defaultTestConfig tmpDir
readCap = fileReadCap [tmpDir]
dbPath = tmpDir </> ".clod" </> "db.dhall"
createDirectoryIfMissing True (tmpDir </> ".clod")
-- First, create and save a database with the initial file
result1 <- runClodM config $ do
-- Checksum the file
checksum <- checksumFile readCap textFile
-- Get current time
currentTime <- liftIO getCurrentTime
-- Create an optimized name
let optName = OptimizedName "test.txt"
-- Initialize database and add the file
db <- initializeDatabase
let updatedDb = updateDatabase db "test.txt" checksum currentTime optName
-- Save database
saveDatabase dbPath updatedDb
-- Return database for later use
return updatedDb
-- Now modify the file
writeFile textFile "Modified content"
-- Detect changes
result2 <- case result1 of
Left err -> do
expectationFailure $ "Initial setup failed: " ++ show err
return $ Left err
Right db -> runClodM config $ detectFileChanges readCap db ["test.txt"] tmpDir
-- Should identify the file as modified
case result2 of
Left err -> expectationFailure $ "Change detection failed: " ++ show err
Right (changes, _) -> do
-- Should have one change
length changes `shouldBe` 1
-- The change should be a modified file
case changes of
(path, status):_ -> do
path `shouldBe` "test.txt"
status `shouldBe` Modified
[] -> expectationFailure "Expected at least one change"
it "identifies renamed files" $ do
withSystemTempDirectory "clod-test" $ \tmpDir -> do
-- Create a file
let originalFile = tmpDir </> "original.txt"
newLocation = tmpDir </> "renamed.txt"
-- Write identical content to both files
-- (simulating a rename operation, since we can't actually rename between detection calls)
let content = "This file will be 'renamed'"
writeFile originalFile content
-- Create a config and capabilities
let config = defaultTestConfig tmpDir
readCap = fileReadCap [tmpDir]
dbPath = tmpDir </> ".clod" </> "db.dhall"
createDirectoryIfMissing True (tmpDir </> ".clod")
-- First, create and save a database with the original file
result1 <- runClodM config $ do
-- Checksum the file
checksum <- checksumFile readCap originalFile
-- Get current time
currentTime <- liftIO getCurrentTime
-- Create an optimized name
let optName = OptimizedName "original.txt"
-- Initialize database and add the file
db <- initializeDatabase
let updatedDb = updateDatabase db "original.txt" checksum currentTime optName
-- Save database
saveDatabase dbPath updatedDb
-- Return database for later use
return updatedDb
-- Now simulate the rename by deleting the original and creating the new file
-- with identical content (will have same checksum)
writeFile newLocation content
-- Don't actually delete the original, since detectFileChanges needs it to exist
-- Detect changes
result2 <- case result1 of
Left err -> do
expectationFailure $ "Initial setup failed: " ++ show err
return $ Left err
Right db -> runClodM config $ detectFileChanges readCap db ["renamed.txt"] tmpDir
-- Should identify the file as renamed
case result2 of
Left err -> expectationFailure $ "Change detection failed: " ++ show err
Right (changes, renamed) -> do
-- Should have one change
length changes `shouldBe` 1
-- The change should be a renamed file
case changes of
(path, status):_ -> do
path `shouldBe` "renamed.txt"
case status of
Renamed oldPath -> oldPath `shouldBe` "original.txt"
_ -> expectationFailure $ "Expected renamed status, got: " ++ show status
[] -> expectationFailure "Expected at least one change"
-- Should include the renamed file in the renamed list
length renamed `shouldBe` 1
case renamed of
(newPath, oldPath):_ -> do
newPath `shouldBe` "renamed.txt"
oldPath `shouldBe` "original.txt"
[] -> expectationFailure "Expected at least one renamed file"