packages feed

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 }