packages feed

ai-agent-diff-patch-0.0.1.0: src/AIAgent/DiffPatch/Unified/ProjectedContext/Context.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}

-- | ProjectedContext layer: projection functions and AppContext actions.
-- Orchestrates file I/O (via IOFunc) with domain computation.
-- Algorithm functions are in Algorithm.hs; parser functions are in Parser.hs.
-- IO is performed only through liftIOE / the injected IOFunc; no concrete
-- IO implementations are imported here.
module AIAgent.DiffPatch.Unified.ProjectedContext.Context
  ( normalize
  , detectLineEnding
  , restoreLineEnding
  , liftIOE
  , patchPC
  , diffPC
  , applyPatchText
  , formatPatch
  -- Re-exports for backward compatibility (used by tests and other modules)
  , module AIAgent.DiffPatch.Unified.ProjectedContext.Algorithm
  , module AIAgent.DiffPatch.Unified.ProjectedContext.Parser
  ) where

import qualified Data.Text as T
import Data.Text (Text)
import AIAgent.DiffPatch.Unified.CoreModel.Type
import AIAgent.DiffPatch.Unified.CoreModel.Constant

import AIAgent.DiffPatch.Unified.ProjectedContext.Algorithm
import AIAgent.DiffPatch.Unified.ProjectedContext.Parser

-- AppContext imports
import Control.Monad.Reader
import Control.Monad.Except
import qualified Control.Exception.Safe as E
import Data.Default


-- | Normalize line endings to LF.
-- Converts CRLF (\r\n) first, then bare CR (\r), to LF (\n).
-- This order is important: handling CR before CRLF would cause \r\n -> \n\n.
normalize :: Text -> Text
normalize = T.replace _CR _LF . T.replace _CRLF _LF

-- ---------------------------------------------------------------------------
-- Line-ending domain logic (moved from Interface layer)
-- ---------------------------------------------------------------------------

-- | Detect the dominant line-ending convention in a Text value.
-- CRLF must be tested before CR to avoid misidentifying "\r\n" as bare CR.
detectLineEnding :: Text -> Text
detectLineEnding t
  | _CRLF `T.isInfixOf` t = _CRLF
  | _CR   `T.isInfixOf` t = _CR
  | otherwise              = _LF

-- | Restore the original line-ending convention in a Text value.
-- Replaces every LF with the detected line-ending sequence.
-- When the original line-ending is already LF, this is a no-op.
restoreLineEnding :: Text -> Text -> Text
restoreLineEnding le txt
  | le == _LF = txt
  | otherwise = T.intercalate le (T.splitOn _LF txt)


-- ---------------------------------------------------------------------------
-- liftIOE helper
-- ---------------------------------------------------------------------------

-- | Lift an IO action into AppContext, catching synchronous exceptions
-- and converting them to ExceptT failures.
liftIOE :: IO a -> AppContext a
liftIOE f = liftIO (E.try f) >>= either (throwError . show @E.SomeException) return


-- ---------------------------------------------------------------------------
-- formatPatch
-- ---------------------------------------------------------------------------

-- | Serialize a Patch to unified diff format Text.
-- When _headerPatch is Nothing, the "--- ..." / "+++ ..." header lines are omitted.
-- The hunk header always uses the full form "@@ -s,c +s,c @@" without omitting
-- the count field even when count == 1.
formatPatch :: Patch -> Text
formatPatch p =
  headerText <> T.concat (map formatHunk (_hunksPatch p))
  where
    headerText = case _headerPatch p of
      Nothing     -> ""
      Just header ->
        "--- " <> _oldPathFileHeader header <> "\n" <>
        "+++ " <> _newPathFileHeader header <> "\n"

    formatHunk :: Hunk -> Text
    formatHunk hunk =
      hunkHeader <> T.concat (map formatLine (_linesHunk hunk))
      where
        hunkHeader =
          "@@ -" <> T.pack (show (_oldStartHunk hunk)) <> "," <> T.pack (show (_oldCountHunk hunk)) <>
          " +"   <> T.pack (show (_newStartHunk hunk)) <> "," <> T.pack (show (_newCountHunk hunk)) <>
          " @@\n"

    formatLine :: Line -> Text
    formatLine line = case _typeLine line of
      Context -> " " <> _contentLine line <> "\n"
      Removed -> "-" <> _contentLine line <> "\n"
      Added   -> "+" <> _contentLine line <> "\n"


-- ---------------------------------------------------------------------------
-- ProjectedContext actions: orchestrate IOFunc + domain computation
-- ---------------------------------------------------------------------------

-- | Core patch action used by both runPatch' and runPatchFile.
-- Reads the original file, applies the patch text, and writes the result
-- via '_writeIOFunc' (which may target a file or stdout).
-- Line-ending detection and restoration are performed here as domain logic.
patchPC :: FilePath -> String -> AppContext ()
patchPC origFile patchText = do
  ioFunc   <- asks _ioFuncAppData
  rawText  <- liftIOE $ _readIOFunc ioFunc origFile
  let le       = detectLineEnding rawText
      origText = normalize rawText
  result   <- either throwError return $ applyPatchText origText (T.pack patchText)
  let resultRestored = restoreLineEnding le result
  liftIOE  $ _writeIOFunc ioFunc origFile resultRestored

-- | Core diff action.
-- Reads both files, normalises line endings, computes the unified diff,
-- and writes the result via '_writeIOFunc'.
diffPC :: FilePath -> FilePath -> AppContext ()
diffPC oldFile newFile = do
  ioFunc  <- asks _ioFuncAppData
  rawOld  <- liftIOE $ _readIOFunc ioFunc oldFile
  rawNew  <- liftIOE $ _readIOFunc ioFunc newFile
  let oldText = normalize rawOld
      newText = normalize rawNew
      edits   = myers (T.lines oldText) (T.lines newText)
      hunks   = buildHunks (_contextLinesDiffOptions (def :: DiffOptions)) edits
      result  = formatPatch (Patch Nothing hunks)
  liftIOE $ _writeIOFunc ioFunc oldFile result


-- ---------------------------------------------------------------------------
-- Pure helper: apply a patch text to normalised Text
-- ---------------------------------------------------------------------------

-- | Parse a unified diff string and apply it to the source Text.
-- The source Text must be LF-normalised before calling this function.
-- Returns the patched Text (LF-terminated) on success, or an error message on failure.
applyPatchText :: T.Text -> T.Text -> Either String T.Text
applyPatchText origText patchTxt
  | T.null patchTxt = Right origText
  | otherwise =
      case parsePatch patchTxt of
        Left  err -> Left (show err)
        Right p   ->
          let origLines = T.lines origText
          in  case applyAllHunks origLines (_hunksPatch p) of
                Left  err     -> Left (show err)
                Right patched -> Right patched
  where
    joinLines []  = ""
    joinLines ls  = T.intercalate _LF ls <> _LF

    applyAllHunks ls hunks = joinLines <$> go 0 1 ls hunks

    go _     _        buf []     = Right buf
    go delta hunkIdx  buf (h:hs) =
      let h'     = h { _oldStartHunk = _oldStartHunk h + delta }
          delta' = delta + (_newCountHunk h - _oldCountHunk h)
      in  case applyHunkFuzzy hunkIdx buf h' of
            Left  err  -> Left err
            Right buf' -> go delta' (hunkIdx + 1) buf' hs