packages feed

claude-gate-1.0.0: src/Claude/Gate/Edit.hs

-- | A single file mutation recorded during a turn.
--
-- The PostToolUse hook only fires for the four edit tools (the matcher in
-- settings.json is @Write|Edit|MultiEdit|NotebookEdit@), so those four
-- constructors are the complete set: there is no "unknown tool" case to fall
-- back on. Each edit is persisted as one JSON line on the review stack and read
-- back at Stop time to reconstruct the diff for the reviewer.
module Claude.Gate.Edit
  ( Edit(..)
  , Replacement(..)
  , editFilePath
  , parseEditFromTool
  ) where

import Data.Aeson (FromJSON (parseJSON), ToJSON (toJSON), Value, object, withObject, (.:), (.=))
import Data.Aeson.Types qualified as Aeson (Parser, parseEither)
import Data.Text (Text)

-- | One old/new text pair, as carried by Edit and each element of MultiEdit.
data Replacement = Replacement
  { replacedText :: Text
  , replacementText :: Text
  }
  deriving stock (Eq, Show)

instance FromJSON Replacement where
  parseJSON :: Value -> Aeson.Parser Replacement
  parseJSON = withObject "Replacement" $ \object' ->
    Replacement
      <$> object' .: "old_string"
      <*> object' .: "new_string"

-- | A recorded edit, one constructor per edit tool the hook fires for.
data Edit
  = SingleEdit FilePath Replacement
  | MultiEditFile FilePath [Replacement]
  | WriteFileContent FilePath Text
  | NotebookCellSource FilePath Text
  deriving stock (Eq, Show)

editFilePath :: Edit -> FilePath
editFilePath = \case
  SingleEdit path _ -> path
  MultiEditFile path _ -> path
  WriteFileContent path _ -> path
  NotebookCellSource path _ -> path

-- | Parse an edit from a PostToolUse payload's tool name and tool_input. The
-- tool name selects the shape; an unexpected name means the settings.json
-- matcher and this code disagree, which is a bug we surface loudly rather than
-- silently drop.
parseEditFromTool :: Text -> Value -> Either String Edit
parseEditFromTool tool = Aeson.parseEither (editParser tool)

editParser :: Text -> Value -> Aeson.Parser Edit
editParser tool = withObject "tool_input" $ \object' -> case tool of
  "Edit" ->
    SingleEdit <$> object' .: "file_path" <*> (Replacement <$> object' .: "old_string" <*> object' .: "new_string")
  "MultiEdit" ->
    MultiEditFile <$> object' .: "file_path" <*> object' .: "edits"
  "Write" ->
    WriteFileContent <$> object' .: "file_path" <*> object' .: "content"
  "NotebookEdit" ->
    NotebookCellSource <$> object' .: "file_path" <*> object' .: "new_source"
  other ->
    fail ("unexpected edit tool: " <> show other)

-- Persisted form: tag with the tool name and keep exactly the fields needed to
-- reconstruct the diff. Round-trips through parseEditFromTool's shape so the
-- record and review halves of the gate stay in sync.
instance ToJSON Edit where
  toJSON :: Edit -> Value
  toJSON = \case
    SingleEdit path (Replacement old new) ->
      object ["tool" .= ("Edit" :: Text), "file_path" .= path, "old_string" .= old, "new_string" .= new]
    MultiEditFile path edits ->
      object ["tool" .= ("MultiEdit" :: Text), "file_path" .= path, "edits" .= map replacementToJSON edits]
    WriteFileContent path content ->
      object ["tool" .= ("Write" :: Text), "file_path" .= path, "content" .= content]
    NotebookCellSource path source ->
      object ["tool" .= ("NotebookEdit" :: Text), "file_path" .= path, "new_source" .= source]

replacementToJSON :: Replacement -> Value
replacementToJSON (Replacement old new) =
  object ["old_string" .= old, "new_string" .= new]

instance FromJSON Edit where
  parseJSON :: Value -> Aeson.Parser Edit
  parseJSON value = flip (withObject "Edit") value $ \object' -> do
    tool <- object' .: "tool"
    editParser tool value