packages feed

llm-simple-0.4.3.0: test/LLM/TextFileSpec.hs

module LLM.TextFileSpec (spec) where

import Control.Exception (throwIO)
import Data.ByteString qualified as BS
import Data.Text (Text)
import LLM.Tools.Internal.TextFile (CappedRead (..), readBoundedBytesWith, readBoundedBytesWithSize, readCapped, readCappedWith)
import System.FilePath ((</>))
import System.IO (Handle, IOMode (ReadMode), hFileSize, withBinaryFile)
import System.IO.Temp (withSystemTempDirectory)
import Test.Hspec (Spec, describe, it, shouldReturn)

spec :: Spec
spec = do
  readCappedSpec
  readBoundedBytesSpec

readCappedSpec :: Spec
readCappedSpec = describe "TextFile.readCapped" $ do
  it "reads a file of exactly the cap" $
    withFile (BS.replicate cap 97) $ \path ->
      withBinaryFile path ReadMode (readCapped (fromIntegral cap))
        `shouldReturn` CappedBytes (fromIntegral cap) (BS.replicate cap 97)

  it "refuses a file over the cap at the size check" $
    withFile (BS.replicate (cap + 1) 97) $ \path ->
      withBinaryFile path ReadMode (readCapped (fromIntegral cap))
        `shouldReturn` CappedTooLarge (fromIntegral cap + 1)

  -- C23: a file within the cap at the size check that grows past it
  -- before the read must be an error, not a page that ends at the cap.
  it "reports a file that grows past the cap after the size check" $
    withFile (BS.replicate (cap + 2) 97) $ \path ->
      withBinaryFile path ReadMode (readCappedWith (sizeBeforeGrowth 2) (fromIntegral cap))
        `shouldReturn` CappedGrew

  it "reads a file that grows but stays within the cap" $
    withFile (BS.replicate cap 97) $ \path ->
      withBinaryFile path ReadMode (readCappedWith (sizeBeforeGrowth 2) (fromIntegral cap))
        `shouldReturn` CappedBytes (fromIntegral cap - 2) (BS.replicate cap 97)
  where
    cap = 1000

readBoundedBytesSpec :: Spec
readBoundedBytesSpec = describe "TextFile.readBoundedBytesWith" $ do
  it "reads a file of exactly the cap" $
    withFile (BS.replicate cap 97) $ \path ->
      readBoundedBytesWith (withBinaryFile path ReadMode) name cap
        `shouldReturn` Right (BS.replicate cap 97)

  it "refuses a file over the cap at the size check" $
    withFile (BS.replicate (cap + 1) 97) $ \path ->
      readBoundedBytesWith (withBinaryFile path ReadMode) name cap
        `shouldReturn` Left "f.txt is 1001 bytes, which exceeds the 1000-byte cap."

  it "reports a file that grows past the cap after the size check" $
    withFile (BS.replicate (cap + 2) 97) $ \path ->
      readBoundedBytesWithSize (sizeBeforeGrowth 2) (withBinaryFile path ReadMode) name cap
        `shouldReturn` Left "f.txt grew past the 1000-byte cap during the read."

  it "reads a file that grows but stays within the cap" $
    withFile (BS.replicate cap 97) $ \path ->
      readBoundedBytesWithSize (sizeBeforeGrowth 2) (withBinaryFile path ReadMode) name cap
        `shouldReturn` Right (BS.replicate cap 97)

  it "names the size check when it fails" $
    withFile "text" $ \path ->
      readBoundedBytesWithSize (\_ -> throwIO (userError "no size")) (withBinaryFile path ReadMode) name cap
        `shouldReturn` Left "could not stat f.txt: user error (no size)"
  where
    cap = 1000
    name :: Text
    name = "f.txt"

-- | A size check that sees the file as it was @n@ bytes earlier: the file
-- grew by @n@ bytes between the check and the read. (Appending for real
-- from the check does not work: GHC's file locking refuses a write handle
-- while the read handle is open.)
sizeBeforeGrowth :: Integer -> Handle -> IO Integer
sizeBeforeGrowth n h = subtract n <$> hFileSize h

withFile :: BS.ByteString -> (FilePath -> IO a) -> IO a
withFile content act =
  withSystemTempDirectory "llm-simple-textfile" $ \dir -> do
    let path = dir </> "f.txt"
    BS.writeFile path content
    act path