ai-agent-diff-patch-0.0.1.0: test/AIAgent/DiffPatch/Unified/UnifiedSpec.hs
{-# LANGUAGE OverloadedStrings #-}
-- | Tests for AIAgent.DiffPatch.Unified.
-- Covers the file I/O API: diff, patch, patchFile.
-- Each test writes a temporary file, calls the target function, and inspects
-- the result and/or captured stdout.
module AIAgent.DiffPatch.Unified.UnifiedSpec (spec) where
import Test.Hspec
import System.IO.Temp (withSystemTempFile)
import System.IO (hClose)
import qualified Data.ByteString as BS
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Data.Default (def)
import AIAgent.DiffPatch.Unified (diff, patch, patchFile)
import AIAgent.DiffPatch.Unified.ProjectedContext.Context (myers, buildHunks, formatPatch, normalize)
import AIAgent.DiffPatch.Unified.CoreModel.Type (DiffOptions(..), Patch(..), _contextLinesDiffOptions)
-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------
-- | Write a ByteString to a temp file, run the action with the FilePath,
-- then read back the file as a ByteString.
withTempBs :: BS.ByteString -> (FilePath -> IO a) -> IO (a, BS.ByteString)
withTempBs content action =
withSystemTempFile "unified-spec.txt" $ \fp h -> do
hClose h
BS.writeFile fp content
result <- action fp
afterBs <- BS.readFile fp
return (result, afterBs)
-- | Write a ByteString to a temp file and run an action that only needs the path.
withTempBs_ :: BS.ByteString -> (FilePath -> IO a) -> IO a
withTempBs_ content action =
withSystemTempFile "unified-spec.txt" $ \fp h -> do
hClose h
BS.writeFile fp content
action fp
-- | Build a unified diff patch String from old/new Text using Context pure functions.
mkPatch :: T.Text -> T.Text -> String
mkPatch old new =
let oldLines = T.lines (normalize old)
newLines = T.lines (normalize new)
edits = myers oldLines newLines
hunks = buildHunks (_contextLinesDiffOptions (def :: DiffOptions)) edits
in T.unpack (formatPatch (Patch Nothing hunks))
-- | Encode Text as UTF-8 ByteString (LF line endings assumed in the Text value).
encLf :: T.Text -> BS.ByteString
encLf = TE.encodeUtf8
-- | Encode Text as UTF-8 ByteString with CRLF line endings.
encCrlf :: T.Text -> BS.ByteString
encCrlf t = TE.encodeUtf8 (T.intercalate "\r\n" (T.splitOn "\n" t))
-- | Replace every LF (0x0a) with CR (0x0d) in a ByteString.
lfToCr :: BS.ByteString -> BS.ByteString
lfToCr = BS.map (\b -> if b == 0x0a then 0x0d else b)
-- ---------------------------------------------------------------------------
-- Spec
-- ---------------------------------------------------------------------------
spec :: Spec
spec = do
-- ==========================================================================
describe "patchFile" $ do
-- ==========================================================================
-- PF-01: basic LF file patched in place
it "PF-01: LF file - patch applied in place, Right () returned" $ do
let old = "apple\nbanana\ncherry\n"
new = "apple\nblueberry\ncherry\n"
patchTxt = mkPatch old new
origBs = encLf old
(result, afterBs) <- withTempBs origBs $ \fp ->
patchFile fp patchTxt
result `shouldBe` Right ()
afterBs `shouldBe` encLf new
-- PF-02: CRLF file - line endings preserved
it "PF-02: CRLF file - CRLF line endings preserved" $ do
let oldLf = "apple\nbanana\ncherry\n"
newLf = "apple\nblueberry\ncherry\n"
patchTxt = mkPatch oldLf newLf
origBs = encCrlf oldLf
(result, afterBs) <- withTempBs origBs $ \fp ->
patchFile fp patchTxt
result `shouldBe` Right ()
afterBs `shouldBe` encCrlf newLf
-- PF-03: CR-only file - CR line endings preserved
it "PF-03: CR-only file - CR line endings preserved" $ do
let oldLf = "apple\nbanana\ncherry\n"
newLf = "apple\nblueberry\ncherry\n"
patchTxt = mkPatch oldLf newLf
origBs = lfToCr (encLf oldLf)
(result, afterBs) <- withTempBs origBs $ \fp ->
patchFile fp patchTxt
result `shouldBe` Right ()
afterBs `shouldBe` lfToCr (encLf newLf)
-- PF-04: UTF-8 multibyte (Japanese) content
it "PF-04: UTF-8 multibyte (Japanese) content" $ do
let old = "りんご\nばなな\nさくらんぼ\n"
new = "りんご\nブルーベリー\nさくらんぼ\n"
patchTxt = mkPatch old new
origBs = encLf old
(result, afterBs) <- withTempBs origBs $ \fp ->
patchFile fp patchTxt
result `shouldBe` Right ()
afterBs `shouldBe` encLf new
-- PF-05: patch failure returns Left, file unchanged
it "PF-05: patch failure - Left returned and file unchanged" $ do
let origContent = "line1\nline2\nline3\n"
badPatch = "@@ -1,3 +1,3 @@\n WRONG_CONTEXT\n-line2\n+modified\n line3\n"
origBs = encLf origContent
(result, afterBs) <- withTempBs origBs $ \fp ->
patchFile fp badPatch
result `shouldSatisfy` \r -> case r of { Left _ -> True; _ -> False }
afterBs `shouldBe` origBs
-- PF-06: no-op patch - file unchanged
it "PF-06: no-op patch (identical old/new) - file unchanged" $ do
let content = "apple\nbanana\ncherry\n"
patchTxt = mkPatch content content
origBs = encLf content
(result, afterBs) <- withTempBs origBs $ \fp ->
patchFile fp patchTxt
result `shouldBe` Right ()
afterBs `shouldBe` origBs
-- ==========================================================================
describe "patch" $ do
-- ==========================================================================
-- PP-01: LF file - Right () returned, file on disk unchanged
it "PP-01: LF file - Right () returned, file on disk not modified" $ do
let old = "apple\nbanana\ncherry\n"
new = "apple\nblueberry\ncherry\n"
patchTxt = mkPatch old new
origBs = encLf old
(result, afterBs) <- withTempBs origBs $ \fp ->
patch fp patchTxt
result `shouldBe` Right ()
-- patch writes to stdout, not to disk
afterBs `shouldBe` origBs
-- PP-02: patch failure - Left returned
it "PP-02: patch failure - Left returned" $ do
let origContent = "line1\nline2\nline3\n"
badPatch = "@@ -1,3 +1,3 @@\n WRONG_CONTEXT\n-line2\n+modified\n line3\n"
origBs = encLf origContent
result <- withTempBs_ origBs $ \fp ->
patch fp badPatch
result `shouldSatisfy` \r -> case r of { Left _ -> True; _ -> False }
-- PP-03: no-op patch - Right () returned
it "PP-03: no-op patch (identical old/new) - Right () returned" $ do
let content = "apple\nbanana\ncherry\n"
patchTxt = mkPatch content content
origBs = encLf content
(result, _) <- withTempBs origBs $ \fp ->
patch fp patchTxt
result `shouldBe` Right ()
-- ==========================================================================
describe "diff" $ do
-- ==========================================================================
-- DF-01: two LF files - Right () returned
it "DF-01: two LF files - Right () returned" $ do
let old = "apple\nbanana\ncherry\n"
new = "apple\nblueberry\ncherry\n"
withSystemTempFile "old.txt" $ \oldFp oldH ->
withSystemTempFile "new.txt" $ \newFp newH -> do
hClose oldH
hClose newH
BS.writeFile oldFp (encLf old)
BS.writeFile newFp (encLf new)
result <- diff oldFp newFp
result `shouldBe` Right ()
-- DF-02: identical files - Right () returned (empty diff to stdout)
it "DF-02: identical files - Right () returned" $ do
let content = "apple\nbanana\ncherry\n"
withSystemTempFile "same.txt" $ \fp1 h1 ->
withSystemTempFile "same.txt" $ \fp2 h2 -> do
hClose h1
hClose h2
BS.writeFile fp1 (encLf content)
BS.writeFile fp2 (encLf content)
result <- diff fp1 fp2
result `shouldBe` Right ()
-- DF-03: non-existent file - Left returned
it "DF-03: non-existent file - Left returned (IO error)" $ do
result <- diff "/nonexistent/path/old.txt" "/nonexistent/path/new.txt"
result `shouldSatisfy` \r -> case r of { Left _ -> True; _ -> False }