never executed always true always false
1 module PureClaw.Tools.Edit
2 ( -- * Tool registration
3 editTool
4 ) where
5
6 import Control.Exception
7 import Data.Aeson
8 import Data.Aeson.Types
9 import Data.Text (Text)
10 import Data.Text qualified as T
11 import Data.Text.Encoding qualified as TE
12
13 import PureClaw.Core.Types
14 import PureClaw.Handles.File
15 import PureClaw.Providers.Class
16 import PureClaw.Security.Path
17 import PureClaw.Tools.Registry
18
19 -- | Create an edit tool that performs string replacement in files.
20 -- The old string must be unique in the file — ambiguous edits are rejected.
21 editTool :: WorkspaceRoot -> FileHandle -> (ToolDefinition, ToolHandler)
22 editTool root fh = (def, handler)
23 where
24 def = ToolDefinition
25 { _td_name = "edit"
26 , _td_description = "Replace a unique string in a file. The old_string must appear exactly once in the file."
27 , _td_inputSchema = object
28 [ "type" .= ("object" :: Text)
29 , "properties" .= object
30 [ "path" .= object
31 [ "type" .= ("string" :: Text)
32 , "description" .= ("The file path relative to the workspace root" :: Text)
33 ]
34 , "old_string" .= object
35 [ "type" .= ("string" :: Text)
36 , "description" .= ("The exact string to find and replace (must be unique)" :: Text)
37 ]
38 , "new_string" .= object
39 [ "type" .= ("string" :: Text)
40 , "description" .= ("The replacement string" :: Text)
41 ]
42 ]
43 , "required" .= (["path", "old_string", "new_string"] :: [Text])
44 ]
45 }
46
47 handler = ToolHandler $ \input ->
48 case parseEither parseInput input of
49 Left err -> pure (T.pack err, True)
50 Right (path, oldStr, newStr) -> do
51 pathResult <- mkSafePath root (T.unpack path)
52 case pathResult of
53 Left pe -> pure (T.pack (show pe), True)
54 Right sp -> do
55 readResult <- try @SomeException (_fh_readFile fh sp)
56 case readResult of
57 Left e -> pure (T.pack (show e), True)
58 Right bs -> case TE.decodeUtf8' bs of
59 Left _ -> pure ("Cannot edit binary file", True)
60 Right content ->
61 let count = countOccurrences oldStr content
62 in case count of
63 0 -> pure ("old_string not found in " <> path, True)
64 1 -> do
65 let newContent = replaceFirst oldStr newStr content
66 writeResult <- try @SomeException
67 (_fh_writeFile fh sp (TE.encodeUtf8 newContent))
68 case writeResult of
69 Left e -> pure (T.pack (show e), True)
70 Right () -> pure ("Edited " <> path, False)
71 n -> pure ("old_string not unique in " <> path
72 <> " (" <> T.pack (show n) <> " occurrences)", True)
73
74 parseInput :: Value -> Parser (Text, Text, Text)
75 parseInput = withObject "EditInput" $ \o ->
76 (,,) <$> o .: "path" <*> o .: "old_string" <*> o .: "new_string"
77
78 -- | Count non-overlapping occurrences of a needle in a haystack.
79 countOccurrences :: Text -> Text -> Int
80 countOccurrences needle haystack
81 | T.null needle = 0
82 | otherwise = go 0 haystack
83 where
84 go !n remaining =
85 case T.breakOn needle remaining of
86 (_, after)
87 | T.null after -> n
88 | otherwise -> go (n + 1) (T.drop (T.length needle) after)
89
90 -- | Replace the first occurrence of needle with replacement.
91 replaceFirst :: Text -> Text -> Text -> Text
92 replaceFirst needle replacement haystack =
93 let (before, after) = T.breakOn needle haystack
94 in before <> replacement <> T.drop (T.length needle) after