packages feed

heni-0.1.0.0: src/Test/Heni/Edit.hs

-- | Transactional checked edits: the callback sees a candidate only after all
-- preconditions and the independent final inventory have passed.
module Test.Heni.Edit
  ( EditError (..),
    FileChange (..),
    EditEvidence (..),
    PatchRunner,
    withEditedWorkspace,
    workspaceOperations,
  )
where

import Control.Exception
import Control.Monad (forM_, unless, when)
import Data.ByteString qualified as BS
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text
import System.Directory qualified as Dir
import System.FilePath (splitDirectories, takeDirectory)
import Test.Heni.Case
import Test.Heni.Expectation
import Test.Heni.Internal.Files
import Test.Heni.Workspace

-- | Stale/invalid mutations differ from ordinary filesystem infrastructure errors.
data EditError = InvalidEdit !Text | EditInfrastructure !Text
  deriving stock (Eq, Show)
  deriving anyclass (Exception)

-- | Actual before/after bytes, including creation, deletion and executable modes.
data FileChange = FileChange {path :: !RelativeTarget, before :: !(Maybe FileEntry), after :: !(Maybe FileEntry)}
  deriving stock (Eq, Show)

-- | Independently recomputed changes, not a hook's claimed touched-file list.
newtype EditEvidence = EditEvidence {changes :: [FileChange]}
  deriving stock (Eq, Show)

-- | Inject the common supervisor for optional Git. It must bound and reap children.
-- Arguments are passed literally; the runner supplies its workspace as cwd.
type PatchRunner = Workspace -> [String] -> IO (Either Text ())

-- | Checked trusted-IO capabilities. Writes require existing files; creation and
-- deletion have explicit byte preconditions and reject runner-owned paths.
workspaceOperations :: Workspace -> WorkspaceOps
workspaceOperations workspace = WorkspaceOps readText writeText createText deleteText
  where
    checked target = do
      when (reserved target) (throwIO (InvalidEdit "edit targets runner-owned build/evidence metadata"))
      checkedWorkspacePath workspace target
    readText target = do
      p <- checked target
      b <- BS.readFile p
      either (const (throwIO (InvalidEdit "target is not valid UTF-8"))) pure (Text.decodeUtf8' b)
    writeText target content = do
      _ <- readText target
      p <- checked target
      BS.writeFile p (Text.encodeUtf8 content)
    createText target content = do
      p <- checked target
      exists <- Dir.doesPathExist p
      when exists (throwIO (InvalidEdit "creation target already exists"))
      Dir.createDirectoryIfMissing True (takeDirectory p)
      BS.writeFile p (Text.encodeUtf8 content)
    deleteText target expected = do
      actual <- readText target
      unless (actual == expected) (throwIO (InvalidEdit "deletion precondition failed"))
      p <- checked target
      Dir.removeFile p

reserved :: RelativeTarget -> Bool
reserved target = any (\p -> p `elem` [".git", "dist", "dist-newstyle", ".heni", "mutation-results"] || ".heni-" `Text.isPrefixOf` Text.pack p) (splitDirectories (targetPath target))

-- | Allocate a private candidate, apply ordered edits, inspect all final bytes,
-- then invoke the consumer. Failure or cancellation discards the entire tree.
withEditedWorkspace :: forall a. PatchRunner -> CaseHooks -> SourceSnapshot -> CaseDefinition -> (Workspace -> EditEvidence -> IO a) -> IO (Either EditError a)
withEditedWorkspace runPatch hooks snapshot definition action =
  catches
    (Right <$> withWorkspace snapshot candidate)
    [ Handler (pure . Left :: EditError -> IO (Either EditError a)),
      Handler (\e -> pure (Left (InvalidEdit (Text.pack (show (e :: WorkspaceError)))))),
      Handler (\e -> pure (Left (EditInfrastructure (Text.pack (show (e :: IOException))))))
    ]
  where
    candidate workspace = do
      let CaseDefinition {edits, expectation} = definition
      forM_ edits (apply workspace)
      actual <- inventory (const False) (workspaceRoot workspace)
      let original = snapshotFiles snapshot
          changed =
            [ FileChange p (Map.lookup p original) (Map.lookup p actual)
            | p <- Set.toAscList (Map.keysSet original <> Map.keysSet actual),
              Map.lookup p original /= Map.lookup p actual
            ]
      when (any (\FileChange {path} -> reserved path) changed) (throwIO (InvalidEdit "edit changed runner-owned metadata"))
      when (expectation /= Control && null changed) (throwIO (InvalidEdit "mutation left all bytes unchanged"))
      when (expectation == Control && not (null changed)) (throwIO (InvalidEdit "control changed source bytes"))
      action workspace (EditEvidence changed)
    apply workspace edit = do
      let ops@WorkspaceOps {readTarget, writeTarget, createTarget, deleteTarget} = workspaceOperations workspace
      case edit of
        Replace target old new count -> do
          when (Text.null old || old == new || count <= 0) (throwIO (InvalidEdit "invalid replacement definition"))
          actual <- readTarget target
          unless (Text.count old actual == count) (throwIO (InvalidEdit "replacement anchor count did not match"))
          writeTarget target (Text.replace old new actual)
        Create target content -> createTarget target content
        Delete target expected -> deleteTarget target expected
        CustomEdit key -> case hooks of
          CaseHooks {customEdits} -> case lookup key customEdits of
            Nothing -> throwIO (InvalidEdit ("missing custom edit hook: " <> key))
            Just hook -> hook ops
        Patch content -> do
          targets <- either (throwIO . InvalidEdit) pure (patchTargets content)
          mapM_ (checkedWorkspacePath workspace) targets
          withOwnedDirectory ".heni-patch-" $ \scratch -> do
            let patchFile = scratch ++ "/candidate.patch"
            BS.writeFile patchFile (Text.encodeUtf8 content)
            forM_ [["apply", "--check", "--", patchFile], ["apply", "--", patchFile]] $ \args -> do
              result <- runPatch workspace args
              either (throwIO . InvalidEdit) pure result

patchTargets :: Text -> Either Text [RelativeTarget]
patchTargets content
  | any unsupported lines' = Left "only textual Git patches without rename/copy/mode/binary operations are supported"
  | null headers = Left "patch requires diff --git headers"
  | otherwise = do
      diffs <- concat <$> mapM parse headers
      paths <- mapM parseFileHeader (filter (\line -> "--- " `Text.isPrefixOf` line || "+++ " `Text.isPrefixOf` line) lines')
      let allPaths = diffs ++ [p | Just p <- paths]
      if any reserved allPaths then Left "patch targets runner-owned metadata" else Right allPaths
  where
    lines' = Text.lines content
    headers = filter (Text.isPrefixOf "diff --git ") lines'
    unsupported line =
      any
        (`Text.isPrefixOf` line)
        ["GIT binary patch", "Binary files ", "rename ", "copy ", "old mode ", "new mode "]
        || ("new file mode " `Text.isPrefixOf` line && line `notElem` ["new file mode 100644", "new file mode 100755"])
        || ("deleted file mode " `Text.isPrefixOf` line && line `notElem` ["deleted file mode 100644", "deleted file mode 100755"])
        || ("index " `Text.isPrefixOf` line && "120000" `Text.isSuffixOf` line)
    parse line = case Text.words line of
      ["diff", "--git", a, b] -> do
        x <- pathAfter "a/" a
        y <- pathAfter "b/" b
        if x /= y then Left "patch renaming is unsupported" else Right [x, y]
      _ -> Left "quoted or spaced patch paths are not supported"
    pathAfter prefix t = case Text.stripPrefix prefix t of
      Nothing -> Left "patch requires a/ and b/ paths"
      Just p -> either (Left . Text.pack . show) Right (mkRelativeTarget (Text.unpack p))
    parseFileHeader line
      | Text.drop 4 line == "/dev/null" = Right Nothing
      | "--- " `Text.isPrefixOf` line = Just <$> pathAfter "a/" (Text.drop 4 line)
      | otherwise = Just <$> pathAfter "b/" (Text.drop 4 line)