SaylorTwift HF Staff commited on
Commit
9ebf6d4
·
verified ·
1 Parent(s): 854994d

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. codex-rs/agent-graph-store/src/error.rs +20 -0
  2. codex-rs/agent-graph-store/src/lib.rs +13 -0
  3. codex-rs/agent-graph-store/src/local.rs +344 -0
  4. codex-rs/agent-graph-store/src/store.rs +60 -0
  5. codex-rs/agent-graph-store/src/types.rs +42 -0
  6. codex-rs/apply-patch/src/file_update.rs +335 -0
  7. codex-rs/apply-patch/src/file_update_tests.rs +278 -0
  8. codex-rs/apply-patch/src/invocation.rs +1036 -0
  9. codex-rs/apply-patch/src/lib.rs +1444 -0
  10. codex-rs/apply-patch/src/main.rs +3 -0
  11. codex-rs/apply-patch/src/parser.rs +682 -0
  12. codex-rs/apply-patch/src/seek_sequence.rs +193 -0
  13. codex-rs/apply-patch/src/standalone_executable.rs +90 -0
  14. codex-rs/apply-patch/src/streaming_parser.rs +924 -0
  15. codex-rs/apply-patch/src/text_file.rs +121 -0
  16. codex-rs/apply-patch/tests/all.rs +3 -0
  17. codex-rs/apply-patch/tests/fixtures/scenarios/.gitattributes +5 -0
  18. codex-rs/apply-patch/tests/fixtures/scenarios/001_add_file/expected/bar.md +1 -0
  19. codex-rs/apply-patch/tests/fixtures/scenarios/001_add_file/patch.txt +4 -0
  20. codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/expected/modify.txt +2 -0
  21. codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/expected/nested/new.txt +1 -0
  22. codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/input/delete.txt +1 -0
  23. codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/input/modify.txt +2 -0
  24. codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/patch.txt +9 -0
  25. codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/expected/multi.txt +4 -0
  26. codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/input/multi.txt +4 -0
  27. codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/patch.txt +9 -0
  28. codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/expected/old/other.txt +1 -0
  29. codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/expected/renamed/dir/name.txt +1 -0
  30. codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/input/old/name.txt +1 -0
  31. codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/input/old/other.txt +1 -0
  32. codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/patch.txt +7 -0
  33. codex-rs/apply-patch/tests/fixtures/scenarios/005_rejects_empty_patch/expected/foo.txt +1 -0
  34. codex-rs/apply-patch/tests/fixtures/scenarios/005_rejects_empty_patch/input/foo.txt +1 -0
  35. codex-rs/apply-patch/tests/fixtures/scenarios/005_rejects_empty_patch/patch.txt +2 -0
  36. codex-rs/apply-patch/tests/fixtures/scenarios/006_rejects_missing_context/expected/modify.txt +2 -0
  37. codex-rs/apply-patch/tests/fixtures/scenarios/006_rejects_missing_context/input/modify.txt +2 -0
  38. codex-rs/apply-patch/tests/fixtures/scenarios/006_rejects_missing_context/patch.txt +6 -0
  39. codex-rs/apply-patch/tests/fixtures/scenarios/007_rejects_missing_file_delete/expected/foo.txt +1 -0
  40. codex-rs/apply-patch/tests/fixtures/scenarios/007_rejects_missing_file_delete/input/foo.txt +1 -0
  41. codex-rs/apply-patch/tests/fixtures/scenarios/007_rejects_missing_file_delete/patch.txt +3 -0
  42. codex-rs/apply-patch/tests/fixtures/scenarios/008_rejects_empty_update_hunk/expected/foo.txt +1 -0
  43. codex-rs/apply-patch/tests/fixtures/scenarios/008_rejects_empty_update_hunk/input/foo.txt +1 -0
  44. codex-rs/apply-patch/tests/fixtures/scenarios/008_rejects_empty_update_hunk/patch.txt +3 -0
  45. codex-rs/apply-patch/tests/fixtures/scenarios/009_requires_existing_file_for_update/expected/foo.txt +1 -0
  46. codex-rs/apply-patch/tests/fixtures/scenarios/009_requires_existing_file_for_update/input/foo.txt +1 -0
  47. codex-rs/apply-patch/tests/fixtures/scenarios/009_requires_existing_file_for_update/patch.txt +6 -0
  48. codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/expected/old/other.txt +1 -0
  49. codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/expected/renamed/dir/name.txt +1 -0
  50. codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/input/old/name.txt +1 -0
codex-rs/agent-graph-store/src/error.rs ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// Result type returned by agent graph store operations.
2
+ pub type AgentGraphStoreResult<T> = Result<T, AgentGraphStoreError>;
3
+
4
+ /// Error type shared by agent graph store implementations.
5
+ #[derive(Debug, thiserror::Error)]
6
+ pub enum AgentGraphStoreError {
7
+ /// The caller supplied invalid request data.
8
+ #[error("invalid agent graph store request: {message}")]
9
+ InvalidRequest {
10
+ /// User-facing explanation of the invalid request.
11
+ message: String,
12
+ },
13
+
14
+ /// Catch-all for implementation failures that do not fit a more specific category.
15
+ #[error("agent graph store internal error: {message}")]
16
+ Internal {
17
+ /// User-facing explanation of the implementation failure.
18
+ message: String,
19
+ },
20
+ }
codex-rs/agent-graph-store/src/lib.rs ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Storage-neutral parent/child topology for thread-spawned agents.
2
+
3
+ mod error;
4
+ mod local;
5
+ mod store;
6
+ mod types;
7
+
8
+ pub use error::AgentGraphStoreError;
9
+ pub use error::AgentGraphStoreResult;
10
+ pub use local::LocalAgentGraphStore;
11
+ pub use store::AgentGraphStore;
12
+ pub use store::AgentGraphStoreFuture;
13
+ pub use types::ThreadSpawnEdgeStatus;
codex-rs/agent-graph-store/src/local.rs ADDED
@@ -0,0 +1,344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use codex_protocol::ThreadId;
2
+ use codex_state::StateRuntime;
3
+ use std::sync::Arc;
4
+
5
+ use crate::AgentGraphStore;
6
+ use crate::AgentGraphStoreError;
7
+ use crate::AgentGraphStoreFuture;
8
+ use crate::ThreadSpawnEdgeStatus;
9
+
10
+ /// SQLite-backed implementation of [`AgentGraphStore`] using an existing state runtime.
11
+ #[derive(Clone)]
12
+ pub struct LocalAgentGraphStore {
13
+ state_db: Arc<StateRuntime>,
14
+ }
15
+
16
+ impl std::fmt::Debug for LocalAgentGraphStore {
17
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18
+ f.debug_struct("LocalAgentGraphStore")
19
+ .field("sqlite", self.state_db.sqlite())
20
+ .finish_non_exhaustive()
21
+ }
22
+ }
23
+
24
+ impl LocalAgentGraphStore {
25
+ /// Create a local graph store from an already-initialized state runtime.
26
+ pub fn new(state_db: Arc<StateRuntime>) -> Self {
27
+ Self { state_db }
28
+ }
29
+ }
30
+
31
+ impl AgentGraphStore for LocalAgentGraphStore {
32
+ fn upsert_thread_spawn_edge(
33
+ &self,
34
+ parent_thread_id: ThreadId,
35
+ child_thread_id: ThreadId,
36
+ status: ThreadSpawnEdgeStatus,
37
+ ) -> AgentGraphStoreFuture<'_, ()> {
38
+ Box::pin(async move {
39
+ self.state_db
40
+ .upsert_thread_spawn_edge(
41
+ parent_thread_id,
42
+ child_thread_id,
43
+ to_state_status(status),
44
+ )
45
+ .await
46
+ .map_err(internal_error)
47
+ })
48
+ }
49
+
50
+ fn set_thread_spawn_edge_status(
51
+ &self,
52
+ child_thread_id: ThreadId,
53
+ status: ThreadSpawnEdgeStatus,
54
+ ) -> AgentGraphStoreFuture<'_, ()> {
55
+ Box::pin(async move {
56
+ self.state_db
57
+ .set_thread_spawn_edge_status(child_thread_id, to_state_status(status))
58
+ .await
59
+ .map_err(internal_error)
60
+ })
61
+ }
62
+
63
+ fn list_thread_spawn_children(
64
+ &self,
65
+ parent_thread_id: ThreadId,
66
+ status_filter: Option<ThreadSpawnEdgeStatus>,
67
+ ) -> AgentGraphStoreFuture<'_, Vec<ThreadId>> {
68
+ Box::pin(async move {
69
+ if let Some(status) = status_filter {
70
+ return self
71
+ .state_db
72
+ .list_thread_spawn_children_with_status(
73
+ parent_thread_id,
74
+ to_state_status(status),
75
+ )
76
+ .await
77
+ .map_err(internal_error);
78
+ }
79
+
80
+ self.state_db
81
+ .list_thread_spawn_children(parent_thread_id)
82
+ .await
83
+ .map_err(internal_error)
84
+ })
85
+ }
86
+
87
+ fn list_thread_spawn_descendants(
88
+ &self,
89
+ root_thread_id: ThreadId,
90
+ status_filter: Option<ThreadSpawnEdgeStatus>,
91
+ ) -> AgentGraphStoreFuture<'_, Vec<ThreadId>> {
92
+ Box::pin(async move {
93
+ match status_filter {
94
+ Some(status) => self
95
+ .state_db
96
+ .list_thread_spawn_descendants_with_status(
97
+ root_thread_id,
98
+ to_state_status(status),
99
+ )
100
+ .await
101
+ .map_err(internal_error),
102
+ None => self
103
+ .state_db
104
+ .list_thread_spawn_descendants(root_thread_id)
105
+ .await
106
+ .map_err(internal_error),
107
+ }
108
+ })
109
+ }
110
+ }
111
+
112
+ fn to_state_status(status: ThreadSpawnEdgeStatus) -> codex_state::DirectionalThreadSpawnEdgeStatus {
113
+ match status {
114
+ ThreadSpawnEdgeStatus::Open => codex_state::DirectionalThreadSpawnEdgeStatus::Open,
115
+ ThreadSpawnEdgeStatus::Closed => codex_state::DirectionalThreadSpawnEdgeStatus::Closed,
116
+ }
117
+ }
118
+
119
+ fn internal_error(err: impl std::fmt::Display) -> AgentGraphStoreError {
120
+ AgentGraphStoreError::Internal {
121
+ message: err.to_string(),
122
+ }
123
+ }
124
+
125
+ #[cfg(test)]
126
+ mod tests {
127
+ use super::*;
128
+ use codex_state::DirectionalThreadSpawnEdgeStatus;
129
+ use codex_utils_absolute_path::test_support::PathExt;
130
+ use pretty_assertions::assert_eq;
131
+ use tempfile::TempDir;
132
+
133
+ struct TestRuntime {
134
+ state_db: Arc<StateRuntime>,
135
+ _codex_home: TempDir,
136
+ }
137
+
138
+ fn thread_id(suffix: u128) -> ThreadId {
139
+ ThreadId::from_string(&format!("00000000-0000-0000-0000-{suffix:012}"))
140
+ .expect("valid thread id")
141
+ }
142
+
143
+ async fn state_runtime() -> TestRuntime {
144
+ let codex_home = TempDir::new().expect("tempdir should be created");
145
+ let state_db = StateRuntime::init(
146
+ codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()),
147
+ "test-provider".to_string(),
148
+ )
149
+ .await
150
+ .expect("state db should initialize");
151
+ TestRuntime {
152
+ state_db,
153
+ _codex_home: codex_home,
154
+ }
155
+ }
156
+
157
+ #[tokio::test]
158
+ async fn local_store_upserts_and_lists_direct_children_with_status_filters() {
159
+ let fixture = state_runtime().await;
160
+ let state_db = fixture.state_db;
161
+ let store = LocalAgentGraphStore::new(state_db.clone());
162
+ let parent_thread_id = thread_id(/*suffix*/ 1);
163
+ let first_child_thread_id = thread_id(/*suffix*/ 2);
164
+ let second_child_thread_id = thread_id(/*suffix*/ 3);
165
+
166
+ store
167
+ .upsert_thread_spawn_edge(
168
+ parent_thread_id,
169
+ second_child_thread_id,
170
+ ThreadSpawnEdgeStatus::Closed,
171
+ )
172
+ .await
173
+ .expect("closed child edge should insert");
174
+ store
175
+ .upsert_thread_spawn_edge(
176
+ parent_thread_id,
177
+ first_child_thread_id,
178
+ ThreadSpawnEdgeStatus::Open,
179
+ )
180
+ .await
181
+ .expect("open child edge should insert");
182
+
183
+ let all_children = store
184
+ .list_thread_spawn_children(parent_thread_id, /*status_filter*/ None)
185
+ .await
186
+ .expect("all children should load");
187
+ assert_eq!(
188
+ all_children,
189
+ vec![first_child_thread_id, second_child_thread_id]
190
+ );
191
+
192
+ let open_children = store
193
+ .list_thread_spawn_children(parent_thread_id, Some(ThreadSpawnEdgeStatus::Open))
194
+ .await
195
+ .expect("open children should load");
196
+ let state_open_children = state_db
197
+ .list_thread_spawn_children_with_status(
198
+ parent_thread_id,
199
+ DirectionalThreadSpawnEdgeStatus::Open,
200
+ )
201
+ .await
202
+ .expect("state open children should load");
203
+ assert_eq!(open_children, state_open_children);
204
+ assert_eq!(open_children, vec![first_child_thread_id]);
205
+
206
+ let closed_children = store
207
+ .list_thread_spawn_children(parent_thread_id, Some(ThreadSpawnEdgeStatus::Closed))
208
+ .await
209
+ .expect("closed children should load");
210
+ assert_eq!(closed_children, vec![second_child_thread_id]);
211
+ }
212
+
213
+ #[tokio::test]
214
+ async fn local_store_updates_edge_status() {
215
+ let fixture = state_runtime().await;
216
+ let state_db = fixture.state_db;
217
+ let store = LocalAgentGraphStore::new(state_db);
218
+ let parent_thread_id = thread_id(/*suffix*/ 10);
219
+ let child_thread_id = thread_id(/*suffix*/ 11);
220
+
221
+ store
222
+ .upsert_thread_spawn_edge(
223
+ parent_thread_id,
224
+ child_thread_id,
225
+ ThreadSpawnEdgeStatus::Open,
226
+ )
227
+ .await
228
+ .expect("child edge should insert");
229
+ store
230
+ .set_thread_spawn_edge_status(child_thread_id, ThreadSpawnEdgeStatus::Closed)
231
+ .await
232
+ .expect("child edge should close");
233
+
234
+ let open_children = store
235
+ .list_thread_spawn_children(parent_thread_id, Some(ThreadSpawnEdgeStatus::Open))
236
+ .await
237
+ .expect("open children should load");
238
+ assert_eq!(open_children, Vec::<ThreadId>::new());
239
+
240
+ let closed_children = store
241
+ .list_thread_spawn_children(parent_thread_id, Some(ThreadSpawnEdgeStatus::Closed))
242
+ .await
243
+ .expect("closed children should load");
244
+ assert_eq!(closed_children, vec![child_thread_id]);
245
+ }
246
+
247
+ #[tokio::test]
248
+ async fn local_store_lists_descendants_breadth_first_with_status_filters() {
249
+ let fixture = state_runtime().await;
250
+ let state_db = fixture.state_db;
251
+ let store = LocalAgentGraphStore::new(state_db.clone());
252
+ let root_thread_id = thread_id(/*suffix*/ 20);
253
+ let later_child_thread_id = thread_id(/*suffix*/ 22);
254
+ let earlier_child_thread_id = thread_id(/*suffix*/ 21);
255
+ let closed_grandchild_thread_id = thread_id(/*suffix*/ 23);
256
+ let open_grandchild_thread_id = thread_id(/*suffix*/ 24);
257
+ let closed_child_thread_id = thread_id(/*suffix*/ 25);
258
+ let closed_great_grandchild_thread_id = thread_id(/*suffix*/ 26);
259
+
260
+ for (parent_thread_id, child_thread_id, status) in [
261
+ (
262
+ root_thread_id,
263
+ later_child_thread_id,
264
+ ThreadSpawnEdgeStatus::Open,
265
+ ),
266
+ (
267
+ root_thread_id,
268
+ earlier_child_thread_id,
269
+ ThreadSpawnEdgeStatus::Open,
270
+ ),
271
+ (
272
+ earlier_child_thread_id,
273
+ open_grandchild_thread_id,
274
+ ThreadSpawnEdgeStatus::Open,
275
+ ),
276
+ (
277
+ later_child_thread_id,
278
+ closed_grandchild_thread_id,
279
+ ThreadSpawnEdgeStatus::Closed,
280
+ ),
281
+ (
282
+ root_thread_id,
283
+ closed_child_thread_id,
284
+ ThreadSpawnEdgeStatus::Closed,
285
+ ),
286
+ (
287
+ closed_child_thread_id,
288
+ closed_great_grandchild_thread_id,
289
+ ThreadSpawnEdgeStatus::Closed,
290
+ ),
291
+ ] {
292
+ store
293
+ .upsert_thread_spawn_edge(parent_thread_id, child_thread_id, status)
294
+ .await
295
+ .expect("edge should insert");
296
+ }
297
+
298
+ let all_descendants = store
299
+ .list_thread_spawn_descendants(root_thread_id, /*status_filter*/ None)
300
+ .await
301
+ .expect("all descendants should load");
302
+ assert_eq!(
303
+ all_descendants,
304
+ vec![
305
+ earlier_child_thread_id,
306
+ later_child_thread_id,
307
+ closed_child_thread_id,
308
+ closed_grandchild_thread_id,
309
+ open_grandchild_thread_id,
310
+ closed_great_grandchild_thread_id,
311
+ ]
312
+ );
313
+
314
+ let open_descendants = store
315
+ .list_thread_spawn_descendants(root_thread_id, Some(ThreadSpawnEdgeStatus::Open))
316
+ .await
317
+ .expect("open descendants should load");
318
+ let state_open_descendants = state_db
319
+ .list_thread_spawn_descendants_with_status(
320
+ root_thread_id,
321
+ DirectionalThreadSpawnEdgeStatus::Open,
322
+ )
323
+ .await
324
+ .expect("state open descendants should load");
325
+ assert_eq!(open_descendants, state_open_descendants);
326
+ assert_eq!(
327
+ open_descendants,
328
+ vec![
329
+ earlier_child_thread_id,
330
+ later_child_thread_id,
331
+ open_grandchild_thread_id,
332
+ ]
333
+ );
334
+
335
+ let closed_descendants = store
336
+ .list_thread_spawn_descendants(root_thread_id, Some(ThreadSpawnEdgeStatus::Closed))
337
+ .await
338
+ .expect("closed descendants should load");
339
+ assert_eq!(
340
+ closed_descendants,
341
+ vec![closed_child_thread_id, closed_great_grandchild_thread_id]
342
+ );
343
+ }
344
+ }
codex-rs/agent-graph-store/src/store.rs ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::future::Future;
2
+ use std::pin::Pin;
3
+
4
+ use codex_protocol::ThreadId;
5
+
6
+ use crate::AgentGraphStoreResult;
7
+ use crate::ThreadSpawnEdgeStatus;
8
+
9
+ /// Future returned by [`AgentGraphStore`] operations.
10
+ pub type AgentGraphStoreFuture<'a, T> =
11
+ Pin<Box<dyn Future<Output = AgentGraphStoreResult<T>> + Send + 'a>>;
12
+
13
+ /// Storage-neutral boundary for persisted thread-spawn parent/child topology.
14
+ ///
15
+ /// Implementations are expected to return stable ordering for list methods so callers can merge
16
+ /// persisted graph state with live in-memory state without introducing nondeterministic output.
17
+ pub trait AgentGraphStore: Send + Sync {
18
+ /// Insert or replace the directional parent/child edge for a spawned thread.
19
+ ///
20
+ /// `child_thread_id` has at most one persisted parent. Re-inserting the same child should
21
+ /// update both the parent and status to match the supplied values.
22
+ fn upsert_thread_spawn_edge(
23
+ &self,
24
+ parent_thread_id: ThreadId,
25
+ child_thread_id: ThreadId,
26
+ status: ThreadSpawnEdgeStatus,
27
+ ) -> AgentGraphStoreFuture<'_, ()>;
28
+
29
+ /// Update the persisted lifecycle status of a spawned thread's incoming edge.
30
+ ///
31
+ /// Implementations should treat missing children as a successful no-op.
32
+ fn set_thread_spawn_edge_status(
33
+ &self,
34
+ child_thread_id: ThreadId,
35
+ status: ThreadSpawnEdgeStatus,
36
+ ) -> AgentGraphStoreFuture<'_, ()>;
37
+
38
+ /// List direct spawned children of a parent thread.
39
+ ///
40
+ /// When `status_filter` is `Some`, only child edges with that exact status are returned. When
41
+ /// it is `None`, all direct child edges are returned regardless of status, including statuses
42
+ /// that may be added by a future store implementation.
43
+ fn list_thread_spawn_children(
44
+ &self,
45
+ parent_thread_id: ThreadId,
46
+ status_filter: Option<ThreadSpawnEdgeStatus>,
47
+ ) -> AgentGraphStoreFuture<'_, Vec<ThreadId>>;
48
+
49
+ /// List spawned descendants breadth-first by depth, then by thread id.
50
+ ///
51
+ /// `status_filter` is applied to every traversed edge, not just to the returned descendants.
52
+ /// For example, `Some(Open)` walks only open edges, so descendants under a closed edge are not
53
+ /// included even if their own incoming edge is open. `None` walks and returns every persisted
54
+ /// edge regardless of status.
55
+ fn list_thread_spawn_descendants(
56
+ &self,
57
+ root_thread_id: ThreadId,
58
+ status_filter: Option<ThreadSpawnEdgeStatus>,
59
+ ) -> AgentGraphStoreFuture<'_, Vec<ThreadId>>;
60
+ }
codex-rs/agent-graph-store/src/types.rs ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use serde::Deserialize;
2
+ use serde::Serialize;
3
+
4
+ /// Lifecycle status attached to a directional thread-spawn edge.
5
+ #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
6
+ #[serde(rename_all = "snake_case")]
7
+ pub enum ThreadSpawnEdgeStatus {
8
+ /// The child thread is still live or resumable as an open spawned agent.
9
+ Open,
10
+ /// The child thread has been closed from the parent/child graph's perspective.
11
+ Closed,
12
+ }
13
+
14
+ #[cfg(test)]
15
+ mod tests {
16
+ use super::*;
17
+ use pretty_assertions::assert_eq;
18
+
19
+ #[test]
20
+ fn thread_spawn_edge_status_serializes_as_snake_case() {
21
+ assert_eq!(
22
+ serde_json::to_string(&ThreadSpawnEdgeStatus::Open)
23
+ .expect("open status should serialize"),
24
+ "\"open\""
25
+ );
26
+ assert_eq!(
27
+ serde_json::to_string(&ThreadSpawnEdgeStatus::Closed)
28
+ .expect("closed status should serialize"),
29
+ "\"closed\""
30
+ );
31
+ assert_eq!(
32
+ serde_json::from_str::<ThreadSpawnEdgeStatus>("\"open\"")
33
+ .expect("open status should deserialize"),
34
+ ThreadSpawnEdgeStatus::Open
35
+ );
36
+ assert_eq!(
37
+ serde_json::from_str::<ThreadSpawnEdgeStatus>("\"closed\"")
38
+ .expect("closed status should deserialize"),
39
+ ThreadSpawnEdgeStatus::Closed
40
+ );
41
+ }
42
+ }
codex-rs/apply-patch/src/file_update.rs ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use codex_exec_server::ExecutorFileSystem;
2
+ use codex_exec_server::FileSystemSandboxContext;
3
+ use codex_exec_server::ReadFileOptions;
4
+ use codex_utils_path_uri::PathUri;
5
+ use similar::TextDiff;
6
+
7
+ use crate::ApplyPatchError;
8
+ use crate::ApplyPatchFileUpdateMode;
9
+ use crate::IoError;
10
+ use crate::UpdateFileChunk;
11
+ use crate::seek_sequence;
12
+ use crate::text_file::Replacement;
13
+ use crate::text_file::SourceFile;
14
+
15
+ #[cfg(test)]
16
+ #[path = "file_update_tests.rs"]
17
+ mod tests;
18
+
19
+ pub(crate) struct AppliedPatch {
20
+ pub(crate) original_contents: String,
21
+ pub(crate) new_contents: String,
22
+ }
23
+
24
+ /// Return *only* the new file contents (joined into a single `String`) after
25
+ /// applying the chunks to the file at `path`.
26
+ pub(crate) async fn derive_new_contents_from_chunks(
27
+ path: &PathUri,
28
+ chunks: &[UpdateFileChunk],
29
+ update_file_mode: ApplyPatchFileUpdateMode,
30
+ fs: &dyn ExecutorFileSystem,
31
+ follow_symlinks: bool,
32
+ sandbox: Option<&FileSystemSandboxContext>,
33
+ ) -> std::result::Result<AppliedPatch, ApplyPatchError> {
34
+ let original_contents = fs
35
+ .read_file_text(path, ReadFileOptions { follow_symlinks }, sandbox)
36
+ .await
37
+ .map_err(|err| {
38
+ ApplyPatchError::IoError(IoError {
39
+ context: format!(
40
+ "Failed to read file to update {}",
41
+ path.inferred_native_path_string()
42
+ ),
43
+ source: err,
44
+ })
45
+ })?;
46
+
47
+ let path_text = path.inferred_native_path_string();
48
+ let new_contents = match update_file_mode {
49
+ ApplyPatchFileUpdateMode::NormalizeToLf => {
50
+ let mut original_lines = original_contents
51
+ .split('\n')
52
+ .map(String::from)
53
+ .collect::<Vec<_>>();
54
+
55
+ // Drop the trailing empty element that results from the final newline so
56
+ // that line counts match the behaviour of standard `diff`.
57
+ if original_lines.last().is_some_and(String::is_empty) {
58
+ original_lines.pop();
59
+ }
60
+
61
+ let replacements =
62
+ compute_replacements(&original_lines, &path_text, chunks, update_file_mode)?;
63
+ let mut new_lines = apply_replacements(original_lines, &replacements);
64
+ if !new_lines.last().is_some_and(String::is_empty) {
65
+ new_lines.push(String::new());
66
+ }
67
+ new_lines.join("\n")
68
+ }
69
+ ApplyPatchFileUpdateMode::PreserveLineEndings => {
70
+ let mut source_file = SourceFile::parse(&original_contents);
71
+ let original_lines = source_file.line_texts();
72
+ let replacements =
73
+ compute_replacements(&original_lines, &path_text, chunks, update_file_mode)?;
74
+ source_file.apply_replacements(&replacements);
75
+ source_file.into_contents()
76
+ }
77
+ };
78
+ Ok(AppliedPatch {
79
+ original_contents,
80
+ new_contents,
81
+ })
82
+ }
83
+
84
+ /// Compute a list of replacements needed to transform `original_lines` into the
85
+ /// new lines, given the patch `chunks`. Each replacement is returned as
86
+ /// `(start_index, old_len, new_lines)`.
87
+ fn compute_replacements(
88
+ original_lines: &[String],
89
+ path: &str,
90
+ chunks: &[UpdateFileChunk],
91
+ update_file_mode: ApplyPatchFileUpdateMode,
92
+ ) -> std::result::Result<Vec<Replacement>, ApplyPatchError> {
93
+ let mut replacements: Vec<Replacement> = Vec::new();
94
+ let mut line_index: usize = 0;
95
+
96
+ for chunk in chunks {
97
+ // If a chunk has a `change_context`, we use seek_sequence to find it, then
98
+ // adjust our `line_index` to continue from there.
99
+ if let Some(ctx_line) = &chunk.change_context {
100
+ if let Some(idx) = seek_sequence::seek_sequence(
101
+ original_lines,
102
+ std::slice::from_ref(ctx_line),
103
+ line_index,
104
+ /*eof*/ false,
105
+ update_file_mode,
106
+ ) {
107
+ line_index = idx + 1;
108
+ } else {
109
+ return Err(ApplyPatchError::ComputeReplacements(format!(
110
+ "Failed to find context '{ctx_line}' in {path}"
111
+ )));
112
+ }
113
+ }
114
+
115
+ if chunk.old_lines.is_empty() {
116
+ // Preserve the legacy split representation's handling of a final
117
+ // empty line. `SourceFile` only exposes real source lines, so its
118
+ // insertion point is always after the final line.
119
+ let insertion_idx = match update_file_mode {
120
+ ApplyPatchFileUpdateMode::NormalizeToLf => {
121
+ if original_lines.last().is_some_and(String::is_empty) {
122
+ original_lines.len() - 1
123
+ } else {
124
+ original_lines.len()
125
+ }
126
+ }
127
+ ApplyPatchFileUpdateMode::PreserveLineEndings => original_lines.len(),
128
+ };
129
+ replacements.push((insertion_idx, 0, chunk.new_lines.clone()));
130
+ continue;
131
+ }
132
+
133
+ // Otherwise, try to match the existing lines in the file with the old lines
134
+ // from the chunk. If found, schedule that region for replacement.
135
+ // Attempt to locate the `old_lines` verbatim within the file. In many
136
+ // real‑world diffs the last element of `old_lines` is an *empty* string
137
+ // representing the terminating newline of the region being replaced.
138
+ // This sentinel is not present in `original_lines` because `SourceFile`
139
+ // stores the terminator on the preceding line rather than as an extra
140
+ // trailing element. If a direct search fails and the pattern ends with
141
+ // an empty string, retry without that final element so modifications
142
+ // touching the end‑of‑file can be located reliably.
143
+
144
+ let mut pattern: &[String] = &chunk.old_lines;
145
+ let mut found = seek_sequence::seek_sequence(
146
+ original_lines,
147
+ pattern,
148
+ line_index,
149
+ chunk.is_end_of_file,
150
+ update_file_mode,
151
+ );
152
+
153
+ let mut new_slice: &[String] = &chunk.new_lines;
154
+
155
+ if found.is_none() && pattern.last().is_some_and(String::is_empty) {
156
+ // Retry without the trailing empty line which represents the final
157
+ // newline in the file.
158
+ pattern = &pattern[..pattern.len() - 1];
159
+ if new_slice.last().is_some_and(String::is_empty) {
160
+ new_slice = &new_slice[..new_slice.len() - 1];
161
+ }
162
+
163
+ found = seek_sequence::seek_sequence(
164
+ original_lines,
165
+ pattern,
166
+ line_index,
167
+ chunk.is_end_of_file,
168
+ update_file_mode,
169
+ );
170
+ }
171
+
172
+ if let Some(start_idx) = found {
173
+ match update_file_mode {
174
+ ApplyPatchFileUpdateMode::NormalizeToLf => {
175
+ replacements.push((start_idx, pattern.len(), new_slice.to_vec()));
176
+ }
177
+ ApplyPatchFileUpdateMode::PreserveLineEndings => {
178
+ // Context lines occur in both sides of a patch chunk. Keep those
179
+ // original lines in place so their exact contents and terminators
180
+ // survive, especially when the file has mixed line endings.
181
+ let mut old_start = 0;
182
+ let mut new_start = 0;
183
+ for &(old_context, new_context) in &chunk.context_line_indices {
184
+ // A trailing empty context line can be removed from `pattern`
185
+ // and `new_slice` above when it represents the final newline.
186
+ if old_context >= pattern.len() || new_context >= new_slice.len() {
187
+ break;
188
+ }
189
+ if old_start != old_context || new_start != new_context {
190
+ replacements.push((
191
+ start_idx + old_start,
192
+ old_context - old_start,
193
+ new_slice[new_start..new_context].to_vec(),
194
+ ));
195
+ }
196
+ old_start = old_context + 1;
197
+ new_start = new_context + 1;
198
+ }
199
+ if old_start != pattern.len() || new_start != new_slice.len() {
200
+ replacements.push((
201
+ start_idx + old_start,
202
+ pattern.len() - old_start,
203
+ new_slice[new_start..].to_vec(),
204
+ ));
205
+ }
206
+ }
207
+ }
208
+ line_index = start_idx + pattern.len();
209
+ } else {
210
+ return Err(ApplyPatchError::ComputeReplacements(format!(
211
+ "Failed to find expected lines in {}:\n{}",
212
+ path,
213
+ chunk.old_lines.join("\n"),
214
+ )));
215
+ }
216
+ }
217
+
218
+ replacements.sort_by_key(|(index, _, _)| *index);
219
+
220
+ Ok(replacements)
221
+ }
222
+
223
+ /// Apply the `(start_index, old_len, new_lines)` replacements to `original_lines`,
224
+ /// returning the modified file contents as a vector of lines.
225
+ fn apply_replacements(mut lines: Vec<String>, replacements: &[Replacement]) -> Vec<String> {
226
+ // We must apply replacements in descending order so that earlier replacements
227
+ // don't shift the positions of later ones.
228
+ for (start_idx, old_len, new_segment) in replacements.iter().rev() {
229
+ let start_idx = *start_idx;
230
+ let old_len = *old_len;
231
+
232
+ // Remove old lines.
233
+ for _ in 0..old_len {
234
+ if start_idx < lines.len() {
235
+ lines.remove(start_idx);
236
+ }
237
+ }
238
+
239
+ // Insert new lines.
240
+ for (offset, new_line) in new_segment.iter().enumerate() {
241
+ lines.insert(start_idx + offset, new_line.clone());
242
+ }
243
+ }
244
+
245
+ lines
246
+ }
247
+
248
+ /// Intended result of a file update for apply_patch.
249
+ #[derive(Debug, Eq, PartialEq)]
250
+ pub struct ApplyPatchFileUpdate {
251
+ pub(crate) unified_diff: String,
252
+ pub(crate) original_content: String,
253
+ pub(crate) content: String,
254
+ }
255
+
256
+ pub async fn unified_diff_from_chunks(
257
+ path: &PathUri,
258
+ chunks: &[UpdateFileChunk],
259
+ fs: &dyn ExecutorFileSystem,
260
+ sandbox: Option<&FileSystemSandboxContext>,
261
+ ) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
262
+ unified_diff_from_chunks_with_mode(
263
+ path,
264
+ chunks,
265
+ ApplyPatchFileUpdateMode::default(),
266
+ fs,
267
+ sandbox,
268
+ )
269
+ .await
270
+ }
271
+
272
+ pub(crate) async fn unified_diff_from_chunks_with_mode(
273
+ path: &PathUri,
274
+ chunks: &[UpdateFileChunk],
275
+ update_file_mode: ApplyPatchFileUpdateMode,
276
+ fs: &dyn ExecutorFileSystem,
277
+ sandbox: Option<&FileSystemSandboxContext>,
278
+ ) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
279
+ unified_diff_from_chunks_with_context_and_mode(
280
+ path,
281
+ chunks,
282
+ /*context*/ 1,
283
+ update_file_mode,
284
+ fs,
285
+ sandbox,
286
+ )
287
+ .await
288
+ }
289
+
290
+ pub async fn unified_diff_from_chunks_with_context(
291
+ path: &PathUri,
292
+ chunks: &[UpdateFileChunk],
293
+ context: usize,
294
+ fs: &dyn ExecutorFileSystem,
295
+ sandbox: Option<&FileSystemSandboxContext>,
296
+ ) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
297
+ unified_diff_from_chunks_with_context_and_mode(
298
+ path,
299
+ chunks,
300
+ context,
301
+ ApplyPatchFileUpdateMode::default(),
302
+ fs,
303
+ sandbox,
304
+ )
305
+ .await
306
+ }
307
+
308
+ async fn unified_diff_from_chunks_with_context_and_mode(
309
+ path: &PathUri,
310
+ chunks: &[UpdateFileChunk],
311
+ context: usize,
312
+ update_file_mode: ApplyPatchFileUpdateMode,
313
+ fs: &dyn ExecutorFileSystem,
314
+ sandbox: Option<&FileSystemSandboxContext>,
315
+ ) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
316
+ let AppliedPatch {
317
+ original_contents,
318
+ new_contents,
319
+ } = derive_new_contents_from_chunks(
320
+ path,
321
+ chunks,
322
+ update_file_mode,
323
+ fs,
324
+ /*follow_symlinks*/ true,
325
+ sandbox,
326
+ )
327
+ .await?;
328
+ let text_diff = TextDiff::from_lines(&original_contents, &new_contents);
329
+ let unified_diff = text_diff.unified_diff().context_radius(context).to_string();
330
+ Ok(ApplyPatchFileUpdate {
331
+ unified_diff,
332
+ original_content: original_contents,
333
+ content: new_contents,
334
+ })
335
+ }
codex-rs/apply-patch/src/file_update_tests.rs ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use super::*;
2
+ use crate::Hunk;
3
+ use crate::apply_patch;
4
+ use crate::parse_patch;
5
+ use codex_exec_server::LOCAL_FS;
6
+ use codex_utils_path_uri::PathUri;
7
+ use pretty_assertions::assert_eq;
8
+ use std::fs;
9
+ use tempfile::tempdir;
10
+
11
+ fn wrap_patch(body: &str) -> String {
12
+ format!("*** Begin Patch\n{body}\n*** End Patch")
13
+ }
14
+
15
+ #[tokio::test]
16
+ async fn test_unified_diff() {
17
+ // Start with a file containing four lines.
18
+ let dir = tempdir().unwrap();
19
+ let path = dir.path().join("multi.txt");
20
+ fs::write(&path, "foo\nbar\nbaz\nqux\n").unwrap();
21
+ let patch = wrap_patch(&format!(
22
+ r#"*** Update File: {}
23
+ @@
24
+ foo
25
+ -bar
26
+ +BAR
27
+ @@
28
+ baz
29
+ -qux
30
+ +QUX"#,
31
+ path.display()
32
+ ));
33
+ let patch = parse_patch(&patch).unwrap();
34
+
35
+ let update_file_chunks = match patch.hunks.as_slice() {
36
+ [Hunk::UpdateFile { chunks, .. }] => chunks,
37
+ _ => panic!("Expected a single UpdateFile hunk"),
38
+ };
39
+ let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path");
40
+ let diff = unified_diff_from_chunks(
41
+ &path_uri,
42
+ update_file_chunks,
43
+ LOCAL_FS.as_ref(),
44
+ /*sandbox*/ None,
45
+ )
46
+ .await
47
+ .unwrap();
48
+ let expected_diff = r#"@@ -1,4 +1,4 @@
49
+ foo
50
+ -bar
51
+ +BAR
52
+ baz
53
+ -qux
54
+ +QUX
55
+ "#;
56
+ let expected = ApplyPatchFileUpdate {
57
+ unified_diff: expected_diff.to_string(),
58
+ original_content: "foo\nbar\nbaz\nqux\n".to_string(),
59
+ content: "foo\nBAR\nbaz\nQUX\n".to_string(),
60
+ };
61
+ assert_eq!(expected, diff);
62
+ }
63
+
64
+ #[tokio::test]
65
+ async fn test_unified_diff_first_line_replacement() {
66
+ // Replace the very first line of the file.
67
+ let dir = tempdir().unwrap();
68
+ let path = dir.path().join("first.txt");
69
+ fs::write(&path, "foo\nbar\nbaz\n").unwrap();
70
+
71
+ let patch = wrap_patch(&format!(
72
+ r#"*** Update File: {}
73
+ @@
74
+ -foo
75
+ +FOO
76
+ bar
77
+ "#,
78
+ path.display()
79
+ ));
80
+
81
+ let patch = parse_patch(&patch).unwrap();
82
+ let chunks = match patch.hunks.as_slice() {
83
+ [Hunk::UpdateFile { chunks, .. }] => chunks,
84
+ _ => panic!("Expected a single UpdateFile hunk"),
85
+ };
86
+
87
+ let resolved_path = PathUri::from_host_native_path(&path).expect("absolute test path");
88
+ let diff = unified_diff_from_chunks(
89
+ &resolved_path,
90
+ chunks,
91
+ LOCAL_FS.as_ref(),
92
+ /*sandbox*/ None,
93
+ )
94
+ .await
95
+ .unwrap();
96
+ let expected_diff = r#"@@ -1,2 +1,2 @@
97
+ -foo
98
+ +FOO
99
+ bar
100
+ "#;
101
+ let expected = ApplyPatchFileUpdate {
102
+ unified_diff: expected_diff.to_string(),
103
+ original_content: "foo\nbar\nbaz\n".to_string(),
104
+ content: "FOO\nbar\nbaz\n".to_string(),
105
+ };
106
+ assert_eq!(expected, diff);
107
+ }
108
+
109
+ #[tokio::test]
110
+ async fn test_unified_diff_last_line_replacement() {
111
+ // Replace the very last line of the file.
112
+ let dir = tempdir().unwrap();
113
+ let path = dir.path().join("last.txt");
114
+ fs::write(&path, "foo\nbar\nbaz\n").unwrap();
115
+
116
+ let patch = wrap_patch(&format!(
117
+ r#"*** Update File: {}
118
+ @@
119
+ foo
120
+ bar
121
+ -baz
122
+ +BAZ
123
+ "#,
124
+ path.display()
125
+ ));
126
+
127
+ let patch = parse_patch(&patch).unwrap();
128
+ let chunks = match patch.hunks.as_slice() {
129
+ [Hunk::UpdateFile { chunks, .. }] => chunks,
130
+ _ => panic!("Expected a single UpdateFile hunk"),
131
+ };
132
+
133
+ let resolved_path = PathUri::from_host_native_path(&path).expect("absolute test path");
134
+ let diff = unified_diff_from_chunks(
135
+ &resolved_path,
136
+ chunks,
137
+ LOCAL_FS.as_ref(),
138
+ /*sandbox*/ None,
139
+ )
140
+ .await
141
+ .unwrap();
142
+ let expected_diff = r#"@@ -2,2 +2,2 @@
143
+ bar
144
+ -baz
145
+ +BAZ
146
+ "#;
147
+ let expected = ApplyPatchFileUpdate {
148
+ unified_diff: expected_diff.to_string(),
149
+ original_content: "foo\nbar\nbaz\n".to_string(),
150
+ content: "foo\nbar\nBAZ\n".to_string(),
151
+ };
152
+ assert_eq!(expected, diff);
153
+ }
154
+
155
+ #[tokio::test]
156
+ async fn test_unified_diff_insert_at_eof() {
157
+ // Insert a new line at end-of-file.
158
+ let dir = tempdir().unwrap();
159
+ let path = dir.path().join("insert.txt");
160
+ fs::write(&path, "foo\nbar\nbaz\n").unwrap();
161
+
162
+ let patch = wrap_patch(&format!(
163
+ r#"*** Update File: {}
164
+ @@
165
+ +quux
166
+ *** End of File
167
+ "#,
168
+ path.display()
169
+ ));
170
+
171
+ let patch = parse_patch(&patch).unwrap();
172
+ let chunks = match patch.hunks.as_slice() {
173
+ [Hunk::UpdateFile { chunks, .. }] => chunks,
174
+ _ => panic!("Expected a single UpdateFile hunk"),
175
+ };
176
+
177
+ let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path");
178
+ let diff =
179
+ unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
180
+ .await
181
+ .unwrap();
182
+ let expected_diff = r#"@@ -3 +3,2 @@
183
+ baz
184
+ +quux
185
+ "#;
186
+ let expected = ApplyPatchFileUpdate {
187
+ unified_diff: expected_diff.to_string(),
188
+ original_content: "foo\nbar\nbaz\n".to_string(),
189
+ content: "foo\nbar\nbaz\nquux\n".to_string(),
190
+ };
191
+ assert_eq!(expected, diff);
192
+ }
193
+
194
+ #[tokio::test]
195
+ async fn test_unified_diff_interleaved_changes() {
196
+ // Original file with six lines.
197
+ let dir = tempdir().unwrap();
198
+ let path = dir.path().join("interleaved.txt");
199
+ fs::write(&path, "a\nb\nc\nd\ne\nf\n").unwrap();
200
+
201
+ // Patch replaces two separate lines and appends a new one at EOF using
202
+ // three distinct chunks.
203
+ let patch_body = format!(
204
+ r#"*** Update File: {}
205
+ @@
206
+ a
207
+ -b
208
+ +B
209
+ @@
210
+ d
211
+ -e
212
+ +E
213
+ @@
214
+ f
215
+ +g
216
+ *** End of File"#,
217
+ path.display()
218
+ );
219
+ let patch = wrap_patch(&patch_body);
220
+
221
+ // Extract chunks then build the unified diff.
222
+ let parsed = parse_patch(&patch).unwrap();
223
+ let chunks = match parsed.hunks.as_slice() {
224
+ [Hunk::UpdateFile { chunks, .. }] => chunks,
225
+ _ => panic!("Expected a single UpdateFile hunk"),
226
+ };
227
+
228
+ let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path");
229
+ let diff =
230
+ unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
231
+ .await
232
+ .unwrap();
233
+
234
+ let expected_diff = r#"@@ -1,6 +1,7 @@
235
+ a
236
+ -b
237
+ +B
238
+ c
239
+ d
240
+ -e
241
+ +E
242
+ f
243
+ +g
244
+ "#;
245
+
246
+ let expected = ApplyPatchFileUpdate {
247
+ unified_diff: expected_diff.to_string(),
248
+ original_content: "a\nb\nc\nd\ne\nf\n".to_string(),
249
+ content: "a\nB\nc\nd\nE\nf\ng\n".to_string(),
250
+ };
251
+
252
+ assert_eq!(expected, diff);
253
+
254
+ let mut stdout = Vec::new();
255
+ let mut stderr = Vec::new();
256
+ apply_patch(
257
+ &patch,
258
+ &PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
259
+ &mut stdout,
260
+ &mut stderr,
261
+ LOCAL_FS.as_ref(),
262
+ /*sandbox*/ None,
263
+ )
264
+ .await
265
+ .unwrap();
266
+ let contents = fs::read_to_string(path).unwrap();
267
+ assert_eq!(
268
+ contents,
269
+ r#"a
270
+ B
271
+ c
272
+ d
273
+ E
274
+ f
275
+ g
276
+ "#
277
+ );
278
+ }
codex-rs/apply-patch/src/invocation.rs ADDED
@@ -0,0 +1,1036 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::collections::HashMap;
2
+ use std::sync::LazyLock;
3
+
4
+ use codex_exec_server::ExecutorFileSystem;
5
+ use tree_sitter::Parser;
6
+ use tree_sitter::Query;
7
+ use tree_sitter::QueryCursor;
8
+ use tree_sitter::StreamingIterator;
9
+ use tree_sitter_bash::LANGUAGE as BASH;
10
+
11
+ use crate::ApplyPatchAction;
12
+ use crate::ApplyPatchArgs;
13
+ use crate::ApplyPatchError;
14
+ use crate::ApplyPatchFileChange;
15
+ use crate::ApplyPatchFileUpdate;
16
+ use crate::ApplyPatchFileUpdateMode;
17
+ use crate::IoError;
18
+ use crate::MaybeApplyPatchVerified;
19
+ use crate::parser::Hunk;
20
+ use crate::parser::ParseError;
21
+ use crate::parser::parse_patch;
22
+ use crate::unified_diff_from_chunks_with_mode;
23
+ use codex_utils_path_uri::PathConvention;
24
+ use codex_utils_path_uri::PathUri;
25
+ use std::str::Utf8Error;
26
+ use tree_sitter::LanguageError;
27
+
28
+ const APPLY_PATCH_COMMANDS: [&str; 2] = ["apply_patch", "applypatch"];
29
+
30
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
31
+ enum ApplyPatchShell {
32
+ Unix,
33
+ PowerShell,
34
+ Cmd,
35
+ }
36
+
37
+ #[derive(Debug, PartialEq)]
38
+ pub enum MaybeApplyPatch {
39
+ Body(ApplyPatchArgs),
40
+ ShellParseError(ExtractHeredocError),
41
+ PatchParseError(ParseError),
42
+ NotApplyPatch,
43
+ }
44
+
45
+ #[derive(Debug, PartialEq)]
46
+ pub enum ExtractHeredocError {
47
+ CommandDidNotStartWithApplyPatch,
48
+ FailedToLoadBashGrammar(LanguageError),
49
+ HeredocNotUtf8(Utf8Error),
50
+ FailedToParsePatchIntoAst,
51
+ FailedToFindHeredocBody,
52
+ }
53
+
54
+ fn classify_shell_name(shell: &str, convention: PathConvention) -> Option<String> {
55
+ let basename = convention.path_segments(shell).next_back()?;
56
+ let stem = basename
57
+ .rsplit_once('.')
58
+ .and_then(|(stem, _extension)| (!stem.is_empty()).then_some(stem))
59
+ .unwrap_or(basename);
60
+ Some(stem.to_ascii_lowercase())
61
+ }
62
+
63
+ fn classify_shell(shell: &str, flag: &str, convention: PathConvention) -> Option<ApplyPatchShell> {
64
+ classify_shell_name(shell, convention).and_then(|name| match name.as_str() {
65
+ "bash" | "zsh" | "sh" if matches!(flag, "-lc" | "-c") => Some(ApplyPatchShell::Unix),
66
+ "pwsh" | "powershell" if flag.eq_ignore_ascii_case("-command") => {
67
+ Some(ApplyPatchShell::PowerShell)
68
+ }
69
+ "cmd" if flag.eq_ignore_ascii_case("/c") => Some(ApplyPatchShell::Cmd),
70
+ _ => None,
71
+ })
72
+ }
73
+
74
+ fn can_skip_flag(shell: &str, flag: &str, convention: PathConvention) -> bool {
75
+ classify_shell_name(shell, convention).is_some_and(|name| {
76
+ matches!(name.as_str(), "pwsh" | "powershell") && flag.eq_ignore_ascii_case("-noprofile")
77
+ })
78
+ }
79
+
80
+ fn parse_shell_script<'a>(argv: &'a [String], cwd: &PathUri) -> Option<(ApplyPatchShell, &'a str)> {
81
+ let convention = cwd.infer_path_convention()?;
82
+ match argv {
83
+ [shell, flag, script] => classify_shell(shell, flag, convention).map(|shell_type| {
84
+ let script = script.as_str();
85
+ (shell_type, script)
86
+ }),
87
+ [shell, skip_flag, flag, script] => {
88
+ if !can_skip_flag(shell, skip_flag, convention) {
89
+ return None;
90
+ }
91
+ classify_shell(shell, flag, convention).map(|shell_type| {
92
+ let script = script.as_str();
93
+ (shell_type, script)
94
+ })
95
+ }
96
+ _ => None,
97
+ }
98
+ }
99
+
100
+ fn extract_apply_patch_from_shell(
101
+ shell: ApplyPatchShell,
102
+ script: &str,
103
+ ) -> std::result::Result<(String, Option<String>), ExtractHeredocError> {
104
+ match shell {
105
+ ApplyPatchShell::Unix | ApplyPatchShell::PowerShell | ApplyPatchShell::Cmd => {
106
+ extract_apply_patch_from_bash(script)
107
+ }
108
+ }
109
+ }
110
+
111
+ // TODO: make private once we remove tests in lib.rs
112
+ /// `cwd` supplies the path convention used to interpret the shell executable in `argv`.
113
+ pub fn maybe_parse_apply_patch(argv: &[String], cwd: &PathUri) -> MaybeApplyPatch {
114
+ match argv {
115
+ // Direct invocation: apply_patch <patch>
116
+ [cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => match parse_patch(body) {
117
+ Ok(source) => MaybeApplyPatch::Body(source),
118
+ Err(e) => MaybeApplyPatch::PatchParseError(e),
119
+ },
120
+ // Shell heredoc form: (optional `cd <path> &&`) apply_patch <<'EOF' ...
121
+ _ => match parse_shell_script(argv, cwd) {
122
+ Some((shell, script)) => match extract_apply_patch_from_shell(shell, script) {
123
+ Ok((body, workdir)) => match parse_patch(&body) {
124
+ Ok(mut source) => {
125
+ source.workdir = workdir;
126
+ MaybeApplyPatch::Body(source)
127
+ }
128
+ Err(e) => MaybeApplyPatch::PatchParseError(e),
129
+ },
130
+ Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch) => {
131
+ MaybeApplyPatch::NotApplyPatch
132
+ }
133
+ Err(e) => MaybeApplyPatch::ShellParseError(e),
134
+ },
135
+ None => MaybeApplyPatch::NotApplyPatch,
136
+ },
137
+ }
138
+ }
139
+
140
+ /// `cwd` must identify an absolute environment-native path so relative patch paths can be
141
+ /// resolved without projecting them onto the app-server or exec-server host.
142
+ pub async fn maybe_parse_apply_patch_verified(
143
+ argv: &[String],
144
+ cwd: &PathUri,
145
+ fs: &dyn ExecutorFileSystem,
146
+ sandbox: Option<&codex_exec_server::FileSystemSandboxContext>,
147
+ ) -> MaybeApplyPatchVerified {
148
+ maybe_parse_apply_patch_verified_with_mode(
149
+ argv,
150
+ cwd,
151
+ ApplyPatchFileUpdateMode::default(),
152
+ fs,
153
+ sandbox,
154
+ )
155
+ .await
156
+ }
157
+
158
+ /// Parses and verifies an `apply_patch` invocation using the selected
159
+ /// file-update mode.
160
+ pub async fn maybe_parse_apply_patch_verified_with_mode(
161
+ argv: &[String],
162
+ cwd: &PathUri,
163
+ update_file_mode: ApplyPatchFileUpdateMode,
164
+ fs: &dyn ExecutorFileSystem,
165
+ sandbox: Option<&codex_exec_server::FileSystemSandboxContext>,
166
+ ) -> MaybeApplyPatchVerified {
167
+ // Detect a raw patch body passed directly as the command or as the body of a shell
168
+ // script. In these cases, report an explicit error rather than applying the patch.
169
+ if let [body] = argv
170
+ && parse_patch(body).is_ok()
171
+ {
172
+ return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation);
173
+ }
174
+ if let Some((_, script)) = parse_shell_script(argv, cwd)
175
+ && parse_patch(script).is_ok()
176
+ {
177
+ return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation);
178
+ }
179
+
180
+ match maybe_parse_apply_patch(argv, cwd) {
181
+ MaybeApplyPatch::Body(args) => {
182
+ verify_apply_patch_args_with_mode(args, cwd, update_file_mode, fs, sandbox).await
183
+ }
184
+ MaybeApplyPatch::ShellParseError(e) => MaybeApplyPatchVerified::ShellParseError(e),
185
+ MaybeApplyPatch::PatchParseError(e) => MaybeApplyPatchVerified::CorrectnessError(e.into()),
186
+ MaybeApplyPatch::NotApplyPatch => MaybeApplyPatchVerified::NotApplyPatch,
187
+ }
188
+ }
189
+
190
+ pub async fn verify_apply_patch_args(
191
+ args: ApplyPatchArgs,
192
+ cwd: &PathUri,
193
+ fs: &dyn ExecutorFileSystem,
194
+ sandbox: Option<&codex_exec_server::FileSystemSandboxContext>,
195
+ ) -> MaybeApplyPatchVerified {
196
+ verify_apply_patch_args_with_mode(args, cwd, ApplyPatchFileUpdateMode::default(), fs, sandbox)
197
+ .await
198
+ }
199
+
200
+ /// Verifies parsed patch arguments using the selected file-update mode.
201
+ pub async fn verify_apply_patch_args_with_mode(
202
+ args: ApplyPatchArgs,
203
+ cwd: &PathUri,
204
+ update_file_mode: ApplyPatchFileUpdateMode,
205
+ fs: &dyn ExecutorFileSystem,
206
+ sandbox: Option<&codex_exec_server::FileSystemSandboxContext>,
207
+ ) -> MaybeApplyPatchVerified {
208
+ match try_verify_apply_patch_args(args, cwd, update_file_mode, fs, sandbox).await {
209
+ Ok(action) => MaybeApplyPatchVerified::Body(action),
210
+ Err(err) => MaybeApplyPatchVerified::CorrectnessError(err),
211
+ }
212
+ }
213
+
214
+ async fn try_verify_apply_patch_args(
215
+ args: ApplyPatchArgs,
216
+ cwd: &PathUri,
217
+ update_file_mode: ApplyPatchFileUpdateMode,
218
+ fs: &dyn ExecutorFileSystem,
219
+ sandbox: Option<&codex_exec_server::FileSystemSandboxContext>,
220
+ ) -> Result<ApplyPatchAction, ApplyPatchError> {
221
+ let ApplyPatchArgs {
222
+ patch,
223
+ hunks,
224
+ workdir,
225
+ ..
226
+ } = args;
227
+ let effective_cwd = workdir
228
+ .as_ref()
229
+ .map(|dir| cwd.join(dir))
230
+ .transpose()?
231
+ .unwrap_or_else(|| cwd.clone());
232
+ let mut changes = HashMap::new();
233
+ for hunk in hunks {
234
+ let path = hunk.resolve_path(&effective_cwd)?;
235
+ if changes.contains_key(&path) {
236
+ return Err(ParseError::InvalidPatchError(format!(
237
+ "multiple operations target {}",
238
+ path.inferred_native_path_string()
239
+ ))
240
+ .into());
241
+ }
242
+ match hunk {
243
+ Hunk::AddFile { contents, .. } => {
244
+ changes.insert(path, ApplyPatchFileChange::Add { content: contents });
245
+ }
246
+ Hunk::DeleteFile { .. } => {
247
+ let content = fs
248
+ .read_file_text(&path, Default::default(), sandbox)
249
+ .await
250
+ .map_err(|source| {
251
+ ApplyPatchError::IoError(IoError {
252
+ context: format!(
253
+ "Failed to read {}",
254
+ path.inferred_native_path_string()
255
+ ),
256
+ source,
257
+ })
258
+ })?;
259
+ changes.insert(path, ApplyPatchFileChange::Delete { content });
260
+ }
261
+ Hunk::UpdateFile {
262
+ move_path, chunks, ..
263
+ } => {
264
+ let ApplyPatchFileUpdate {
265
+ unified_diff,
266
+ content: contents,
267
+ ..
268
+ } = unified_diff_from_chunks_with_mode(
269
+ &path,
270
+ &chunks,
271
+ update_file_mode,
272
+ fs,
273
+ sandbox,
274
+ )
275
+ .await?;
276
+ changes.insert(
277
+ path,
278
+ ApplyPatchFileChange::Update {
279
+ unified_diff,
280
+ move_path: move_path
281
+ .map(|path| effective_cwd.join(&path.to_string_lossy()))
282
+ .transpose()?,
283
+ new_content: contents,
284
+ },
285
+ );
286
+ }
287
+ }
288
+ }
289
+ Ok(ApplyPatchAction {
290
+ changes,
291
+ update_file_mode,
292
+ patch,
293
+ cwd: effective_cwd,
294
+ })
295
+ }
296
+
297
+ /// Extract the heredoc body (and optional `cd` workdir) from a `bash -lc` script
298
+ /// that invokes the apply_patch tool using a heredoc.
299
+ ///
300
+ /// Supported top‑level forms (must be the only top‑level statement):
301
+ /// - `apply_patch <<'EOF'\n...\nEOF`
302
+ /// - `cd <path> && apply_patch <<'EOF'\n...\nEOF`
303
+ ///
304
+ /// Notes about matching:
305
+ /// - Parsed with Tree‑sitter Bash and a strict query that uses anchors so the
306
+ /// heredoc‑redirected statement is the only top‑level statement.
307
+ /// - The connector between `cd` and `apply_patch` must be `&&` (not `|` or `||`).
308
+ /// - Exactly one positional `word` argument is allowed for `cd` (no flags, no quoted
309
+ /// strings, no second argument).
310
+ /// - The apply command is validated in‑query via `#any-of?` to allow `apply_patch`
311
+ /// or `applypatch`.
312
+ /// - Preceding or trailing commands (e.g., `echo ...;` or `... && echo done`) do not match.
313
+ ///
314
+ /// Returns `(heredoc_body, Some(path))` when the `cd` variant matches, or
315
+ /// `(heredoc_body, None)` for the direct form. Errors are returned if the script
316
+ /// cannot be parsed or does not match the allowed patterns.
317
+ fn extract_apply_patch_from_bash(
318
+ src: &str,
319
+ ) -> std::result::Result<(String, Option<String>), ExtractHeredocError> {
320
+ // This function uses a Tree-sitter query to recognize one of two
321
+ // whole-script forms, each expressed as a single top-level statement:
322
+ //
323
+ // 1. apply_patch <<'EOF'\n...\nEOF
324
+ // 2. cd <path> && apply_patch <<'EOF'\n...\nEOF
325
+ //
326
+ // Key ideas when reading the query:
327
+ // - dots (`.`) between named nodes enforces adjacency among named children and
328
+ // anchor to the start/end of the expression.
329
+ // - we match a single redirected_statement directly under program with leading
330
+ // and trailing anchors (`.`). This ensures it is the only top-level statement
331
+ // (so prefixes like `echo ...;` or suffixes like `... && echo done` do not match).
332
+ //
333
+ // Overall, we want to be conservative and only match the intended forms, as other
334
+ // forms are likely to be model errors, or incorrectly interpreted by later code.
335
+ //
336
+ // If you're editing this query, it's helpful to start by creating a debugging binary
337
+ // which will let you see the AST of an arbitrary bash script passed in, and optionally
338
+ // also run an arbitrary query against the AST. This is useful for understanding
339
+ // how tree-sitter parses the script and whether the query syntax is correct. Be sure
340
+ // to test both positive and negative cases.
341
+ static APPLY_PATCH_QUERY: LazyLock<Query> = LazyLock::new(|| {
342
+ let language = BASH.into();
343
+ #[expect(clippy::expect_used)]
344
+ Query::new(
345
+ &language,
346
+ r#"
347
+ (
348
+ program
349
+ . (redirected_statement
350
+ body: (command
351
+ name: (command_name (word) @apply_name) .)
352
+ (#any-of? @apply_name "apply_patch" "applypatch")
353
+ redirect: (heredoc_redirect
354
+ . (heredoc_start)
355
+ . (heredoc_body) @heredoc
356
+ . (heredoc_end)
357
+ .))
358
+ .)
359
+
360
+ (
361
+ program
362
+ . (redirected_statement
363
+ body: (list
364
+ . (command
365
+ name: (command_name (word) @cd_name) .
366
+ argument: [
367
+ (word) @cd_path
368
+ (string (string_content) @cd_path)
369
+ (raw_string) @cd_raw_string
370
+ ] .)
371
+ "&&"
372
+ . (command
373
+ name: (command_name (word) @apply_name))
374
+ .)
375
+ (#eq? @cd_name "cd")
376
+ (#any-of? @apply_name "apply_patch" "applypatch")
377
+ redirect: (heredoc_redirect
378
+ . (heredoc_start)
379
+ . (heredoc_body) @heredoc
380
+ . (heredoc_end)
381
+ .))
382
+ .)
383
+ "#,
384
+ )
385
+ .expect("valid bash query")
386
+ });
387
+
388
+ let lang = BASH.into();
389
+ let mut parser = Parser::new();
390
+ parser
391
+ .set_language(&lang)
392
+ .map_err(ExtractHeredocError::FailedToLoadBashGrammar)?;
393
+ let tree = parser
394
+ .parse(src, None)
395
+ .ok_or(ExtractHeredocError::FailedToParsePatchIntoAst)?;
396
+
397
+ let bytes = src.as_bytes();
398
+ let root = tree.root_node();
399
+
400
+ let mut cursor = QueryCursor::new();
401
+ let mut matches = cursor.matches(&APPLY_PATCH_QUERY, root, bytes);
402
+ while let Some(m) = matches.next() {
403
+ let mut heredoc_text: Option<String> = None;
404
+ let mut cd_path: Option<String> = None;
405
+
406
+ for capture in m.captures.iter() {
407
+ let name = APPLY_PATCH_QUERY.capture_names()[capture.index as usize];
408
+ match name {
409
+ "heredoc" => {
410
+ let text = capture
411
+ .node
412
+ .utf8_text(bytes)
413
+ .map_err(ExtractHeredocError::HeredocNotUtf8)?
414
+ .trim_end_matches('\n')
415
+ .to_string();
416
+ heredoc_text = Some(text);
417
+ }
418
+ "cd_path" => {
419
+ let text = capture
420
+ .node
421
+ .utf8_text(bytes)
422
+ .map_err(ExtractHeredocError::HeredocNotUtf8)?
423
+ .to_string();
424
+ cd_path = Some(text);
425
+ }
426
+ "cd_raw_string" => {
427
+ let raw = capture
428
+ .node
429
+ .utf8_text(bytes)
430
+ .map_err(ExtractHeredocError::HeredocNotUtf8)?;
431
+ let trimmed = raw
432
+ .strip_prefix('\'')
433
+ .and_then(|s| s.strip_suffix('\''))
434
+ .unwrap_or(raw);
435
+ cd_path = Some(trimmed.to_string());
436
+ }
437
+ _ => {}
438
+ }
439
+ }
440
+
441
+ if let Some(heredoc) = heredoc_text {
442
+ return Ok((heredoc, cd_path));
443
+ }
444
+ }
445
+
446
+ Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch)
447
+ }
448
+
449
+ #[cfg(test)]
450
+ mod tests {
451
+ use super::*;
452
+ use crate::unified_diff_from_chunks;
453
+ use assert_matches::assert_matches;
454
+ use codex_exec_server::LOCAL_FS;
455
+ use pretty_assertions::assert_eq;
456
+ use std::fs;
457
+ use std::path::PathBuf;
458
+ use std::string::ToString;
459
+ use tempfile::tempdir;
460
+
461
+ /// Helper to construct a patch with the given body.
462
+ fn wrap_patch(body: &str) -> String {
463
+ format!("*** Begin Patch\n{body}\n*** End Patch")
464
+ }
465
+
466
+ fn strs_to_strings(strs: &[&str]) -> Vec<String> {
467
+ strs.iter().map(ToString::to_string).collect()
468
+ }
469
+
470
+ // Test helpers to reduce repetition when building bash -lc heredoc scripts
471
+ fn args_bash(script: &str) -> Vec<String> {
472
+ strs_to_strings(&["bash", "-lc", script])
473
+ }
474
+
475
+ fn args_powershell(script: &str) -> Vec<String> {
476
+ strs_to_strings(&["powershell.exe", "-Command", script])
477
+ }
478
+
479
+ fn args_powershell_no_profile(script: &str) -> Vec<String> {
480
+ strs_to_strings(&["powershell.exe", "-NoProfile", "-Command", script])
481
+ }
482
+
483
+ fn args_pwsh(script: &str) -> Vec<String> {
484
+ strs_to_strings(&["pwsh", "-NoProfile", "-Command", script])
485
+ }
486
+
487
+ fn args_cmd(script: &str) -> Vec<String> {
488
+ strs_to_strings(&["cmd.exe", "/c", script])
489
+ }
490
+
491
+ fn heredoc_script(prefix: &str) -> String {
492
+ format!(
493
+ "{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH"
494
+ )
495
+ }
496
+
497
+ fn heredoc_script_ps(prefix: &str, suffix: &str) -> String {
498
+ format!(
499
+ "{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH{suffix}"
500
+ )
501
+ }
502
+
503
+ fn expected_single_add() -> Vec<Hunk> {
504
+ vec![Hunk::AddFile {
505
+ path: PathBuf::from("foo"),
506
+ contents: "hi\n".to_string(),
507
+ }]
508
+ }
509
+
510
+ #[track_caller]
511
+ fn assert_match_args(args: Vec<String>, expected_workdir: Option<&str>) {
512
+ assert_match_args_with_cwd(
513
+ args,
514
+ &PathUri::parse("file:///workspace").expect("valid POSIX test cwd"),
515
+ expected_workdir,
516
+ );
517
+ }
518
+
519
+ #[track_caller]
520
+ fn assert_match_args_with_cwd(
521
+ args: Vec<String>,
522
+ cwd: &PathUri,
523
+ expected_workdir: Option<&str>,
524
+ ) {
525
+ match maybe_parse_apply_patch(&args, cwd) {
526
+ MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
527
+ assert_eq!(workdir.as_deref(), expected_workdir);
528
+ assert_eq!(hunks, expected_single_add());
529
+ }
530
+ result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
531
+ }
532
+ }
533
+
534
+ #[track_caller]
535
+ fn assert_match(script: &str, expected_workdir: Option<&str>) {
536
+ let args = args_bash(script);
537
+ assert_match_args(args, expected_workdir);
538
+ }
539
+
540
+ fn assert_not_match(script: &str) {
541
+ let args = args_bash(script);
542
+ assert_matches!(
543
+ maybe_parse_apply_patch(
544
+ &args,
545
+ &PathUri::parse("file:///workspace").expect("valid POSIX test cwd"),
546
+ ),
547
+ MaybeApplyPatch::NotApplyPatch
548
+ );
549
+ }
550
+
551
+ #[tokio::test]
552
+ async fn test_implicit_patch_single_arg_is_error() {
553
+ let patch = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch".to_string();
554
+ let args = vec![patch];
555
+ let dir = tempdir().unwrap();
556
+ assert_matches!(
557
+ maybe_parse_apply_patch_verified(
558
+ &args,
559
+ &PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
560
+ LOCAL_FS.as_ref(),
561
+ /*sandbox*/ None,
562
+ )
563
+ .await,
564
+ MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
565
+ );
566
+ }
567
+
568
+ #[tokio::test]
569
+ async fn test_implicit_patch_bash_script_is_error() {
570
+ let script = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch";
571
+ let args = args_bash(script);
572
+ let dir = tempdir().unwrap();
573
+ assert_matches!(
574
+ maybe_parse_apply_patch_verified(
575
+ &args,
576
+ &PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
577
+ LOCAL_FS.as_ref(),
578
+ /*sandbox*/ None,
579
+ )
580
+ .await,
581
+ MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
582
+ );
583
+ }
584
+
585
+ #[tokio::test]
586
+ async fn test_literal() {
587
+ let args = strs_to_strings(&[
588
+ "apply_patch",
589
+ r#"*** Begin Patch
590
+ *** Add File: foo
591
+ +hi
592
+ *** End Patch
593
+ "#,
594
+ ]);
595
+
596
+ match maybe_parse_apply_patch(
597
+ &args,
598
+ &PathUri::parse("file:///workspace").expect("valid POSIX test cwd"),
599
+ ) {
600
+ MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => {
601
+ assert_eq!(
602
+ hunks,
603
+ vec![Hunk::AddFile {
604
+ path: PathBuf::from("foo"),
605
+ contents: "hi\n".to_string()
606
+ }]
607
+ );
608
+ }
609
+ result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
610
+ }
611
+ }
612
+
613
+ #[tokio::test]
614
+ async fn test_literal_applypatch() {
615
+ let args = strs_to_strings(&[
616
+ "applypatch",
617
+ r#"*** Begin Patch
618
+ *** Add File: foo
619
+ +hi
620
+ *** End Patch
621
+ "#,
622
+ ]);
623
+
624
+ match maybe_parse_apply_patch(
625
+ &args,
626
+ &PathUri::parse("file:///workspace").expect("valid POSIX test cwd"),
627
+ ) {
628
+ MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => {
629
+ assert_eq!(
630
+ hunks,
631
+ vec![Hunk::AddFile {
632
+ path: PathBuf::from("foo"),
633
+ contents: "hi\n".to_string()
634
+ }]
635
+ );
636
+ }
637
+ result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
638
+ }
639
+ }
640
+
641
+ #[tokio::test]
642
+ async fn test_heredoc() {
643
+ assert_match(&heredoc_script(""), /*expected_workdir*/ None);
644
+ }
645
+
646
+ #[tokio::test]
647
+ async fn test_heredoc_non_login_shell() {
648
+ let script = heredoc_script("");
649
+ let args = strs_to_strings(&["bash", "-c", &script]);
650
+ assert_match_args(args, /*expected_workdir*/ None);
651
+ }
652
+
653
+ #[tokio::test]
654
+ async fn test_heredoc_applypatch() {
655
+ let args = strs_to_strings(&[
656
+ "bash",
657
+ "-lc",
658
+ r#"applypatch <<'PATCH'
659
+ *** Begin Patch
660
+ *** Add File: foo
661
+ +hi
662
+ *** End Patch
663
+ PATCH"#,
664
+ ]);
665
+
666
+ match maybe_parse_apply_patch(
667
+ &args,
668
+ &PathUri::parse("file:///workspace").expect("valid POSIX test cwd"),
669
+ ) {
670
+ MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
671
+ assert_eq!(workdir, None);
672
+ assert_eq!(
673
+ hunks,
674
+ vec![Hunk::AddFile {
675
+ path: PathBuf::from("foo"),
676
+ contents: "hi\n".to_string()
677
+ }]
678
+ );
679
+ }
680
+ result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
681
+ }
682
+ }
683
+
684
+ #[tokio::test]
685
+ async fn test_powershell_heredoc() {
686
+ let script = heredoc_script("");
687
+ assert_match_args(args_powershell(&script), /*expected_workdir*/ None);
688
+ }
689
+ #[tokio::test]
690
+ async fn test_powershell_heredoc_no_profile() {
691
+ let script = heredoc_script("");
692
+ assert_match_args(
693
+ args_powershell_no_profile(&script),
694
+ /*expected_workdir*/ None,
695
+ );
696
+ }
697
+ #[tokio::test]
698
+ async fn test_pwsh_heredoc() {
699
+ let script = heredoc_script("");
700
+ assert_match_args(args_pwsh(&script), /*expected_workdir*/ None);
701
+ }
702
+
703
+ #[tokio::test]
704
+ async fn test_apply_patch_interception_uses_cwd_convention_for_windows_pwsh_path() {
705
+ let script = heredoc_script("");
706
+ assert_match_args_with_cwd(
707
+ strs_to_strings(&[
708
+ r"C:\Program Files\PowerShell\7\pwsh.exe",
709
+ "-NoProfile",
710
+ "-Command",
711
+ &script,
712
+ ]),
713
+ &PathUri::parse("file:///C:/windows").expect("valid Windows test cwd"),
714
+ /*expected_workdir*/ None,
715
+ );
716
+ }
717
+
718
+ #[tokio::test]
719
+ async fn test_cmd_heredoc_with_cd() {
720
+ let script = heredoc_script("cd foo && ");
721
+ assert_match_args(args_cmd(&script), Some("foo"));
722
+ }
723
+
724
+ #[tokio::test]
725
+ async fn test_heredoc_with_leading_cd() {
726
+ assert_match(&heredoc_script("cd foo && "), Some("foo"));
727
+ }
728
+
729
+ #[tokio::test]
730
+ async fn test_cd_with_semicolon_is_ignored() {
731
+ assert_not_match(&heredoc_script("cd foo; "));
732
+ }
733
+
734
+ #[tokio::test]
735
+ async fn test_cd_or_apply_patch_is_ignored() {
736
+ assert_not_match(&heredoc_script("cd bar || "));
737
+ }
738
+
739
+ #[tokio::test]
740
+ async fn test_cd_pipe_apply_patch_is_ignored() {
741
+ assert_not_match(&heredoc_script("cd bar | "));
742
+ }
743
+
744
+ #[tokio::test]
745
+ async fn test_cd_single_quoted_path_with_spaces() {
746
+ assert_match(&heredoc_script("cd 'foo bar' && "), Some("foo bar"));
747
+ }
748
+
749
+ #[tokio::test]
750
+ async fn test_cd_double_quoted_path_with_spaces() {
751
+ assert_match(&heredoc_script("cd \"foo bar\" && "), Some("foo bar"));
752
+ }
753
+
754
+ #[tokio::test]
755
+ async fn test_echo_and_apply_patch_is_ignored() {
756
+ assert_not_match(&heredoc_script("echo foo && "));
757
+ }
758
+
759
+ #[tokio::test]
760
+ async fn test_apply_patch_with_arg_is_ignored() {
761
+ let script = "apply_patch foo <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH";
762
+ assert_not_match(script);
763
+ }
764
+
765
+ #[tokio::test]
766
+ async fn test_double_cd_then_apply_patch_is_ignored() {
767
+ assert_not_match(&heredoc_script("cd foo && cd bar && "));
768
+ }
769
+
770
+ #[tokio::test]
771
+ async fn test_cd_two_args_is_ignored() {
772
+ assert_not_match(&heredoc_script("cd foo bar && "));
773
+ }
774
+
775
+ #[tokio::test]
776
+ async fn test_cd_then_apply_patch_then_extra_is_ignored() {
777
+ let script = heredoc_script_ps("cd bar && ", " && echo done");
778
+ assert_not_match(&script);
779
+ }
780
+
781
+ #[tokio::test]
782
+ async fn test_echo_then_cd_and_apply_patch_is_ignored() {
783
+ // Ensure preceding commands before the `cd && apply_patch <<...` sequence do not match.
784
+ assert_not_match(&heredoc_script("echo foo; cd bar && "));
785
+ }
786
+
787
+ #[tokio::test]
788
+ async fn test_unified_diff_last_line_replacement() {
789
+ // Replace the very last line of the file.
790
+ let dir = tempdir().unwrap();
791
+ let path = dir.path().join("last.txt");
792
+ fs::write(&path, "foo\nbar\nbaz\n").unwrap();
793
+
794
+ let patch = wrap_patch(&format!(
795
+ r#"*** Update File: {}
796
+ @@
797
+ foo
798
+ bar
799
+ -baz
800
+ +BAZ
801
+ "#,
802
+ path.display()
803
+ ));
804
+
805
+ let patch = parse_patch(&patch).unwrap();
806
+ let chunks = match patch.hunks.as_slice() {
807
+ [Hunk::UpdateFile { chunks, .. }] => chunks,
808
+ _ => panic!("Expected a single UpdateFile hunk"),
809
+ };
810
+
811
+ let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path");
812
+ let diff =
813
+ unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
814
+ .await
815
+ .unwrap();
816
+ let expected_diff = r#"@@ -2,2 +2,2 @@
817
+ bar
818
+ -baz
819
+ +BAZ
820
+ "#;
821
+ let expected = ApplyPatchFileUpdate {
822
+ unified_diff: expected_diff.to_string(),
823
+ original_content: "foo\nbar\nbaz\n".to_string(),
824
+ content: "foo\nbar\nBAZ\n".to_string(),
825
+ };
826
+ assert_eq!(expected, diff);
827
+ }
828
+
829
+ #[tokio::test]
830
+ async fn test_unified_diff_insert_at_eof() {
831
+ // Insert a new line at end‑of‑file.
832
+ let dir = tempdir().unwrap();
833
+ let path = dir.path().join("insert.txt");
834
+ fs::write(&path, "foo\nbar\nbaz\n").unwrap();
835
+
836
+ let patch = wrap_patch(&format!(
837
+ r#"*** Update File: {}
838
+ @@
839
+ +quux
840
+ *** End of File
841
+ "#,
842
+ path.display()
843
+ ));
844
+
845
+ let patch = parse_patch(&patch).unwrap();
846
+ let chunks = match patch.hunks.as_slice() {
847
+ [Hunk::UpdateFile { chunks, .. }] => chunks,
848
+ _ => panic!("Expected a single UpdateFile hunk"),
849
+ };
850
+
851
+ let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path");
852
+ let diff =
853
+ unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
854
+ .await
855
+ .unwrap();
856
+ let expected_diff = r#"@@ -3 +3,2 @@
857
+ baz
858
+ +quux
859
+ "#;
860
+ let expected = ApplyPatchFileUpdate {
861
+ unified_diff: expected_diff.to_string(),
862
+ original_content: "foo\nbar\nbaz\n".to_string(),
863
+ content: "foo\nbar\nbaz\nquux\n".to_string(),
864
+ };
865
+ assert_eq!(expected, diff);
866
+ }
867
+
868
+ #[tokio::test]
869
+ async fn test_apply_patch_should_resolve_absolute_paths_in_cwd() {
870
+ let session_dir = tempdir().unwrap();
871
+ let relative_path = "source.txt";
872
+
873
+ // Note that we need this file to exist for the patch to be "verified"
874
+ // and parsed correctly.
875
+ let session_file_path = session_dir.path().join(relative_path);
876
+ fs::write(&session_file_path, "session directory content\n").unwrap();
877
+
878
+ let argv = vec![
879
+ "apply_patch".to_string(),
880
+ r#"*** Begin Patch
881
+ *** Update File: source.txt
882
+ @@
883
+ -session directory content
884
+ +updated session directory content
885
+ *** End Patch"#
886
+ .to_string(),
887
+ ];
888
+
889
+ let result = maybe_parse_apply_patch_verified(
890
+ &argv,
891
+ &PathUri::from_host_native_path(session_dir.path()).expect("absolute test path"),
892
+ LOCAL_FS.as_ref(),
893
+ /*sandbox*/ None,
894
+ )
895
+ .await;
896
+
897
+ // Verify the patch contents - as otherwise we may have pulled contents
898
+ // from the wrong file (as we're using relative paths)
899
+ assert_eq!(
900
+ result,
901
+ MaybeApplyPatchVerified::Body(ApplyPatchAction {
902
+ changes: HashMap::from([(
903
+ PathUri::from_host_native_path(session_dir.path().join(relative_path))
904
+ .expect("absolute test path"),
905
+ ApplyPatchFileChange::Update {
906
+ unified_diff: r#"@@ -1 +1 @@
907
+ -session directory content
908
+ +updated session directory content
909
+ "#
910
+ .to_string(),
911
+ move_path: None,
912
+ new_content: "updated session directory content\n".to_string(),
913
+ },
914
+ )]),
915
+ update_file_mode: ApplyPatchFileUpdateMode::default(),
916
+ patch: argv[1].clone(),
917
+ cwd: PathUri::from_host_native_path(session_dir.path())
918
+ .expect("absolute test path"),
919
+ })
920
+ );
921
+ }
922
+
923
+ #[tokio::test]
924
+ async fn test_apply_patch_resolves_move_path_with_effective_cwd() {
925
+ let session_dir = tempdir().unwrap();
926
+ let worktree_rel = "alt";
927
+ let worktree_dir = session_dir.path().join(worktree_rel);
928
+ fs::create_dir_all(&worktree_dir).unwrap();
929
+
930
+ let source_name = "old.txt";
931
+ let dest_name = "renamed.txt";
932
+ let source_path = worktree_dir.join(source_name);
933
+ fs::write(&source_path, "before\n").unwrap();
934
+
935
+ let patch = wrap_patch(&format!(
936
+ r#"*** Update File: {source_name}
937
+ *** Move to: {dest_name}
938
+ @@
939
+ -before
940
+ +after"#
941
+ ));
942
+
943
+ let shell_script = format!("cd {worktree_rel} && apply_patch <<'PATCH'\n{patch}\nPATCH");
944
+ let argv = vec!["bash".into(), "-lc".into(), shell_script];
945
+
946
+ let result = maybe_parse_apply_patch_verified(
947
+ &argv,
948
+ &PathUri::from_host_native_path(session_dir.path()).expect("absolute test path"),
949
+ LOCAL_FS.as_ref(),
950
+ /*sandbox*/ None,
951
+ )
952
+ .await;
953
+ let action = match result {
954
+ MaybeApplyPatchVerified::Body(action) => action,
955
+ other => panic!("expected verified body, got {other:?}"),
956
+ };
957
+
958
+ assert_eq!(
959
+ action.cwd.to_abs_path().unwrap().as_path(),
960
+ worktree_dir.as_path()
961
+ );
962
+
963
+ let source_path = PathUri::from_host_native_path(worktree_dir.join(source_name))
964
+ .expect("absolute test path");
965
+ let change = action
966
+ .changes()
967
+ .get(&source_path)
968
+ .expect("source file change present");
969
+
970
+ match change {
971
+ ApplyPatchFileChange::Update { move_path, .. } => {
972
+ let expected_move_path =
973
+ PathUri::from_host_native_path(worktree_dir.join(dest_name))
974
+ .expect("absolute test path");
975
+ assert_eq!(move_path.as_ref(), Some(&expected_move_path));
976
+ }
977
+ other => panic!("expected update change, got {other:?}"),
978
+ }
979
+ }
980
+
981
+ #[tokio::test]
982
+ async fn test_unreadable_destinations_still_verify() {
983
+ let session_dir = tempdir().unwrap();
984
+ fs::write(session_dir.path().join("binary.dat"), [0xff, 0xfe, 0xfd]).unwrap();
985
+ let cwd = PathUri::from_host_native_path(session_dir.path()).expect("absolute test path");
986
+ let add_argv = vec![
987
+ "apply_patch".to_string(),
988
+ "*** Begin Patch\n*** Add File: binary.dat\n+text\n*** End Patch".to_string(),
989
+ ];
990
+ fs::write(session_dir.path().join("source.txt"), "before\n").unwrap();
991
+ let move_argv = vec![
992
+ "apply_patch".to_string(),
993
+ "*** Begin Patch\n*** Update File: source.txt\n*** Move to: binary.dat\n@@\n-before\n+after\n*** End Patch".to_string(),
994
+ ];
995
+
996
+ for argv in [add_argv, move_argv] {
997
+ let result = maybe_parse_apply_patch_verified(
998
+ &argv,
999
+ &cwd,
1000
+ LOCAL_FS.as_ref(),
1001
+ /*sandbox*/ None,
1002
+ )
1003
+ .await;
1004
+
1005
+ assert!(matches!(result, MaybeApplyPatchVerified::Body(_)));
1006
+ }
1007
+ }
1008
+
1009
+ #[cfg(unix)]
1010
+ #[tokio::test]
1011
+ async fn test_delete_symlink_still_verifies() {
1012
+ use std::os::unix::fs::symlink;
1013
+
1014
+ let session_dir = tempdir().unwrap();
1015
+ fs::write(session_dir.path().join("target.txt"), "target\n").unwrap();
1016
+ symlink(
1017
+ session_dir.path().join("target.txt"),
1018
+ session_dir.path().join("link.txt"),
1019
+ )
1020
+ .unwrap();
1021
+ let argv = vec![
1022
+ "apply_patch".to_string(),
1023
+ "*** Begin Patch\n*** Delete File: link.txt\n*** End Patch".to_string(),
1024
+ ];
1025
+
1026
+ let result = maybe_parse_apply_patch_verified(
1027
+ &argv,
1028
+ &PathUri::from_host_native_path(session_dir.path()).expect("absolute test path"),
1029
+ LOCAL_FS.as_ref(),
1030
+ /*sandbox*/ None,
1031
+ )
1032
+ .await;
1033
+
1034
+ assert!(matches!(result, MaybeApplyPatchVerified::Body(_)));
1035
+ }
1036
+ }
codex-rs/apply-patch/src/lib.rs ADDED
@@ -0,0 +1,1444 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ mod file_update;
2
+ mod invocation;
3
+ mod parser;
4
+ mod seek_sequence;
5
+ mod standalone_executable;
6
+ mod streaming_parser;
7
+ mod text_file;
8
+
9
+ use std::collections::HashMap;
10
+ use std::io;
11
+ use std::path::PathBuf;
12
+
13
+ use anyhow::Context;
14
+ use anyhow::Result;
15
+ use codex_exec_server::CreateDirectoryOptions;
16
+ use codex_exec_server::ExecutorFileSystem;
17
+ use codex_exec_server::FileSystemSandboxContext;
18
+ use codex_exec_server::GetMetadataOptions;
19
+ use codex_exec_server::ReadFileOptions;
20
+ use codex_exec_server::RemoveOptions;
21
+ use codex_exec_server::WriteFileOptions;
22
+ use codex_utils_path_uri::PathUri;
23
+ use codex_utils_path_uri::PathUriParseError;
24
+ pub use parser::Hunk;
25
+ pub use parser::ParseError;
26
+ use parser::ParseError::*;
27
+ pub use parser::UpdateFileChunk;
28
+ pub use parser::parse_patch;
29
+ pub use streaming_parser::StreamingPatchParser;
30
+ use thiserror::Error;
31
+
32
+ use file_update::AppliedPatch;
33
+ pub use file_update::ApplyPatchFileUpdate;
34
+ use file_update::derive_new_contents_from_chunks;
35
+ pub use file_update::unified_diff_from_chunks;
36
+ pub use file_update::unified_diff_from_chunks_with_context;
37
+ pub(crate) use file_update::unified_diff_from_chunks_with_mode;
38
+ pub use invocation::MaybeApplyPatch;
39
+ pub use invocation::maybe_parse_apply_patch;
40
+ pub use invocation::maybe_parse_apply_patch_verified;
41
+ pub use invocation::maybe_parse_apply_patch_verified_with_mode;
42
+ pub use invocation::verify_apply_patch_args;
43
+ pub use invocation::verify_apply_patch_args_with_mode;
44
+ pub use standalone_executable::main;
45
+
46
+ use crate::invocation::ExtractHeredocError;
47
+
48
+ /// Special argv[1] flag used when the Codex executable self-invokes to run the
49
+ /// internal `apply_patch` path.
50
+ ///
51
+ /// Although this constant lives in `codex-apply-patch` (to avoid forcing
52
+ /// `codex-arg0` to depend on `codex-core`), it remains part of the "codex core"
53
+ /// process-invocation contract for the standalone `apply_patch` command
54
+ /// surface.
55
+ pub const CODEX_CORE_APPLY_PATCH_ARG1: &str = "--codex-run-as-apply-patch";
56
+
57
+ /// Internal environment variable used to carry the selected update mode
58
+ /// through the arg0-dispatched standalone executable.
59
+ pub const CODEX_APPLY_PATCH_PRESERVE_LINE_ENDINGS_ENV_VAR: &str =
60
+ "CODEX_APPLY_PATCH_PRESERVE_LINE_ENDINGS";
61
+
62
+ /// Controls how updates reconstruct the target file after matching a patch.
63
+ #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
64
+ pub enum ApplyPatchFileUpdateMode {
65
+ /// Preserve the historical behavior of normalizing updated files to LF.
66
+ #[default]
67
+ NormalizeToLf,
68
+ /// Preserve existing line endings and use the file's preferred ending for new lines.
69
+ PreserveLineEndings,
70
+ }
71
+
72
+ /// Policy for one patch application. Standalone callers follow symlinks by default.
73
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
74
+ pub struct ApplyPatchOptions {
75
+ pub update_file_mode: ApplyPatchFileUpdateMode,
76
+ /// Whether filesystem operations may resolve symlinks in any path component.
77
+ pub follow_symlinks: bool,
78
+ }
79
+
80
+ impl Default for ApplyPatchOptions {
81
+ fn default() -> Self {
82
+ Self {
83
+ update_file_mode: ApplyPatchFileUpdateMode::default(),
84
+ follow_symlinks: true,
85
+ }
86
+ }
87
+ }
88
+
89
+ /// Reads the update mode selected for an arg0-dispatched `apply_patch` process.
90
+ #[doc(hidden)]
91
+ pub fn apply_patch_file_update_mode_from_env() -> ApplyPatchFileUpdateMode {
92
+ match std::env::var(CODEX_APPLY_PATCH_PRESERVE_LINE_ENDINGS_ENV_VAR).as_deref() {
93
+ Ok("1") => ApplyPatchFileUpdateMode::PreserveLineEndings,
94
+ _ => ApplyPatchFileUpdateMode::NormalizeToLf,
95
+ }
96
+ }
97
+
98
+ #[derive(Debug, Error, PartialEq)]
99
+ pub enum ApplyPatchError {
100
+ #[error(transparent)]
101
+ ParseError(#[from] ParseError),
102
+ #[error(transparent)]
103
+ IoError(#[from] IoError),
104
+ /// Error that occurs while computing replacements when applying patch chunks
105
+ #[error("{0}")]
106
+ ComputeReplacements(String),
107
+ /// A patch path could not be resolved as a path URI.
108
+ #[error(transparent)]
109
+ PathUri(#[from] PathUriParseError),
110
+ /// A raw patch body was provided without an explicit `apply_patch` invocation.
111
+ #[error(
112
+ "patch detected without explicit call to apply_patch. Rerun as [\"apply_patch\", \"<patch>\"]"
113
+ )]
114
+ ImplicitInvocation,
115
+ }
116
+
117
+ impl From<std::io::Error> for ApplyPatchError {
118
+ fn from(err: std::io::Error) -> Self {
119
+ ApplyPatchError::IoError(IoError {
120
+ context: "I/O error".to_string(),
121
+ source: err,
122
+ })
123
+ }
124
+ }
125
+
126
+ impl From<&std::io::Error> for ApplyPatchError {
127
+ fn from(err: &std::io::Error) -> Self {
128
+ ApplyPatchError::IoError(IoError {
129
+ context: "I/O error".to_string(),
130
+ source: std::io::Error::new(err.kind(), err.to_string()),
131
+ })
132
+ }
133
+ }
134
+
135
+ #[derive(Debug, Error)]
136
+ #[error("{context}: {source}")]
137
+ pub struct IoError {
138
+ context: String,
139
+ #[source]
140
+ source: std::io::Error,
141
+ }
142
+
143
+ impl PartialEq for IoError {
144
+ fn eq(&self, other: &Self) -> bool {
145
+ self.context == other.context && self.source.to_string() == other.source.to_string()
146
+ }
147
+ }
148
+
149
+ /// Both the raw PATCH argument to `apply_patch` as well as the PATCH argument
150
+ /// parsed into hunks.
151
+ #[derive(Debug, PartialEq)]
152
+ pub struct ApplyPatchArgs {
153
+ pub patch: String,
154
+ pub hunks: Vec<Hunk>,
155
+ pub workdir: Option<String>,
156
+ pub environment_id: Option<String>,
157
+ }
158
+
159
+ #[derive(Debug, PartialEq)]
160
+ pub enum ApplyPatchFileChange {
161
+ Add {
162
+ content: String,
163
+ },
164
+ Delete {
165
+ content: String,
166
+ },
167
+ Update {
168
+ unified_diff: String,
169
+ move_path: Option<PathUri>,
170
+ /// new_content that will result after the unified_diff is applied.
171
+ new_content: String,
172
+ },
173
+ }
174
+
175
+ #[derive(Debug, PartialEq)]
176
+ pub enum MaybeApplyPatchVerified {
177
+ /// `argv` corresponded to an `apply_patch` invocation, and these are the
178
+ /// resulting proposed file changes.
179
+ Body(ApplyPatchAction),
180
+ /// `argv` could not be parsed to determine whether it corresponds to an
181
+ /// `apply_patch` invocation.
182
+ ShellParseError(ExtractHeredocError),
183
+ /// `argv` corresponded to an `apply_patch` invocation, but it could not
184
+ /// be fulfilled due to the specified error.
185
+ CorrectnessError(ApplyPatchError),
186
+ /// `argv` decidedly did not correspond to an `apply_patch` invocation.
187
+ NotApplyPatch,
188
+ }
189
+
190
+ /// ApplyPatchAction is the result of parsing an `apply_patch` command. By
191
+ /// construction, all paths should be absolute paths.
192
+ #[derive(Debug, PartialEq)]
193
+ pub struct ApplyPatchAction {
194
+ changes: HashMap<PathUri, ApplyPatchFileChange>,
195
+
196
+ update_file_mode: ApplyPatchFileUpdateMode,
197
+
198
+ /// The raw patch argument that can be used to apply the patch. i.e., if the
199
+ /// original arg was parsed in "lenient" mode with a
200
+ /// heredoc, this should be the value without the heredoc wrapper.
201
+ pub patch: String,
202
+
203
+ /// The working directory that was used to resolve relative paths in the patch.
204
+ pub cwd: PathUri,
205
+ }
206
+
207
+ impl ApplyPatchAction {
208
+ pub fn is_empty(&self) -> bool {
209
+ self.changes.is_empty()
210
+ }
211
+
212
+ /// Returns the changes that would be made by applying the patch.
213
+ pub fn changes(&self) -> &HashMap<PathUri, ApplyPatchFileChange> {
214
+ &self.changes
215
+ }
216
+
217
+ /// Returns the update mode selected while the patch was verified.
218
+ pub fn update_file_mode(&self) -> ApplyPatchFileUpdateMode {
219
+ self.update_file_mode
220
+ }
221
+
222
+ /// Should be used exclusively for testing. (Not worth the overhead of
223
+ /// creating a feature flag for this.)
224
+ pub fn new_add_for_test(path: &PathUri, content: String) -> Self {
225
+ #[expect(clippy::expect_used)]
226
+ let filename = path.basename().expect("path should not be empty");
227
+ let patch = format!(
228
+ r#"*** Begin Patch
229
+ *** Update File: {filename}
230
+ @@
231
+ + {content}
232
+ *** End Patch"#,
233
+ );
234
+ let changes = HashMap::from([(path.clone(), ApplyPatchFileChange::Add { content })]);
235
+ #[expect(clippy::expect_used)]
236
+ Self {
237
+ changes,
238
+ update_file_mode: ApplyPatchFileUpdateMode::default(),
239
+ cwd: path.parent().expect("path should have parent"),
240
+ patch,
241
+ }
242
+ }
243
+ }
244
+
245
+ /// Textual file changes that were actually committed while applying a patch.
246
+ #[derive(Clone, Debug, PartialEq)]
247
+ pub struct AppliedPatchDelta {
248
+ changes: Vec<AppliedPatchChange>,
249
+ exact: bool,
250
+ }
251
+
252
+ impl AppliedPatchDelta {
253
+ fn new(changes: Vec<AppliedPatchChange>, exact: bool) -> Self {
254
+ Self { changes, exact }
255
+ }
256
+
257
+ fn empty() -> Self {
258
+ Self::new(Vec::new(), /*exact*/ true)
259
+ }
260
+
261
+ pub fn changes(&self) -> &[AppliedPatchChange] {
262
+ &self.changes
263
+ }
264
+
265
+ pub fn is_empty(&self) -> bool {
266
+ self.changes.is_empty()
267
+ }
268
+
269
+ pub fn is_exact(&self) -> bool {
270
+ self.exact
271
+ }
272
+
273
+ /// Appends a later committed prefix while preserving the aggregate exactness.
274
+ pub fn append(&mut self, other: Self) {
275
+ self.changes.extend(other.changes);
276
+ self.exact &= other.exact;
277
+ }
278
+ }
279
+
280
+ impl Default for AppliedPatchDelta {
281
+ fn default() -> Self {
282
+ Self::empty()
283
+ }
284
+ }
285
+
286
+ /// A committed file change, preserved in the order it was applied.
287
+ #[derive(Clone, Debug, PartialEq)]
288
+ pub struct AppliedPatchChange {
289
+ pub path: PathUri,
290
+ pub change: AppliedPatchFileChange,
291
+ }
292
+
293
+ #[derive(Clone, Debug, PartialEq)]
294
+ pub enum AppliedPatchFileChange {
295
+ Add {
296
+ content: String,
297
+ overwritten_content: Option<String>,
298
+ },
299
+ Delete {
300
+ content: String,
301
+ },
302
+ Update {
303
+ move_path: Option<PathUri>,
304
+ old_content: String,
305
+ overwritten_move_content: Option<String>,
306
+ new_content: String,
307
+ },
308
+ }
309
+
310
+ /// A failed patch application together with the textual mutations that were
311
+ /// definitely committed before the failure was observed.
312
+ #[derive(Debug, Error)]
313
+ #[error("{error}")]
314
+ pub struct ApplyPatchFailure {
315
+ #[source]
316
+ error: ApplyPatchError,
317
+ delta: AppliedPatchDelta,
318
+ }
319
+
320
+ impl ApplyPatchFailure {
321
+ fn new(error: ApplyPatchError, delta: AppliedPatchDelta) -> Self {
322
+ Self { error, delta }
323
+ }
324
+
325
+ fn without_delta(error: ApplyPatchError) -> Self {
326
+ Self::new(error, AppliedPatchDelta::empty())
327
+ }
328
+
329
+ pub fn delta(&self) -> &AppliedPatchDelta {
330
+ &self.delta
331
+ }
332
+
333
+ pub fn into_parts(self) -> (ApplyPatchError, AppliedPatchDelta) {
334
+ (self.error, self.delta)
335
+ }
336
+ }
337
+
338
+ /// Applies the patch and prints the result to stdout/stderr.
339
+ pub async fn apply_patch(
340
+ patch: &str,
341
+ cwd: &PathUri,
342
+ stdout: &mut impl std::io::Write,
343
+ stderr: &mut impl std::io::Write,
344
+ fs: &dyn ExecutorFileSystem,
345
+ sandbox: Option<&FileSystemSandboxContext>,
346
+ ) -> Result<AppliedPatchDelta, ApplyPatchFailure> {
347
+ apply_patch_with_options(
348
+ patch,
349
+ ApplyPatchOptions::default(),
350
+ cwd,
351
+ stdout,
352
+ stderr,
353
+ fs,
354
+ sandbox,
355
+ )
356
+ .await
357
+ }
358
+
359
+ /// Applies the patch using the selected options and prints the result
360
+ /// to stdout/stderr.
361
+ pub async fn apply_patch_with_options(
362
+ patch: &str,
363
+ options: ApplyPatchOptions,
364
+ cwd: &PathUri,
365
+ stdout: &mut impl std::io::Write,
366
+ stderr: &mut impl std::io::Write,
367
+ fs: &dyn ExecutorFileSystem,
368
+ sandbox: Option<&FileSystemSandboxContext>,
369
+ ) -> Result<AppliedPatchDelta, ApplyPatchFailure> {
370
+ let hunks = match parse_patch(patch) {
371
+ Ok(source) => source.hunks,
372
+ Err(e) => {
373
+ match &e {
374
+ InvalidPatchError(message) => {
375
+ writeln!(stderr, "Invalid patch: {message}")
376
+ .map_err(ApplyPatchError::from)
377
+ .map_err(ApplyPatchFailure::without_delta)?;
378
+ }
379
+ InvalidHunkError {
380
+ message,
381
+ line_number,
382
+ } => {
383
+ writeln!(
384
+ stderr,
385
+ "Invalid patch hunk on line {line_number}: {message}"
386
+ )
387
+ .map_err(ApplyPatchError::from)
388
+ .map_err(ApplyPatchFailure::without_delta)?;
389
+ }
390
+ }
391
+ return Err(ApplyPatchFailure::without_delta(
392
+ ApplyPatchError::ParseError(e),
393
+ ));
394
+ }
395
+ };
396
+
397
+ apply_hunks_with_options(&hunks, options, cwd, stdout, stderr, fs, sandbox).await
398
+ }
399
+
400
+ /// Applies hunks and continues to update stdout/stderr
401
+ pub async fn apply_hunks(
402
+ hunks: &[Hunk],
403
+ cwd: &PathUri,
404
+ stdout: &mut impl std::io::Write,
405
+ stderr: &mut impl std::io::Write,
406
+ fs: &dyn ExecutorFileSystem,
407
+ sandbox: Option<&FileSystemSandboxContext>,
408
+ ) -> Result<AppliedPatchDelta, ApplyPatchFailure> {
409
+ apply_hunks_with_options(
410
+ hunks,
411
+ ApplyPatchOptions::default(),
412
+ cwd,
413
+ stdout,
414
+ stderr,
415
+ fs,
416
+ sandbox,
417
+ )
418
+ .await
419
+ }
420
+
421
+ /// Applies hunks using the selected file-update mode and continues to update
422
+ /// stdout/stderr.
423
+ async fn apply_hunks_with_options(
424
+ hunks: &[Hunk],
425
+ options: ApplyPatchOptions,
426
+ cwd: &PathUri,
427
+ stdout: &mut impl std::io::Write,
428
+ stderr: &mut impl std::io::Write,
429
+ fs: &dyn ExecutorFileSystem,
430
+ sandbox: Option<&FileSystemSandboxContext>,
431
+ ) -> Result<AppliedPatchDelta, ApplyPatchFailure> {
432
+ let mut delta = AppliedPatchDelta::empty();
433
+ match apply_hunks_to_files(hunks, options, cwd, fs, sandbox, &mut delta).await {
434
+ Ok(affected_paths) => {
435
+ print_summary(&affected_paths, stdout).map_err(|error| {
436
+ ApplyPatchFailure::new(ApplyPatchError::from(error), delta.clone())
437
+ })?;
438
+ Ok(delta)
439
+ }
440
+ Err(error) => {
441
+ let msg = error.to_string();
442
+ writeln!(stderr, "{msg}").map_err(|error| {
443
+ ApplyPatchFailure::new(ApplyPatchError::from(error), delta.clone())
444
+ })?;
445
+ let error = if let Some(io) = error.downcast_ref::<std::io::Error>() {
446
+ ApplyPatchError::from(io)
447
+ } else {
448
+ ApplyPatchError::IoError(IoError {
449
+ context: msg,
450
+ source: std::io::Error::other(error),
451
+ })
452
+ };
453
+ Err(ApplyPatchFailure::new(error, delta))
454
+ }
455
+ }
456
+ }
457
+
458
+ /// Applies each parsed patch hunk to the filesystem.
459
+ /// Returns an error if any of the changes could not be applied.
460
+ /// Tracks file paths affected by applying a patch, preserving the path spelling
461
+ /// from the patch for user-facing summaries.
462
+ pub struct AffectedPaths {
463
+ pub added: Vec<PathBuf>,
464
+ pub modified: Vec<PathBuf>,
465
+ pub deleted: Vec<PathBuf>,
466
+ }
467
+
468
+ /// Apply the hunks to the filesystem, returning which files were added, modified, or deleted.
469
+ /// Returns an error if the patch could not be applied.
470
+ async fn apply_hunks_to_files(
471
+ hunks: &[Hunk],
472
+ options: ApplyPatchOptions,
473
+ cwd: &PathUri,
474
+ fs: &dyn ExecutorFileSystem,
475
+ sandbox: Option<&FileSystemSandboxContext>,
476
+ delta: &mut AppliedPatchDelta,
477
+ ) -> anyhow::Result<AffectedPaths> {
478
+ let ApplyPatchOptions {
479
+ update_file_mode,
480
+ follow_symlinks,
481
+ } = options;
482
+ if hunks.is_empty() {
483
+ anyhow::bail!("No files were modified.");
484
+ }
485
+
486
+ let mut added: Vec<PathBuf> = Vec::new();
487
+ let mut modified: Vec<PathBuf> = Vec::new();
488
+ let mut deleted: Vec<PathBuf> = Vec::new();
489
+ // A failed write can still have modified the target before surfacing an
490
+ // error (for example by truncating before ENOSPC), so the accumulated
491
+ // delta is no longer exact when a write fails.
492
+ macro_rules! try_write {
493
+ ($result:expr) => {
494
+ match $result {
495
+ Ok(value) => value,
496
+ Err(error) => {
497
+ delta.exact = false;
498
+ return Err(anyhow::Error::from(error));
499
+ }
500
+ }
501
+ };
502
+ }
503
+
504
+ for hunk in hunks {
505
+ let affected_path = hunk.path().to_path_buf();
506
+ let path_uri = hunk.resolve_path(cwd)?;
507
+ match hunk {
508
+ Hunk::AddFile { contents, .. } => {
509
+ let overwritten_content = read_optional_file_text_for_delta(
510
+ &path_uri,
511
+ fs,
512
+ follow_symlinks,
513
+ sandbox,
514
+ &mut delta.exact,
515
+ )
516
+ .await;
517
+ try_write!(
518
+ write_file_with_missing_parent_retry(
519
+ fs,
520
+ &path_uri,
521
+ contents.clone().into_bytes(),
522
+ follow_symlinks,
523
+ sandbox,
524
+ )
525
+ .await
526
+ );
527
+ delta.changes.push(AppliedPatchChange {
528
+ path: path_uri,
529
+ change: AppliedPatchFileChange::Add {
530
+ content: contents.clone(),
531
+ overwritten_content,
532
+ },
533
+ });
534
+ added.push(affected_path);
535
+ }
536
+ Hunk::DeleteFile { .. } => {
537
+ note_existing_path_delta_support(
538
+ &path_uri,
539
+ fs,
540
+ follow_symlinks,
541
+ sandbox,
542
+ &mut delta.exact,
543
+ )
544
+ .await;
545
+ let deleted_content = fs
546
+ .read_file_text(&path_uri, ReadFileOptions { follow_symlinks }, sandbox)
547
+ .await
548
+ .ok();
549
+ if deleted_content.is_none() {
550
+ delta.exact = false;
551
+ }
552
+ ensure_not_directory(&path_uri, fs, follow_symlinks, sandbox)
553
+ .await
554
+ .with_context(|| {
555
+ format!(
556
+ "Failed to delete file {}",
557
+ path_uri.inferred_native_path_string()
558
+ )
559
+ })?;
560
+ if let Err(error) = fs
561
+ .remove(
562
+ &path_uri,
563
+ RemoveOptions {
564
+ recursive: false,
565
+ force: false,
566
+ follow_symlinks,
567
+ },
568
+ sandbox,
569
+ )
570
+ .await
571
+ .with_context(|| {
572
+ format!(
573
+ "Failed to delete file {}",
574
+ path_uri.inferred_native_path_string()
575
+ )
576
+ })
577
+ {
578
+ delta.exact &= remove_failure_was_side_effect_free(
579
+ &path_uri,
580
+ deleted_content.as_deref(),
581
+ fs,
582
+ follow_symlinks,
583
+ sandbox,
584
+ )
585
+ .await;
586
+ return Err(error);
587
+ }
588
+ if let Some(content) = deleted_content {
589
+ delta.changes.push(AppliedPatchChange {
590
+ path: path_uri,
591
+ change: AppliedPatchFileChange::Delete { content },
592
+ });
593
+ }
594
+ deleted.push(affected_path);
595
+ }
596
+ Hunk::UpdateFile {
597
+ move_path, chunks, ..
598
+ } => {
599
+ note_existing_path_delta_support(
600
+ &path_uri,
601
+ fs,
602
+ follow_symlinks,
603
+ sandbox,
604
+ &mut delta.exact,
605
+ )
606
+ .await;
607
+ let AppliedPatch {
608
+ original_contents,
609
+ new_contents,
610
+ } = derive_new_contents_from_chunks(
611
+ &path_uri,
612
+ chunks,
613
+ update_file_mode,
614
+ fs,
615
+ follow_symlinks,
616
+ sandbox,
617
+ )
618
+ .await?;
619
+ if let Some(dest) = move_path {
620
+ let dest_uri = cwd.join(&dest.to_string_lossy())?;
621
+ let overwritten_move_content = read_optional_file_text_for_delta(
622
+ &dest_uri,
623
+ fs,
624
+ follow_symlinks,
625
+ sandbox,
626
+ &mut delta.exact,
627
+ )
628
+ .await;
629
+ try_write!(
630
+ write_file_with_missing_parent_retry(
631
+ fs,
632
+ &dest_uri,
633
+ new_contents.clone().into_bytes(),
634
+ follow_symlinks,
635
+ sandbox,
636
+ )
637
+ .await
638
+ );
639
+ let dest_write_change_index = delta.changes.len();
640
+ delta.changes.push(AppliedPatchChange {
641
+ path: dest_uri.clone(),
642
+ change: AppliedPatchFileChange::Add {
643
+ content: new_contents.clone(),
644
+ overwritten_content: overwritten_move_content.clone(),
645
+ },
646
+ });
647
+ ensure_not_directory(&path_uri, fs, follow_symlinks, sandbox)
648
+ .await
649
+ .with_context(|| {
650
+ format!(
651
+ "Failed to remove original {}",
652
+ path_uri.inferred_native_path_string()
653
+ )
654
+ })?;
655
+ if let Err(error) = fs
656
+ .remove(
657
+ &path_uri,
658
+ RemoveOptions {
659
+ recursive: false,
660
+ force: false,
661
+ follow_symlinks,
662
+ },
663
+ sandbox,
664
+ )
665
+ .await
666
+ .with_context(|| {
667
+ format!(
668
+ "Failed to remove original {}",
669
+ path_uri.inferred_native_path_string()
670
+ )
671
+ })
672
+ {
673
+ delta.exact &= remove_failure_was_side_effect_free(
674
+ &path_uri,
675
+ Some(&original_contents),
676
+ fs,
677
+ follow_symlinks,
678
+ sandbox,
679
+ )
680
+ .await;
681
+ return Err(error);
682
+ }
683
+ delta.changes[dest_write_change_index] = AppliedPatchChange {
684
+ path: path_uri,
685
+ change: AppliedPatchFileChange::Update {
686
+ move_path: Some(dest_uri),
687
+ old_content: original_contents,
688
+ overwritten_move_content,
689
+ new_content: new_contents,
690
+ },
691
+ };
692
+ modified.push(affected_path);
693
+ } else {
694
+ try_write!(
695
+ fs.write_file(
696
+ &path_uri,
697
+ new_contents.clone().into_bytes(),
698
+ WriteFileOptions { follow_symlinks },
699
+ sandbox,
700
+ )
701
+ .await
702
+ .with_context(|| format!(
703
+ "Failed to write file {}",
704
+ path_uri.inferred_native_path_string()
705
+ ))
706
+ );
707
+ delta.changes.push(AppliedPatchChange {
708
+ path: path_uri,
709
+ change: AppliedPatchFileChange::Update {
710
+ move_path: None,
711
+ old_content: original_contents,
712
+ overwritten_move_content: None,
713
+ new_content: new_contents,
714
+ },
715
+ });
716
+ modified.push(affected_path);
717
+ }
718
+ }
719
+ }
720
+ }
721
+ Ok(AffectedPaths {
722
+ added,
723
+ modified,
724
+ deleted,
725
+ })
726
+ }
727
+
728
+ async fn ensure_not_directory(
729
+ path: &PathUri,
730
+ fs: &dyn ExecutorFileSystem,
731
+ follow_symlinks: bool,
732
+ sandbox: Option<&FileSystemSandboxContext>,
733
+ ) -> io::Result<()> {
734
+ let metadata = fs
735
+ .get_metadata(path, GetMetadataOptions { follow_symlinks }, sandbox)
736
+ .await?;
737
+ if metadata.is_directory {
738
+ return Err(io::Error::new(
739
+ io::ErrorKind::InvalidInput,
740
+ "path is a directory",
741
+ ));
742
+ }
743
+ Ok(())
744
+ }
745
+
746
+ async fn remove_failure_was_side_effect_free(
747
+ path: &PathUri,
748
+ expected_content: Option<&str>,
749
+ fs: &dyn ExecutorFileSystem,
750
+ follow_symlinks: bool,
751
+ sandbox: Option<&FileSystemSandboxContext>,
752
+ ) -> bool {
753
+ match expected_content {
754
+ Some(expected_content) => fs
755
+ .read_file_text(path, ReadFileOptions { follow_symlinks }, sandbox)
756
+ .await
757
+ .is_ok_and(|content| content == expected_content),
758
+ None => false,
759
+ }
760
+ }
761
+
762
+ async fn read_optional_file_text_for_delta(
763
+ path: &PathUri,
764
+ fs: &dyn ExecutorFileSystem,
765
+ follow_symlinks: bool,
766
+ sandbox: Option<&FileSystemSandboxContext>,
767
+ exact: &mut bool,
768
+ ) -> Option<String> {
769
+ note_existing_path_delta_support(path, fs, follow_symlinks, sandbox, exact).await;
770
+ match fs
771
+ .read_file_text(path, ReadFileOptions { follow_symlinks }, sandbox)
772
+ .await
773
+ {
774
+ Ok(content) => Some(content),
775
+ Err(source) if source.kind() == io::ErrorKind::NotFound => None,
776
+ Err(_) => {
777
+ *exact = false;
778
+ None
779
+ }
780
+ }
781
+ }
782
+
783
+ async fn note_existing_path_delta_support(
784
+ path: &PathUri,
785
+ fs: &dyn ExecutorFileSystem,
786
+ follow_symlinks: bool,
787
+ sandbox: Option<&FileSystemSandboxContext>,
788
+ exact: &mut bool,
789
+ ) {
790
+ match fs
791
+ .get_metadata(path, GetMetadataOptions { follow_symlinks }, sandbox)
792
+ .await
793
+ {
794
+ Ok(metadata) if metadata.is_file && !metadata.is_symlink => {}
795
+ Ok(_) => *exact = false,
796
+ Err(source) if source.kind() == io::ErrorKind::NotFound => {}
797
+ Err(_) => *exact = false,
798
+ }
799
+ }
800
+
801
+ async fn write_file_with_missing_parent_retry(
802
+ fs: &dyn ExecutorFileSystem,
803
+ path: &PathUri,
804
+ contents: Vec<u8>,
805
+ follow_symlinks: bool,
806
+ sandbox: Option<&FileSystemSandboxContext>,
807
+ ) -> anyhow::Result<()> {
808
+ match fs
809
+ .write_file(
810
+ path,
811
+ contents.clone(),
812
+ WriteFileOptions { follow_symlinks },
813
+ sandbox,
814
+ )
815
+ .await
816
+ {
817
+ Ok(()) => Ok(()),
818
+ Err(err) if err.kind() == io::ErrorKind::NotFound => {
819
+ if let Some(parent) = path.parent() {
820
+ fs.create_directory(
821
+ &parent,
822
+ CreateDirectoryOptions {
823
+ recursive: true,
824
+ follow_symlinks,
825
+ },
826
+ sandbox,
827
+ )
828
+ .await
829
+ .with_context(|| {
830
+ format!(
831
+ "Failed to create parent directories for {}",
832
+ path.inferred_native_path_string()
833
+ )
834
+ })?;
835
+ }
836
+ fs.write_file(
837
+ path,
838
+ contents,
839
+ WriteFileOptions { follow_symlinks },
840
+ sandbox,
841
+ )
842
+ .await
843
+ .with_context(|| {
844
+ format!(
845
+ "Failed to write file {}",
846
+ path.inferred_native_path_string()
847
+ )
848
+ })?;
849
+ Ok(())
850
+ }
851
+ Err(err) => Err(err).with_context(|| {
852
+ format!(
853
+ "Failed to write file {}",
854
+ path.inferred_native_path_string()
855
+ )
856
+ }),
857
+ }
858
+ }
859
+
860
+ /// Print the summary of changes in git-style format.
861
+ /// Write a summary of changes to the given writer.
862
+ pub fn print_summary(
863
+ affected: &AffectedPaths,
864
+ out: &mut impl std::io::Write,
865
+ ) -> std::io::Result<()> {
866
+ writeln!(out, "Success. Updated the following files:")?;
867
+ for path in &affected.added {
868
+ writeln!(out, "A {}", path.display())?;
869
+ }
870
+ for path in &affected.modified {
871
+ writeln!(out, "M {}", path.display())?;
872
+ }
873
+ for path in &affected.deleted {
874
+ writeln!(out, "D {}", path.display())?;
875
+ }
876
+ Ok(())
877
+ }
878
+
879
+ #[cfg(test)]
880
+ mod tests {
881
+ use super::*;
882
+ use codex_exec_server::LOCAL_FS;
883
+ use pretty_assertions::assert_eq;
884
+ use std::fs;
885
+ use tempfile::tempdir;
886
+
887
+ /// Helper to construct a patch with the given body.
888
+ fn wrap_patch(body: &str) -> String {
889
+ format!("*** Begin Patch\n{body}\n*** End Patch")
890
+ }
891
+
892
+ #[tokio::test]
893
+ async fn test_add_file_hunk_creates_file_with_contents() {
894
+ let dir = tempdir().unwrap();
895
+ let path = dir.path().join("add.txt");
896
+ let patch = wrap_patch(&format!(
897
+ r#"*** Add File: {}
898
+ +ab
899
+ +cd"#,
900
+ path.display()
901
+ ));
902
+ let mut stdout = Vec::new();
903
+ let mut stderr = Vec::new();
904
+ apply_patch(
905
+ &patch,
906
+ &PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
907
+ &mut stdout,
908
+ &mut stderr,
909
+ LOCAL_FS.as_ref(),
910
+ /*sandbox*/ None,
911
+ )
912
+ .await
913
+ .unwrap();
914
+ // Verify expected stdout and stderr outputs.
915
+ let stdout_str = String::from_utf8(stdout).unwrap();
916
+ let stderr_str = String::from_utf8(stderr).unwrap();
917
+ let expected_out = format!(
918
+ "Success. Updated the following files:\nA {}\n",
919
+ path.display()
920
+ );
921
+ assert_eq!(stdout_str, expected_out);
922
+ assert_eq!(stderr_str, "");
923
+ let contents = fs::read_to_string(path).unwrap();
924
+ assert_eq!(contents, "ab\ncd\n");
925
+ }
926
+
927
+ #[tokio::test]
928
+ async fn test_apply_patch_hunks_accept_relative_and_absolute_paths() {
929
+ let dir = tempdir().unwrap();
930
+ let cwd = PathUri::from_host_native_path(dir.path()).expect("absolute test path");
931
+ let relative_add = dir.path().join("relative-add.txt");
932
+ let absolute_add = dir.path().join("absolute-add.txt");
933
+ let relative_delete = dir.path().join("relative-delete.txt");
934
+ let absolute_delete = dir.path().join("absolute-delete.txt");
935
+ let relative_update = dir.path().join("relative-update.txt");
936
+ let absolute_update = dir.path().join("absolute-update.txt");
937
+ fs::write(&relative_delete, "delete relative\n").unwrap();
938
+ fs::write(&absolute_delete, "delete absolute\n").unwrap();
939
+ fs::write(&relative_update, "relative old\n").unwrap();
940
+ fs::write(&absolute_update, "absolute old\n").unwrap();
941
+
942
+ let patch = wrap_patch(&format!(
943
+ r#"*** Add File: relative-add.txt
944
+ +relative add
945
+ *** Add File: {}
946
+ +absolute add
947
+ *** Delete File: relative-delete.txt
948
+ *** Delete File: {}
949
+ *** Update File: relative-update.txt
950
+ @@
951
+ -relative old
952
+ +relative new
953
+ *** Update File: {}
954
+ @@
955
+ -absolute old
956
+ +absolute new"#,
957
+ absolute_add.display(),
958
+ absolute_delete.display(),
959
+ absolute_update.display(),
960
+ ));
961
+ let mut stdout = Vec::new();
962
+ let mut stderr = Vec::new();
963
+
964
+ apply_patch(
965
+ &patch,
966
+ &cwd,
967
+ &mut stdout,
968
+ &mut stderr,
969
+ LOCAL_FS.as_ref(),
970
+ /*sandbox*/ None,
971
+ )
972
+ .await
973
+ .unwrap();
974
+
975
+ assert_eq!(fs::read_to_string(&relative_add).unwrap(), "relative add\n");
976
+ assert_eq!(fs::read_to_string(&absolute_add).unwrap(), "absolute add\n");
977
+ assert!(!relative_delete.exists());
978
+ assert!(!absolute_delete.exists());
979
+ assert_eq!(
980
+ fs::read_to_string(&relative_update).unwrap(),
981
+ "relative new\n"
982
+ );
983
+ assert_eq!(
984
+ fs::read_to_string(&absolute_update).unwrap(),
985
+ "absolute new\n"
986
+ );
987
+ assert_eq!(String::from_utf8(stderr).unwrap(), "");
988
+ assert_eq!(
989
+ String::from_utf8(stdout).unwrap(),
990
+ format!(
991
+ "Success. Updated the following files:\nA relative-add.txt\nA {}\nM relative-update.txt\nM {}\nD relative-delete.txt\nD {}\n",
992
+ absolute_add.display(),
993
+ absolute_update.display(),
994
+ absolute_delete.display(),
995
+ )
996
+ );
997
+ }
998
+
999
+ #[tokio::test]
1000
+ async fn test_delete_file_hunk_removes_file() {
1001
+ let dir = tempdir().unwrap();
1002
+ let path = dir.path().join("del.txt");
1003
+ fs::write(&path, "x").unwrap();
1004
+ let patch = wrap_patch(&format!("*** Delete File: {}", path.display()));
1005
+ let mut stdout = Vec::new();
1006
+ let mut stderr = Vec::new();
1007
+ apply_patch(
1008
+ &patch,
1009
+ &PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
1010
+ &mut stdout,
1011
+ &mut stderr,
1012
+ LOCAL_FS.as_ref(),
1013
+ /*sandbox*/ None,
1014
+ )
1015
+ .await
1016
+ .unwrap();
1017
+ let stdout_str = String::from_utf8(stdout).unwrap();
1018
+ let stderr_str = String::from_utf8(stderr).unwrap();
1019
+ let expected_out = format!(
1020
+ "Success. Updated the following files:\nD {}\n",
1021
+ path.display()
1022
+ );
1023
+ assert_eq!(stdout_str, expected_out);
1024
+ assert_eq!(stderr_str, "");
1025
+ assert!(!path.exists());
1026
+ }
1027
+
1028
+ #[tokio::test]
1029
+ async fn test_update_file_hunk_modifies_content() {
1030
+ let dir = tempdir().unwrap();
1031
+ let path = dir.path().join("update.txt");
1032
+ fs::write(&path, "foo\nbar\n").unwrap();
1033
+ let patch = wrap_patch(&format!(
1034
+ r#"*** Update File: {}
1035
+ @@
1036
+ foo
1037
+ -bar
1038
+ +baz"#,
1039
+ path.display()
1040
+ ));
1041
+ let mut stdout = Vec::new();
1042
+ let mut stderr = Vec::new();
1043
+ apply_patch(
1044
+ &patch,
1045
+ &PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
1046
+ &mut stdout,
1047
+ &mut stderr,
1048
+ LOCAL_FS.as_ref(),
1049
+ /*sandbox*/ None,
1050
+ )
1051
+ .await
1052
+ .unwrap();
1053
+ // Validate modified file contents and expected stdout/stderr.
1054
+ let stdout_str = String::from_utf8(stdout).unwrap();
1055
+ let stderr_str = String::from_utf8(stderr).unwrap();
1056
+ let expected_out = format!(
1057
+ "Success. Updated the following files:\nM {}\n",
1058
+ path.display()
1059
+ );
1060
+ assert_eq!(stdout_str, expected_out);
1061
+ assert_eq!(stderr_str, "");
1062
+ let contents = fs::read_to_string(&path).unwrap();
1063
+ assert_eq!(contents, "foo\nbaz\n");
1064
+ }
1065
+
1066
+ #[tokio::test]
1067
+ async fn test_update_file_hunk_can_move_file() {
1068
+ let dir = tempdir().unwrap();
1069
+ let src = dir.path().join("src.txt");
1070
+ let dest = dir.path().join("dst.txt");
1071
+ fs::write(&src, "line\n").unwrap();
1072
+ let patch = wrap_patch(&format!(
1073
+ r#"*** Update File: {}
1074
+ *** Move to: {}
1075
+ @@
1076
+ -line
1077
+ +line2"#,
1078
+ src.display(),
1079
+ dest.display()
1080
+ ));
1081
+ let mut stdout = Vec::new();
1082
+ let mut stderr = Vec::new();
1083
+ apply_patch(
1084
+ &patch,
1085
+ &PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
1086
+ &mut stdout,
1087
+ &mut stderr,
1088
+ LOCAL_FS.as_ref(),
1089
+ /*sandbox*/ None,
1090
+ )
1091
+ .await
1092
+ .unwrap();
1093
+ // Validate move semantics and expected stdout/stderr.
1094
+ let stdout_str = String::from_utf8(stdout).unwrap();
1095
+ let stderr_str = String::from_utf8(stderr).unwrap();
1096
+ let expected_out = format!(
1097
+ "Success. Updated the following files:\nM {}\n",
1098
+ dest.display()
1099
+ );
1100
+ assert_eq!(stdout_str, expected_out);
1101
+ assert_eq!(stderr_str, "");
1102
+ assert!(!src.exists());
1103
+ let contents = fs::read_to_string(&dest).unwrap();
1104
+ assert_eq!(contents, "line2\n");
1105
+ }
1106
+
1107
+ #[cfg(unix)]
1108
+ #[tokio::test]
1109
+ async fn test_failed_move_returns_committed_destination_delta() {
1110
+ use std::os::unix::fs::PermissionsExt;
1111
+
1112
+ let dir = tempdir().unwrap();
1113
+ let source_dir = dir.path().join("locked");
1114
+ let dest_dir = dir.path().join("out");
1115
+ fs::create_dir(&source_dir).unwrap();
1116
+ fs::create_dir(&dest_dir).unwrap();
1117
+ let src = source_dir.join("src.txt");
1118
+ let dest = dest_dir.join("dst.txt");
1119
+ fs::write(&src, "line\n").unwrap();
1120
+ fs::set_permissions(&source_dir, fs::Permissions::from_mode(0o555)).unwrap();
1121
+
1122
+ let patch = wrap_patch(
1123
+ "*** Update File: locked/src.txt\n*** Move to: out/dst.txt\n@@\n-line\n+line2",
1124
+ );
1125
+ let mut stdout = Vec::new();
1126
+ let mut stderr = Vec::new();
1127
+ let failure = apply_patch(
1128
+ &patch,
1129
+ &PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
1130
+ &mut stdout,
1131
+ &mut stderr,
1132
+ LOCAL_FS.as_ref(),
1133
+ /*sandbox*/ None,
1134
+ )
1135
+ .await
1136
+ .expect_err("source removal should fail after destination write");
1137
+
1138
+ fs::set_permissions(&source_dir, fs::Permissions::from_mode(0o755)).unwrap();
1139
+
1140
+ assert!(
1141
+ String::from_utf8(stderr)
1142
+ .unwrap()
1143
+ .contains(&format!("Failed to remove original {}", src.display()))
1144
+ );
1145
+ assert_eq!(
1146
+ failure.delta(),
1147
+ &AppliedPatchDelta::new(
1148
+ vec![AppliedPatchChange {
1149
+ path: PathUri::from_host_native_path(&dest).expect("absolute destination path"),
1150
+ change: AppliedPatchFileChange::Add {
1151
+ content: "line2\n".to_string(),
1152
+ overwritten_content: None,
1153
+ },
1154
+ }],
1155
+ /*exact*/ true,
1156
+ )
1157
+ );
1158
+ assert_eq!(fs::read_to_string(src).unwrap(), "line\n");
1159
+ assert_eq!(fs::read_to_string(dest).unwrap(), "line2\n");
1160
+ }
1161
+
1162
+ /// Verify that a single `Update File` hunk with multiple change chunks can update different
1163
+ /// parts of a file and that the file is listed only once in the summary.
1164
+ #[tokio::test]
1165
+ async fn test_multiple_update_chunks_apply_to_single_file() {
1166
+ // Start with a file containing four lines.
1167
+ let dir = tempdir().unwrap();
1168
+ let path = dir.path().join("multi.txt");
1169
+ fs::write(&path, "foo\nbar\nbaz\nqux\n").unwrap();
1170
+ // Construct an update patch with two separate change chunks.
1171
+ // The first chunk uses the line `foo` as context and transforms `bar` into `BAR`.
1172
+ // The second chunk uses `baz` as context and transforms `qux` into `QUX`.
1173
+ let patch = wrap_patch(&format!(
1174
+ r#"*** Update File: {}
1175
+ @@
1176
+ foo
1177
+ -bar
1178
+ +BAR
1179
+ @@
1180
+ baz
1181
+ -qux
1182
+ +QUX"#,
1183
+ path.display()
1184
+ ));
1185
+ let mut stdout = Vec::new();
1186
+ let mut stderr = Vec::new();
1187
+ apply_patch(
1188
+ &patch,
1189
+ &PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
1190
+ &mut stdout,
1191
+ &mut stderr,
1192
+ LOCAL_FS.as_ref(),
1193
+ /*sandbox*/ None,
1194
+ )
1195
+ .await
1196
+ .unwrap();
1197
+ let stdout_str = String::from_utf8(stdout).unwrap();
1198
+ let stderr_str = String::from_utf8(stderr).unwrap();
1199
+ let expected_out = format!(
1200
+ "Success. Updated the following files:\nM {}\n",
1201
+ path.display()
1202
+ );
1203
+ assert_eq!(stdout_str, expected_out);
1204
+ assert_eq!(stderr_str, "");
1205
+ let contents = fs::read_to_string(&path).unwrap();
1206
+ assert_eq!(contents, "foo\nBAR\nbaz\nQUX\n");
1207
+ }
1208
+
1209
+ /// A more involved `Update File` hunk that exercises additions, deletions and
1210
+ /// replacements in separate chunks that appear in non‑adjacent parts of the
1211
+ /// file. Verifies that all edits are applied and that the summary lists the
1212
+ /// file only once.
1213
+ #[tokio::test]
1214
+ async fn test_update_file_hunk_interleaved_changes() {
1215
+ let dir = tempdir().unwrap();
1216
+ let path = dir.path().join("interleaved.txt");
1217
+
1218
+ // Original file: six numbered lines.
1219
+ fs::write(&path, "a\nb\nc\nd\ne\nf\n").unwrap();
1220
+
1221
+ // Patch performs:
1222
+ // • Replace `b` → `B`
1223
+ // • Replace `e` → `E` (using surrounding context)
1224
+ // • Append new line `g` at the end‑of‑file
1225
+ let patch = wrap_patch(&format!(
1226
+ r#"*** Update File: {}
1227
+ @@
1228
+ a
1229
+ -b
1230
+ +B
1231
+ @@
1232
+ c
1233
+ d
1234
+ -e
1235
+ +E
1236
+ @@
1237
+ f
1238
+ +g
1239
+ *** End of File"#,
1240
+ path.display()
1241
+ ));
1242
+
1243
+ let mut stdout = Vec::new();
1244
+ let mut stderr = Vec::new();
1245
+ apply_patch(
1246
+ &patch,
1247
+ &PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
1248
+ &mut stdout,
1249
+ &mut stderr,
1250
+ LOCAL_FS.as_ref(),
1251
+ /*sandbox*/ None,
1252
+ )
1253
+ .await
1254
+ .unwrap();
1255
+
1256
+ let stdout_str = String::from_utf8(stdout).unwrap();
1257
+ let stderr_str = String::from_utf8(stderr).unwrap();
1258
+
1259
+ let expected_out = format!(
1260
+ "Success. Updated the following files:\nM {}\n",
1261
+ path.display()
1262
+ );
1263
+ assert_eq!(stdout_str, expected_out);
1264
+ assert_eq!(stderr_str, "");
1265
+
1266
+ let contents = fs::read_to_string(&path).unwrap();
1267
+ assert_eq!(contents, "a\nB\nc\nd\nE\nf\ng\n");
1268
+ }
1269
+
1270
+ #[tokio::test]
1271
+ async fn test_pure_addition_chunk_followed_by_removal() {
1272
+ let dir = tempdir().unwrap();
1273
+ let path = dir.path().join("panic.txt");
1274
+ fs::write(&path, "line1\nline2\nline3\n").unwrap();
1275
+ let patch = wrap_patch(&format!(
1276
+ r#"*** Update File: {}
1277
+ @@
1278
+ +after-context
1279
+ +second-line
1280
+ @@
1281
+ line1
1282
+ -line2
1283
+ -line3
1284
+ +line2-replacement"#,
1285
+ path.display()
1286
+ ));
1287
+ let mut stdout = Vec::new();
1288
+ let mut stderr = Vec::new();
1289
+ apply_patch(
1290
+ &patch,
1291
+ &PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
1292
+ &mut stdout,
1293
+ &mut stderr,
1294
+ LOCAL_FS.as_ref(),
1295
+ /*sandbox*/ None,
1296
+ )
1297
+ .await
1298
+ .unwrap();
1299
+ let contents = fs::read_to_string(path).unwrap();
1300
+ assert_eq!(
1301
+ contents,
1302
+ "line1\nline2-replacement\nafter-context\nsecond-line\n"
1303
+ );
1304
+ }
1305
+
1306
+ /// Ensure that patches authored with ASCII characters can update lines that
1307
+ /// contain typographic Unicode punctuation (e.g. EN DASH, NON-BREAKING
1308
+ /// HYPHEN). Historically `git apply` succeeds in such scenarios but our
1309
+ /// internal matcher failed requiring an exact byte-for-byte match. The
1310
+ /// fuzzy-matching pass that normalises common punctuation should now bridge
1311
+ /// the gap.
1312
+ #[tokio::test]
1313
+ async fn test_update_line_with_unicode_dash() {
1314
+ let dir = tempdir().unwrap();
1315
+ let path = dir.path().join("unicode.py");
1316
+
1317
+ // Original line contains EN DASH (\u{2013}) and NON-BREAKING HYPHEN (\u{2011}).
1318
+ let original = "import asyncio # local import \u{2013} avoids top\u{2011}level dep\n";
1319
+ std::fs::write(&path, original).unwrap();
1320
+
1321
+ // Patch uses plain ASCII dash / hyphen.
1322
+ let patch = wrap_patch(&format!(
1323
+ r#"*** Update File: {}
1324
+ @@
1325
+ -import asyncio # local import - avoids top-level dep
1326
+ +import asyncio # HELLO"#,
1327
+ path.display()
1328
+ ));
1329
+
1330
+ let mut stdout = Vec::new();
1331
+ let mut stderr = Vec::new();
1332
+ apply_patch(
1333
+ &patch,
1334
+ &PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
1335
+ &mut stdout,
1336
+ &mut stderr,
1337
+ LOCAL_FS.as_ref(),
1338
+ /*sandbox*/ None,
1339
+ )
1340
+ .await
1341
+ .unwrap();
1342
+
1343
+ // File should now contain the replaced comment.
1344
+ let expected = "import asyncio # HELLO\n";
1345
+ let contents = std::fs::read_to_string(&path).unwrap();
1346
+ assert_eq!(contents, expected);
1347
+
1348
+ // Ensure success summary lists the file as modified.
1349
+ let stdout_str = String::from_utf8(stdout).unwrap();
1350
+ let expected_out = format!(
1351
+ "Success. Updated the following files:\nM {}\n",
1352
+ path.display()
1353
+ );
1354
+ assert_eq!(stdout_str, expected_out);
1355
+
1356
+ // No stderr expected.
1357
+ assert_eq!(String::from_utf8(stderr).unwrap(), "");
1358
+ }
1359
+
1360
+ #[cfg(unix)]
1361
+ #[tokio::test]
1362
+ async fn test_apply_patch_fails_on_write_error() {
1363
+ use std::os::unix::fs::PermissionsExt;
1364
+
1365
+ let dir = tempdir().unwrap();
1366
+ let locked_dir = dir.path().join("locked");
1367
+ fs::create_dir(&locked_dir).unwrap();
1368
+ fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o555)).unwrap();
1369
+
1370
+ let patch = wrap_patch("*** Add File: locked/new.txt\n+after");
1371
+
1372
+ let mut stdout = Vec::new();
1373
+ let mut stderr = Vec::new();
1374
+ let result = apply_patch(
1375
+ &patch,
1376
+ &PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
1377
+ &mut stdout,
1378
+ &mut stderr,
1379
+ LOCAL_FS.as_ref(),
1380
+ /*sandbox*/ None,
1381
+ )
1382
+ .await;
1383
+ let failure = result.expect_err("write should fail");
1384
+
1385
+ fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o755)).unwrap();
1386
+
1387
+ assert!(!failure.delta().is_exact());
1388
+ }
1389
+
1390
+ #[tokio::test]
1391
+ async fn test_unreadable_destinations_return_inexact_delta() {
1392
+ let dir = tempdir().unwrap();
1393
+ let path = dir.path().join("binary.dat");
1394
+ fs::write(dir.path().join("source.txt"), "before\n").unwrap();
1395
+ let cwd = PathUri::from_host_native_path(dir.path()).expect("absolute test path");
1396
+
1397
+ for patch in [
1398
+ wrap_patch("*** Add File: binary.dat\n+text"),
1399
+ wrap_patch("*** Update File: source.txt\n*** Move to: binary.dat\n@@\n-before\n+after"),
1400
+ ] {
1401
+ fs::write(&path, [0xff, 0xfe, 0xfd]).unwrap();
1402
+ let mut stdout = Vec::new();
1403
+ let mut stderr = Vec::new();
1404
+ let delta = apply_patch(
1405
+ &patch,
1406
+ &cwd,
1407
+ &mut stdout,
1408
+ &mut stderr,
1409
+ LOCAL_FS.as_ref(),
1410
+ /*sandbox*/ None,
1411
+ )
1412
+ .await
1413
+ .unwrap();
1414
+
1415
+ assert!(!delta.is_exact());
1416
+ }
1417
+ }
1418
+
1419
+ #[cfg(unix)]
1420
+ #[tokio::test]
1421
+ async fn test_delete_symlink_returns_inexact_delta() {
1422
+ use std::os::unix::fs::symlink;
1423
+
1424
+ let dir = tempdir().unwrap();
1425
+ fs::write(dir.path().join("target.txt"), "target\n").unwrap();
1426
+ symlink(dir.path().join("target.txt"), dir.path().join("link.txt")).unwrap();
1427
+ let patch = wrap_patch("*** Delete File: link.txt");
1428
+
1429
+ let mut stdout = Vec::new();
1430
+ let mut stderr = Vec::new();
1431
+ let delta = apply_patch(
1432
+ &patch,
1433
+ &PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
1434
+ &mut stdout,
1435
+ &mut stderr,
1436
+ LOCAL_FS.as_ref(),
1437
+ /*sandbox*/ None,
1438
+ )
1439
+ .await
1440
+ .unwrap();
1441
+
1442
+ assert!(!delta.is_exact());
1443
+ }
1444
+ }
codex-rs/apply-patch/src/main.rs ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ pub fn main() -> ! {
2
+ codex_apply_patch::main()
3
+ }
codex-rs/apply-patch/src/parser.rs ADDED
@@ -0,0 +1,682 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! This module is responsible for parsing & validating a patch into a list of "hunks".
2
+ //! (It does not attempt to actually check that the patch can be applied to the filesystem.)
3
+ //!
4
+ //! The official Lark grammar for the apply-patch format is:
5
+ //!
6
+ //! start: begin_patch environment_id? hunk+ end_patch
7
+ //! begin_patch: "*** Begin Patch" LF
8
+ //! environment_id: "*** Environment ID: " filename LF
9
+ //! end_patch: "*** End Patch" LF?
10
+ //!
11
+ //! hunk: add_hunk | delete_hunk | update_hunk
12
+ //! add_hunk: "*** Add File: " filename LF add_line+
13
+ //! delete_hunk: "*** Delete File: " filename LF
14
+ //! update_hunk: "*** Update File: " filename LF change_move? change?
15
+ //! filename: /(.+)/
16
+ //! add_line: "+" /(.+)/ LF -> line
17
+ //!
18
+ //! change_move: "*** Move to: " filename LF
19
+ //! change: (change_context | change_line)+ eof_line?
20
+ //! change_context: ("@@" | "@@ " /(.+)/) LF
21
+ //! change_line: ("+" | "-" | " ") /(.+)/ LF
22
+ //! eof_line: "*** End of File" LF
23
+ //!
24
+ //! The parser below is a little more lenient than the explicit spec and allows for
25
+ //! leading/trailing whitespace around patch markers.
26
+ use crate::ApplyPatchArgs;
27
+ use crate::streaming_parser::StreamingPatchParser;
28
+ #[cfg(test)]
29
+ use codex_utils_absolute_path::test_support::PathBufExt;
30
+ use codex_utils_path_uri::PathUri;
31
+ use codex_utils_path_uri::PathUriParseError;
32
+ use std::path::Path;
33
+ use std::path::PathBuf;
34
+
35
+ use thiserror::Error;
36
+
37
+ pub(crate) const BEGIN_PATCH_MARKER: &str = "*** Begin Patch";
38
+ pub(crate) const END_PATCH_MARKER: &str = "*** End Patch";
39
+ pub(crate) const ADD_FILE_MARKER: &str = "*** Add File: ";
40
+ pub(crate) const DELETE_FILE_MARKER: &str = "*** Delete File: ";
41
+ pub(crate) const UPDATE_FILE_MARKER: &str = "*** Update File: ";
42
+ pub(crate) const MOVE_TO_MARKER: &str = "*** Move to: ";
43
+ pub(crate) const EOF_MARKER: &str = "*** End of File";
44
+ pub(crate) const CHANGE_CONTEXT_MARKER: &str = "@@ ";
45
+ pub(crate) const EMPTY_CHANGE_CONTEXT_MARKER: &str = "@@";
46
+
47
+ /// Currently, the only OpenAI model that knowingly requires lenient parsing is
48
+ /// gpt-4.1. While we could try to require everyone to pass in a strictness
49
+ /// param when invoking apply_patch, it is a pain to thread it through all of
50
+ /// the call sites, so we resign ourselves allowing lenient parsing for all
51
+ /// models. See [`ParseMode::Lenient`] for details on the exceptions we make for
52
+ /// gpt-4.1.
53
+ const PARSE_IN_STRICT_MODE: bool = false;
54
+
55
+ #[derive(Debug, PartialEq, Error, Clone)]
56
+ pub enum ParseError {
57
+ #[error("invalid patch: {0}")]
58
+ InvalidPatchError(String),
59
+ #[error("invalid hunk at line {line_number}, {message}")]
60
+ InvalidHunkError { message: String, line_number: usize },
61
+ }
62
+ use ParseError::*;
63
+
64
+ #[derive(Debug, PartialEq, Clone)]
65
+ #[allow(clippy::enum_variant_names)]
66
+ pub enum Hunk {
67
+ AddFile {
68
+ path: PathBuf,
69
+ contents: String,
70
+ },
71
+ DeleteFile {
72
+ path: PathBuf,
73
+ },
74
+ UpdateFile {
75
+ path: PathBuf,
76
+ move_path: Option<PathBuf>,
77
+
78
+ /// Chunks should be in order, i.e. the `change_context` of one chunk
79
+ /// should occur later in the file than the previous chunk.
80
+ chunks: Vec<UpdateFileChunk>,
81
+ },
82
+ }
83
+
84
+ impl Hunk {
85
+ pub fn resolve_path(&self, cwd: &PathUri) -> Result<PathUri, PathUriParseError> {
86
+ let path = match self {
87
+ Hunk::UpdateFile { path, .. } => path,
88
+ Hunk::AddFile { .. } | Hunk::DeleteFile { .. } => self.path(),
89
+ };
90
+ cwd.join(&path.to_string_lossy())
91
+ }
92
+
93
+ /// Returns the path affected by this hunk, using the move destination for rename hunks.
94
+ pub fn path(&self) -> &Path {
95
+ match self {
96
+ Hunk::AddFile { path, .. } => path,
97
+ Hunk::DeleteFile { path } => path,
98
+ Hunk::UpdateFile {
99
+ move_path: Some(path),
100
+ ..
101
+ } => path,
102
+ Hunk::UpdateFile {
103
+ path,
104
+ move_path: None,
105
+ ..
106
+ } => path,
107
+ }
108
+ }
109
+ }
110
+
111
+ #[cfg(test)]
112
+ use Hunk::*;
113
+
114
+ #[derive(Debug, Default, PartialEq, Clone)]
115
+ pub struct UpdateFileChunk {
116
+ /// A single line of context used to narrow down the position of the chunk
117
+ /// (this is usually a class, method, or function definition.)
118
+ pub change_context: Option<String>,
119
+
120
+ /// A contiguous block of lines that should be replaced with `new_lines`.
121
+ /// `old_lines` must occur strictly after `change_context`.
122
+ pub old_lines: Vec<String>,
123
+ pub new_lines: Vec<String>,
124
+
125
+ /// Pairs of indices into `old_lines` and `new_lines` that identify lines
126
+ /// parsed as context rather than inferred to be equal by their contents.
127
+ pub context_line_indices: Vec<(usize, usize)>,
128
+
129
+ /// If set to true, `old_lines` must occur at the end of the source file.
130
+ /// (Tolerance around trailing newlines should be encouraged.)
131
+ pub is_end_of_file: bool,
132
+ }
133
+
134
+ impl UpdateFileChunk {
135
+ /// Adds a context line to both sides while recording its corresponding
136
+ /// indices so it remains distinguishable from identical changed lines.
137
+ pub(crate) fn push_context_line(&mut self, line: String) {
138
+ self.context_line_indices
139
+ .push((self.old_lines.len(), self.new_lines.len()));
140
+ self.old_lines.push(line.clone());
141
+ self.new_lines.push(line);
142
+ }
143
+ }
144
+
145
+ pub fn parse_patch(patch: &str) -> Result<ApplyPatchArgs, ParseError> {
146
+ let mode = if PARSE_IN_STRICT_MODE {
147
+ ParseMode::Strict
148
+ } else {
149
+ ParseMode::Lenient
150
+ };
151
+ parse_patch_text(patch, mode)
152
+ }
153
+
154
+ enum ParseMode {
155
+ /// Parse the patch text argument as is.
156
+ Strict,
157
+
158
+ /// GPT-4.1 is known to formulate the `command` array for the `local_shell`
159
+ /// tool call for `apply_patch` call using something like the following:
160
+ ///
161
+ /// ```json
162
+ /// [
163
+ /// "apply_patch",
164
+ /// "<<'EOF'\n*** Begin Patch\n*** Update File: README.md\n@@...\n*** End Patch\nEOF\n",
165
+ /// ]
166
+ /// ```
167
+ ///
168
+ /// This is a problem because `local_shell` is a bit of a misnomer: the
169
+ /// `command` is not invoked by passing the arguments to a shell like Bash,
170
+ /// but are invoked using something akin to `execvpe(3)`.
171
+ ///
172
+ /// This is significant in this case because where a shell would interpret
173
+ /// `<<'EOF'...` as a heredoc and pass the contents via stdin (which is
174
+ /// fine, as `apply_patch` is specified to read from stdin if no argument is
175
+ /// passed), `execvpe(3)` interprets the heredoc as a literal string. To get
176
+ /// the `local_shell` tool to run a command the way shell would, the
177
+ /// `command` array must be something like:
178
+ ///
179
+ /// ```json
180
+ /// [
181
+ /// "bash",
182
+ /// "-lc",
183
+ /// "apply_patch <<'EOF'\n*** Begin Patch\n*** Update File: README.md\n@@...\n*** End Patch\nEOF\n",
184
+ /// ]
185
+ /// ```
186
+ ///
187
+ /// In lenient mode, we check if the argument to `apply_patch` starts with
188
+ /// `<<'EOF'` and ends with `EOF\n`. If so, we strip off these markers,
189
+ /// trim() the result, and treat what is left as the patch text.
190
+ Lenient,
191
+ }
192
+
193
+ fn parse_patch_text(patch: &str, mode: ParseMode) -> Result<ApplyPatchArgs, ParseError> {
194
+ let lines: Vec<&str> = patch.trim().lines().collect();
195
+ let patch_lines = match mode {
196
+ ParseMode::Strict => check_patch_boundaries_strict(&lines)?,
197
+ ParseMode::Lenient => check_patch_boundaries_lenient(&lines)?,
198
+ };
199
+
200
+ let patch = patch_lines.join("\n");
201
+ let mut parser = StreamingPatchParser::default();
202
+ parser.push_delta(&patch)?;
203
+ let hunks = parser.finish()?;
204
+ let environment_id = parser.environment_id().map(str::to_owned);
205
+ Ok(ApplyPatchArgs {
206
+ hunks,
207
+ patch,
208
+ workdir: None,
209
+ environment_id,
210
+ })
211
+ }
212
+
213
+ /// Checks the start and end lines of the patch text for `apply_patch`,
214
+ /// returning an error if they do not match the expected markers.
215
+ fn check_patch_boundaries_strict<'a>(lines: &'a [&'a str]) -> Result<&'a [&'a str], ParseError> {
216
+ let (first_line, last_line) = match lines {
217
+ [] => (None, None),
218
+ [first] => (Some(first), Some(first)),
219
+ [first, .., last] => (Some(first), Some(last)),
220
+ };
221
+ check_start_and_end_lines_strict(first_line, last_line)?;
222
+ Ok(lines)
223
+ }
224
+
225
+ /// If we are in lenient mode, we check if the first line starts with `<<EOF`
226
+ /// (possibly quoted) and the last line ends with `EOF`. There must be at least
227
+ /// 4 lines total because the heredoc markers take up 2 lines and the patch text
228
+ /// must have at least 2 lines.
229
+ ///
230
+ /// If successful, returns the lines of the patch text that contain the patch
231
+ /// contents, excluding the heredoc markers.
232
+ fn check_patch_boundaries_lenient<'a>(
233
+ original_lines: &'a [&'a str],
234
+ ) -> Result<&'a [&'a str], ParseError> {
235
+ let original_parse_error = match check_patch_boundaries_strict(original_lines) {
236
+ Ok(lines) => return Ok(lines),
237
+ Err(e) => e,
238
+ };
239
+
240
+ match original_lines {
241
+ [first, .., last] => {
242
+ if (first == &"<<EOF" || first == &"<<'EOF'" || first == &"<<\"EOF\"")
243
+ && last.ends_with("EOF")
244
+ && original_lines.len() >= 4
245
+ {
246
+ let inner_lines = &original_lines[1..original_lines.len() - 1];
247
+ check_patch_boundaries_strict(inner_lines)
248
+ } else {
249
+ Err(original_parse_error)
250
+ }
251
+ }
252
+ _ => Err(original_parse_error),
253
+ }
254
+ }
255
+
256
+ fn check_start_and_end_lines_strict(
257
+ first_line: Option<&&str>,
258
+ last_line: Option<&&str>,
259
+ ) -> Result<(), ParseError> {
260
+ let first_line = first_line.map(|line| line.trim());
261
+ let last_line = last_line.map(|line| line.trim());
262
+
263
+ match (first_line, last_line) {
264
+ (Some(first), Some(last)) if first == BEGIN_PATCH_MARKER && last == END_PATCH_MARKER => {
265
+ Ok(())
266
+ }
267
+ (Some(first), _) if first != BEGIN_PATCH_MARKER => Err(InvalidPatchError(String::from(
268
+ "The first line of the patch must be '*** Begin Patch'",
269
+ ))),
270
+ _ => Err(InvalidPatchError(String::from(
271
+ "The last line of the patch must be '*** End Patch'",
272
+ ))),
273
+ }
274
+ }
275
+
276
+ #[test]
277
+ fn test_parse_patch() {
278
+ assert_eq!(
279
+ parse_patch_text("bad", ParseMode::Strict),
280
+ Err(InvalidPatchError(
281
+ "The first line of the patch must be '*** Begin Patch'".to_string()
282
+ ))
283
+ );
284
+ assert_eq!(
285
+ parse_patch_text("*** Begin Patch\nbad", ParseMode::Strict),
286
+ Err(InvalidPatchError(
287
+ "The last line of the patch must be '*** End Patch'".to_string()
288
+ ))
289
+ );
290
+
291
+ assert_eq!(
292
+ parse_patch_text(
293
+ concat!(
294
+ "*** Begin Patch",
295
+ " ",
296
+ "\n*** Add File: foo\n+hi\n",
297
+ " ",
298
+ "*** End Patch"
299
+ ),
300
+ ParseMode::Strict
301
+ )
302
+ .unwrap()
303
+ .hunks,
304
+ vec![AddFile {
305
+ path: PathBuf::from("foo"),
306
+ contents: "hi\n".to_string()
307
+ }]
308
+ );
309
+ assert_eq!(
310
+ parse_patch_text(
311
+ "*** Begin Patch\n\
312
+ *** Update File: test.py\n\
313
+ *** End Patch",
314
+ ParseMode::Strict
315
+ ),
316
+ Err(InvalidHunkError {
317
+ message: "Update file hunk for path 'test.py' is empty".to_string(),
318
+ line_number: 2,
319
+ })
320
+ );
321
+ assert_eq!(
322
+ parse_patch_text(
323
+ "*** Begin Patch\n\
324
+ *** End Patch",
325
+ ParseMode::Strict
326
+ )
327
+ .unwrap()
328
+ .hunks,
329
+ Vec::new()
330
+ );
331
+ assert_eq!(
332
+ parse_patch_text(
333
+ "*** Begin Patch\n\
334
+ *** Add File: path/add.py\n\
335
+ +abc\n\
336
+ +def\n\
337
+ *** Delete File: path/delete.py\n\
338
+ *** Update File: path/update.py\n\
339
+ *** Move to: path/update2.py\n\
340
+ @@ def f():\n\
341
+ - pass\n\
342
+ + return 123\n\
343
+ *** End Patch",
344
+ ParseMode::Strict
345
+ )
346
+ .unwrap()
347
+ .hunks,
348
+ vec![
349
+ AddFile {
350
+ path: PathBuf::from("path/add.py"),
351
+ contents: "abc\ndef\n".to_string()
352
+ },
353
+ DeleteFile {
354
+ path: PathBuf::from("path/delete.py")
355
+ },
356
+ UpdateFile {
357
+ path: PathBuf::from("path/update.py"),
358
+ move_path: Some(PathBuf::from("path/update2.py")),
359
+ chunks: vec![UpdateFileChunk {
360
+ change_context: Some("def f():".to_string()),
361
+ old_lines: vec![" pass".to_string()],
362
+ new_lines: vec![" return 123".to_string()],
363
+ context_line_indices: vec![],
364
+ is_end_of_file: false
365
+ }]
366
+ }
367
+ ]
368
+ );
369
+ // Update hunk followed by another hunk (Add File).
370
+ assert_eq!(
371
+ parse_patch_text(
372
+ "*** Begin Patch\n\
373
+ *** Update File: file.py\n\
374
+ @@\n\
375
+ +line\n\
376
+ *** Add File: other.py\n\
377
+ +content\n\
378
+ *** End Patch",
379
+ ParseMode::Strict
380
+ )
381
+ .unwrap()
382
+ .hunks,
383
+ vec![
384
+ UpdateFile {
385
+ path: PathBuf::from("file.py"),
386
+ move_path: None,
387
+ chunks: vec![UpdateFileChunk {
388
+ change_context: None,
389
+ old_lines: vec![],
390
+ new_lines: vec!["line".to_string()],
391
+ context_line_indices: vec![],
392
+ is_end_of_file: false
393
+ }],
394
+ },
395
+ AddFile {
396
+ path: PathBuf::from("other.py"),
397
+ contents: "content\n".to_string()
398
+ }
399
+ ]
400
+ );
401
+
402
+ // Update hunk without an explicit @@ header for the first chunk should parse.
403
+ // Use a raw string to preserve the leading space diff marker on the context line.
404
+ assert_eq!(
405
+ parse_patch_text(
406
+ r#"*** Begin Patch
407
+ *** Update File: file2.py
408
+ import foo
409
+ +bar
410
+ *** End Patch"#,
411
+ ParseMode::Strict
412
+ )
413
+ .unwrap()
414
+ .hunks,
415
+ vec![UpdateFile {
416
+ path: PathBuf::from("file2.py"),
417
+ move_path: None,
418
+ chunks: vec![UpdateFileChunk {
419
+ change_context: None,
420
+ old_lines: vec!["import foo".to_string()],
421
+ new_lines: vec!["import foo".to_string(), "bar".to_string()],
422
+ context_line_indices: vec![(0, 0)],
423
+ is_end_of_file: false,
424
+ }],
425
+ }]
426
+ );
427
+ }
428
+
429
+ #[test]
430
+ fn test_parse_patch_preserves_end_of_file_marker() {
431
+ let patch =
432
+ "*** Begin Patch\n*** Update File: file.txt\n@@\n+quux\n*** End of File\n\n*** End Patch";
433
+ assert_eq!(
434
+ parse_patch(patch),
435
+ Ok(ApplyPatchArgs {
436
+ hunks: vec![UpdateFile {
437
+ path: PathBuf::from("file.txt"),
438
+ move_path: None,
439
+ chunks: vec![UpdateFileChunk {
440
+ change_context: None,
441
+ old_lines: Vec::new(),
442
+ new_lines: vec!["quux".to_string()],
443
+ context_line_indices: vec![],
444
+ is_end_of_file: true,
445
+ }],
446
+ }],
447
+ patch: patch.to_string(),
448
+ workdir: None,
449
+ environment_id: None,
450
+ })
451
+ );
452
+ }
453
+
454
+ #[test]
455
+ fn test_parse_patch_accepts_relative_and_absolute_hunk_paths() {
456
+ let dir = tempfile::tempdir().unwrap();
457
+ let absolute_delete = dir.path().join("absolute-delete.py").abs();
458
+ let absolute_update = dir.path().join("absolute-update.py").abs();
459
+ let patch_text = format!(
460
+ r#"*** Begin Patch
461
+ *** Add File: relative-add.py
462
+ +content
463
+ *** Delete File: {}
464
+ *** Update File: {}
465
+ @@
466
+ -old
467
+ +new
468
+ *** End Patch"#,
469
+ absolute_delete.display(),
470
+ absolute_update.display()
471
+ );
472
+
473
+ assert_eq!(
474
+ parse_patch_text(&patch_text, ParseMode::Strict)
475
+ .unwrap()
476
+ .hunks,
477
+ vec![
478
+ AddFile {
479
+ path: PathBuf::from("relative-add.py"),
480
+ contents: "content\n".to_string()
481
+ },
482
+ DeleteFile {
483
+ path: absolute_delete.to_path_buf()
484
+ },
485
+ UpdateFile {
486
+ path: absolute_update.to_path_buf(),
487
+ move_path: None,
488
+ chunks: vec![UpdateFileChunk {
489
+ change_context: None,
490
+ old_lines: vec!["old".to_string()],
491
+ new_lines: vec!["new".to_string()],
492
+ context_line_indices: vec![],
493
+ is_end_of_file: false
494
+ }]
495
+ },
496
+ ]
497
+ );
498
+ }
499
+
500
+ #[test]
501
+ fn test_hunk_resolve_path_accepts_relative_and_absolute_paths() {
502
+ let cwd_dir = tempfile::tempdir().unwrap();
503
+ let cwd = PathUri::from_host_native_path(cwd_dir.path()).unwrap();
504
+ let absolute_dir = tempfile::tempdir().unwrap();
505
+ let absolute_add = absolute_dir.path().join("absolute-add.py").abs();
506
+ let absolute_delete = absolute_dir.path().join("absolute-delete.py").abs();
507
+ let absolute_update = absolute_dir.path().join("absolute-update.py").abs();
508
+
509
+ for (hunk, expected_path) in [
510
+ (
511
+ AddFile {
512
+ path: PathBuf::from("relative-add.py"),
513
+ contents: String::new(),
514
+ },
515
+ cwd.join("relative-add.py").unwrap(),
516
+ ),
517
+ (
518
+ DeleteFile {
519
+ path: PathBuf::from("relative-delete.py"),
520
+ },
521
+ cwd.join("relative-delete.py").unwrap(),
522
+ ),
523
+ (
524
+ UpdateFile {
525
+ path: PathBuf::from("relative-update.py"),
526
+ move_path: None,
527
+ chunks: Vec::new(),
528
+ },
529
+ cwd.join("relative-update.py").unwrap(),
530
+ ),
531
+ (
532
+ AddFile {
533
+ path: absolute_add.to_path_buf(),
534
+ contents: String::new(),
535
+ },
536
+ PathUri::from_abs_path(&absolute_add),
537
+ ),
538
+ (
539
+ DeleteFile {
540
+ path: absolute_delete.to_path_buf(),
541
+ },
542
+ PathUri::from_abs_path(&absolute_delete),
543
+ ),
544
+ (
545
+ UpdateFile {
546
+ path: absolute_update.to_path_buf(),
547
+ move_path: None,
548
+ chunks: Vec::new(),
549
+ },
550
+ PathUri::from_abs_path(&absolute_update),
551
+ ),
552
+ ] {
553
+ assert_eq!(hunk.resolve_path(&cwd), Ok(expected_path));
554
+ }
555
+ }
556
+
557
+ #[test]
558
+ fn test_parse_patch_lenient() {
559
+ let patch_text = r#"*** Begin Patch
560
+ *** Update File: file2.py
561
+ import foo
562
+ +bar
563
+ *** End Patch"#;
564
+ let expected_patch = vec![UpdateFile {
565
+ path: PathBuf::from("file2.py"),
566
+ move_path: None,
567
+ chunks: vec![UpdateFileChunk {
568
+ change_context: None,
569
+ old_lines: vec!["import foo".to_string()],
570
+ new_lines: vec!["import foo".to_string(), "bar".to_string()],
571
+ context_line_indices: vec![(0, 0)],
572
+ is_end_of_file: false,
573
+ }],
574
+ }];
575
+ let expected_error =
576
+ InvalidPatchError("The first line of the patch must be '*** Begin Patch'".to_string());
577
+
578
+ let patch_text_in_heredoc = format!("<<EOF\n{patch_text}\nEOF\n");
579
+ assert_eq!(
580
+ parse_patch_text(&patch_text_in_heredoc, ParseMode::Strict),
581
+ Err(expected_error.clone())
582
+ );
583
+ assert_eq!(
584
+ parse_patch_text(&patch_text_in_heredoc, ParseMode::Lenient),
585
+ Ok(ApplyPatchArgs {
586
+ hunks: expected_patch.clone(),
587
+ patch: patch_text.to_string(),
588
+ workdir: None,
589
+ environment_id: None,
590
+ })
591
+ );
592
+
593
+ let patch_text_in_single_quoted_heredoc = format!("<<'EOF'\n{patch_text}\nEOF\n");
594
+ assert_eq!(
595
+ parse_patch_text(&patch_text_in_single_quoted_heredoc, ParseMode::Strict),
596
+ Err(expected_error.clone())
597
+ );
598
+ assert_eq!(
599
+ parse_patch_text(&patch_text_in_single_quoted_heredoc, ParseMode::Lenient),
600
+ Ok(ApplyPatchArgs {
601
+ hunks: expected_patch.clone(),
602
+ patch: patch_text.to_string(),
603
+ workdir: None,
604
+ environment_id: None,
605
+ })
606
+ );
607
+
608
+ let patch_text_in_double_quoted_heredoc = format!("<<\"EOF\"\n{patch_text}\nEOF\n");
609
+ assert_eq!(
610
+ parse_patch_text(&patch_text_in_double_quoted_heredoc, ParseMode::Strict),
611
+ Err(expected_error.clone())
612
+ );
613
+ assert_eq!(
614
+ parse_patch_text(&patch_text_in_double_quoted_heredoc, ParseMode::Lenient),
615
+ Ok(ApplyPatchArgs {
616
+ hunks: expected_patch,
617
+ patch: patch_text.to_string(),
618
+ workdir: None,
619
+ environment_id: None,
620
+ })
621
+ );
622
+
623
+ let patch_text_in_mismatched_quotes_heredoc = format!("<<\"EOF'\n{patch_text}\nEOF\n");
624
+ assert_eq!(
625
+ parse_patch_text(&patch_text_in_mismatched_quotes_heredoc, ParseMode::Strict),
626
+ Err(expected_error.clone())
627
+ );
628
+ assert_eq!(
629
+ parse_patch_text(&patch_text_in_mismatched_quotes_heredoc, ParseMode::Lenient),
630
+ Err(expected_error.clone())
631
+ );
632
+
633
+ let patch_text_with_missing_closing_heredoc =
634
+ "<<EOF\n*** Begin Patch\n*** Update File: file2.py\nEOF\n".to_string();
635
+ assert_eq!(
636
+ parse_patch_text(&patch_text_with_missing_closing_heredoc, ParseMode::Strict),
637
+ Err(expected_error)
638
+ );
639
+ assert_eq!(
640
+ parse_patch_text(&patch_text_with_missing_closing_heredoc, ParseMode::Lenient),
641
+ Err(InvalidPatchError(
642
+ "The last line of the patch must be '*** End Patch'".to_string()
643
+ ))
644
+ );
645
+ }
646
+
647
+ #[test]
648
+ fn test_parse_patch_environment_id_preamble() {
649
+ assert_eq!(
650
+ parse_patch_text(
651
+ "*** Begin Patch\n\
652
+ *** Environment ID: remote\n\
653
+ *** Add File: hello.txt\n\
654
+ +hello\n\
655
+ *** End Patch",
656
+ ParseMode::Strict
657
+ ),
658
+ Ok(ApplyPatchArgs {
659
+ hunks: vec![AddFile {
660
+ path: PathBuf::from("hello.txt"),
661
+ contents: "hello\n".to_string(),
662
+ }],
663
+ patch: "*** Begin Patch\n*** Environment ID: remote\n*** Add File: hello.txt\n+hello\n*** End Patch".to_string(),
664
+ workdir: None,
665
+ environment_id: Some("remote".to_string()),
666
+ })
667
+ );
668
+
669
+ assert_eq!(
670
+ parse_patch_text(
671
+ "*** Begin Patch\n\
672
+ *** Environment ID: \n\
673
+ *** Add File: hello.txt\n\
674
+ +hello\n\
675
+ *** End Patch",
676
+ ParseMode::Strict
677
+ ),
678
+ Err(InvalidPatchError(
679
+ "apply_patch environment_id cannot be empty".to_string()
680
+ ))
681
+ );
682
+ }
codex-rs/apply-patch/src/seek_sequence.rs ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// Attempt to find the sequence of `pattern` lines within `lines` beginning at or after `start`.
2
+ /// Returns the starting index of the match or `None` if not found. Matches are attempted with
3
+ /// decreasing strictness: exact match, then ignoring trailing whitespace, then ignoring leading
4
+ /// and trailing whitespace. When `eof` is true, we first try starting at the end-of-file (so that
5
+ /// patterns intended to match file endings are applied at the end), and fall back to searching
6
+ /// from `start` if needed.
7
+ ///
8
+ /// Special cases handled defensively:
9
+ /// • Empty `pattern` → returns `Some(start)` (no-op match)
10
+ /// • `pattern.len() > lines.len()` → returns `None` (cannot match, avoids
11
+ /// out‑of‑bounds panic that occurred pre‑2025‑04‑12)
12
+ pub(crate) fn seek_sequence(
13
+ lines: &[String],
14
+ pattern: &[String],
15
+ start: usize,
16
+ eof: bool,
17
+ update_file_mode: crate::ApplyPatchFileUpdateMode,
18
+ ) -> Option<usize> {
19
+ if pattern.is_empty() {
20
+ return Some(start);
21
+ }
22
+
23
+ // When the pattern is longer than the available input there is no possible
24
+ // match. Early‑return to avoid the out‑of‑bounds slice that would occur in
25
+ // the search loops below (previously caused a panic when
26
+ // `pattern.len() > lines.len()`).
27
+ if pattern.len() > lines.len() {
28
+ return None;
29
+ }
30
+ let search_start = if eof && lines.len() >= pattern.len() {
31
+ let eof_start = lines.len() - pattern.len();
32
+ match update_file_mode {
33
+ crate::ApplyPatchFileUpdateMode::NormalizeToLf => eof_start,
34
+ crate::ApplyPatchFileUpdateMode::PreserveLineEndings => eof_start.max(start),
35
+ }
36
+ } else {
37
+ start
38
+ };
39
+ // Exact match first.
40
+ for i in search_start..=lines.len().saturating_sub(pattern.len()) {
41
+ if lines[i..i + pattern.len()] == *pattern {
42
+ return Some(i);
43
+ }
44
+ }
45
+ // Then rstrip match.
46
+ for i in search_start..=lines.len().saturating_sub(pattern.len()) {
47
+ let mut ok = true;
48
+ for (p_idx, pat) in pattern.iter().enumerate() {
49
+ if lines[i + p_idx].trim_end() != pat.trim_end() {
50
+ ok = false;
51
+ break;
52
+ }
53
+ }
54
+ if ok {
55
+ return Some(i);
56
+ }
57
+ }
58
+ // Finally, trim both sides to allow more lenience.
59
+ for i in search_start..=lines.len().saturating_sub(pattern.len()) {
60
+ let mut ok = true;
61
+ for (p_idx, pat) in pattern.iter().enumerate() {
62
+ if lines[i + p_idx].trim() != pat.trim() {
63
+ ok = false;
64
+ break;
65
+ }
66
+ }
67
+ if ok {
68
+ return Some(i);
69
+ }
70
+ }
71
+
72
+ // ------------------------------------------------------------------
73
+ // Final, most permissive pass – attempt to match after *normalising*
74
+ // common Unicode punctuation to their ASCII equivalents so that diffs
75
+ // authored with plain ASCII characters can still be applied to source
76
+ // files that contain typographic dashes / quotes, etc. This mirrors the
77
+ // fuzzy behaviour of `git apply` which ignores minor byte-level
78
+ // differences when locating context lines.
79
+ // ------------------------------------------------------------------
80
+
81
+ fn normalise(s: &str) -> String {
82
+ s.trim()
83
+ .chars()
84
+ .map(|c| match c {
85
+ // Various dash / hyphen code-points → ASCII '-'
86
+ '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}'
87
+ | '\u{2212}' => '-',
88
+ // Fancy single quotes → '\''
89
+ '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}' => '\'',
90
+ // Fancy double quotes → '"'
91
+ '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => '"',
92
+ // Non-breaking space and other odd spaces → normal space
93
+ '\u{00A0}' | '\u{2002}' | '\u{2003}' | '\u{2004}' | '\u{2005}' | '\u{2006}'
94
+ | '\u{2007}' | '\u{2008}' | '\u{2009}' | '\u{200A}' | '\u{202F}' | '\u{205F}'
95
+ | '\u{3000}' => ' ',
96
+ other => other,
97
+ })
98
+ .collect::<String>()
99
+ }
100
+
101
+ for i in search_start..=lines.len().saturating_sub(pattern.len()) {
102
+ let mut ok = true;
103
+ for (p_idx, pat) in pattern.iter().enumerate() {
104
+ if normalise(&lines[i + p_idx]) != normalise(pat) {
105
+ ok = false;
106
+ break;
107
+ }
108
+ }
109
+ if ok {
110
+ return Some(i);
111
+ }
112
+ }
113
+
114
+ None
115
+ }
116
+
117
+ #[cfg(test)]
118
+ mod tests {
119
+ use super::seek_sequence;
120
+ use crate::ApplyPatchFileUpdateMode;
121
+ use std::string::ToString;
122
+
123
+ fn to_vec(strings: &[&str]) -> Vec<String> {
124
+ strings.iter().map(ToString::to_string).collect()
125
+ }
126
+
127
+ #[test]
128
+ fn test_exact_match_finds_sequence() {
129
+ let lines = to_vec(&["foo", "bar", "baz"]);
130
+ let pattern = to_vec(&["bar", "baz"]);
131
+ assert_eq!(
132
+ seek_sequence(
133
+ &lines,
134
+ &pattern,
135
+ /*start*/ 0,
136
+ /*eof*/ false,
137
+ ApplyPatchFileUpdateMode::NormalizeToLf,
138
+ ),
139
+ Some(1)
140
+ );
141
+ }
142
+
143
+ #[test]
144
+ fn test_rstrip_match_ignores_trailing_whitespace() {
145
+ let lines = to_vec(&["foo ", "bar\t\t"]);
146
+ // Pattern omits trailing whitespace.
147
+ let pattern = to_vec(&["foo", "bar"]);
148
+ assert_eq!(
149
+ seek_sequence(
150
+ &lines,
151
+ &pattern,
152
+ /*start*/ 0,
153
+ /*eof*/ false,
154
+ ApplyPatchFileUpdateMode::NormalizeToLf,
155
+ ),
156
+ Some(0)
157
+ );
158
+ }
159
+
160
+ #[test]
161
+ fn test_trim_match_ignores_leading_and_trailing_whitespace() {
162
+ let lines = to_vec(&[" foo ", " bar\t"]);
163
+ // Pattern omits any additional whitespace.
164
+ let pattern = to_vec(&["foo", "bar"]);
165
+ assert_eq!(
166
+ seek_sequence(
167
+ &lines,
168
+ &pattern,
169
+ /*start*/ 0,
170
+ /*eof*/ false,
171
+ ApplyPatchFileUpdateMode::NormalizeToLf,
172
+ ),
173
+ Some(0)
174
+ );
175
+ }
176
+
177
+ #[test]
178
+ fn test_pattern_longer_than_input_returns_none() {
179
+ let lines = to_vec(&["just one line"]);
180
+ let pattern = to_vec(&["too", "many", "lines"]);
181
+ // Should not panic – must return None when pattern cannot possibly fit.
182
+ assert_eq!(
183
+ seek_sequence(
184
+ &lines,
185
+ &pattern,
186
+ /*start*/ 0,
187
+ /*eof*/ false,
188
+ ApplyPatchFileUpdateMode::NormalizeToLf,
189
+ ),
190
+ None
191
+ );
192
+ }
193
+ }
codex-rs/apply-patch/src/standalone_executable.rs ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::io::Read;
2
+ use std::io::Write;
3
+
4
+ pub fn main() -> ! {
5
+ let exit_code = run_main();
6
+ std::process::exit(exit_code);
7
+ }
8
+
9
+ /// We would prefer to return `std::process::ExitCode`, but its `exit_process()`
10
+ /// method is still a nightly API and we want main() to return !.
11
+ pub fn run_main() -> i32 {
12
+ // Expect either one argument (the full apply_patch payload) or read it from stdin.
13
+ let mut args = std::env::args_os();
14
+ let _argv0 = args.next();
15
+
16
+ let patch_arg = match args.next() {
17
+ Some(arg) => match arg.into_string() {
18
+ Ok(s) => s,
19
+ Err(_) => {
20
+ eprintln!("Error: apply_patch requires a UTF-8 PATCH argument.");
21
+ return 1;
22
+ }
23
+ },
24
+ None => {
25
+ // No argument provided; attempt to read the patch from stdin.
26
+ let mut buf = String::new();
27
+ match std::io::stdin().read_to_string(&mut buf) {
28
+ Ok(_) => {
29
+ if buf.is_empty() {
30
+ eprintln!("Usage: apply_patch 'PATCH'\n echo 'PATCH' | apply_patch");
31
+ return 2;
32
+ }
33
+ buf
34
+ }
35
+ Err(err) => {
36
+ eprintln!("Error: Failed to read PATCH from stdin.\n{err}");
37
+ return 1;
38
+ }
39
+ }
40
+ }
41
+ };
42
+
43
+ // Refuse extra args to avoid ambiguity.
44
+ if args.next().is_some() {
45
+ eprintln!("Error: apply_patch accepts exactly one argument.");
46
+ return 2;
47
+ }
48
+
49
+ let mut stdout = std::io::stdout();
50
+ let mut stderr = std::io::stderr();
51
+ let cwd = match codex_utils_absolute_path::AbsolutePathBuf::current_dir() {
52
+ Ok(cwd) => cwd,
53
+ Err(err) => {
54
+ eprintln!("Error: Failed to determine current directory.\n{err}");
55
+ return 1;
56
+ }
57
+ };
58
+ let runtime = match tokio::runtime::Builder::new_current_thread()
59
+ .enable_all()
60
+ .build()
61
+ {
62
+ Ok(runtime) => runtime,
63
+ Err(err) => {
64
+ eprintln!("Error: Failed to initialize runtime.\n{err}");
65
+ return 1;
66
+ }
67
+ };
68
+ // TODO(anp): Discover the standalone executable cwd as PathUri directly.
69
+ let cwd = codex_utils_path_uri::PathUri::from_abs_path(&cwd);
70
+ let update_file_mode = crate::apply_patch_file_update_mode_from_env();
71
+ match runtime.block_on(crate::apply_patch_with_options(
72
+ &patch_arg,
73
+ crate::ApplyPatchOptions {
74
+ update_file_mode,
75
+ ..Default::default()
76
+ },
77
+ &cwd,
78
+ &mut stdout,
79
+ &mut stderr,
80
+ codex_exec_server::LOCAL_FS.as_ref(),
81
+ /*sandbox*/ None,
82
+ )) {
83
+ Ok(_) => {
84
+ // Flush to ensure output ordering when used in pipelines.
85
+ let _ = stdout.flush();
86
+ 0
87
+ }
88
+ Err(_) => 1,
89
+ }
90
+ }
codex-rs/apply-patch/src/streaming_parser.rs ADDED
@@ -0,0 +1,924 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::path::PathBuf;
2
+
3
+ use crate::parser::ADD_FILE_MARKER;
4
+ use crate::parser::BEGIN_PATCH_MARKER;
5
+ use crate::parser::CHANGE_CONTEXT_MARKER;
6
+ use crate::parser::DELETE_FILE_MARKER;
7
+ use crate::parser::EMPTY_CHANGE_CONTEXT_MARKER;
8
+ use crate::parser::END_PATCH_MARKER;
9
+ use crate::parser::EOF_MARKER;
10
+ use crate::parser::Hunk;
11
+ use crate::parser::MOVE_TO_MARKER;
12
+ use crate::parser::ParseError;
13
+ use crate::parser::UPDATE_FILE_MARKER;
14
+ use crate::parser::UpdateFileChunk;
15
+
16
+ use Hunk::*;
17
+ use ParseError::*;
18
+
19
+ const ENVIRONMENT_ID_MARKER: &str = "*** Environment ID:";
20
+
21
+ #[derive(Debug, Default, Clone)]
22
+ pub struct StreamingPatchParser {
23
+ line_buffer: String,
24
+ state: StreamingParserState,
25
+ line_number: usize,
26
+ }
27
+
28
+ #[derive(Debug, Default, Clone)]
29
+ struct StreamingParserState {
30
+ mode: StreamingParserMode,
31
+ hunks: Vec<Hunk>,
32
+ environment_id: Option<String>,
33
+ }
34
+
35
+ #[derive(Debug, Default, Clone, Copy)]
36
+ enum StreamingParserMode {
37
+ #[default]
38
+ NotStarted,
39
+ StartedPatch,
40
+ AddFile,
41
+ DeleteFile,
42
+ UpdateFile {
43
+ hunk_line_number: usize,
44
+ },
45
+ EndedPatch,
46
+ }
47
+
48
+ impl StreamingPatchParser {
49
+ pub fn environment_id(&self) -> Option<&str> {
50
+ self.state.environment_id.as_deref()
51
+ }
52
+
53
+ fn ensure_update_hunk_is_not_empty(&self, line: &str) -> Result<(), ParseError> {
54
+ if let Some(UpdateFile { path, chunks, .. }) = self.state.hunks.last() {
55
+ if chunks.is_empty()
56
+ && let StreamingParserMode::UpdateFile { hunk_line_number } = self.state.mode
57
+ {
58
+ return Err(InvalidHunkError {
59
+ message: format!("Update file hunk for path '{}' is empty", path.display()),
60
+ line_number: hunk_line_number,
61
+ });
62
+ }
63
+ if chunks
64
+ .last()
65
+ .is_some_and(|chunk| chunk.old_lines.is_empty() && chunk.new_lines.is_empty())
66
+ {
67
+ if line == END_PATCH_MARKER {
68
+ return Err(InvalidHunkError {
69
+ message: "Update hunk does not contain any lines".to_string(),
70
+ line_number: self.line_number,
71
+ });
72
+ }
73
+ return Err(InvalidHunkError {
74
+ message: format!(
75
+ "Unexpected line found in update hunk: '{line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)"
76
+ ),
77
+ line_number: self.line_number,
78
+ });
79
+ }
80
+ }
81
+ Ok(())
82
+ }
83
+
84
+ fn handle_hunk_headers_and_end_patch(&mut self, trimmed: &str) -> Result<bool, ParseError> {
85
+ if matches!(self.state.mode, StreamingParserMode::StartedPatch)
86
+ && let Some(environment_id) = trimmed.strip_prefix(ENVIRONMENT_ID_MARKER)
87
+ {
88
+ if self.state.environment_id.is_some() {
89
+ return Err(InvalidPatchError(
90
+ "apply_patch environment_id cannot be specified more than once".to_string(),
91
+ ));
92
+ }
93
+ let environment_id = environment_id.trim();
94
+ if environment_id.is_empty() {
95
+ return Err(InvalidPatchError(
96
+ "apply_patch environment_id cannot be empty".to_string(),
97
+ ));
98
+ }
99
+ self.state.environment_id = Some(environment_id.to_string());
100
+ return Ok(true);
101
+ }
102
+ if trimmed == END_PATCH_MARKER {
103
+ self.ensure_update_hunk_is_not_empty(trimmed)?;
104
+ self.state.mode = StreamingParserMode::EndedPatch;
105
+ return Ok(true);
106
+ }
107
+ if let Some(path) = trimmed.strip_prefix(ADD_FILE_MARKER) {
108
+ self.ensure_update_hunk_is_not_empty(trimmed)?;
109
+ self.state.hunks.push(AddFile {
110
+ path: PathBuf::from(path),
111
+ contents: String::new(),
112
+ });
113
+ self.state.mode = StreamingParserMode::AddFile;
114
+ return Ok(true);
115
+ }
116
+ if let Some(path) = trimmed.strip_prefix(DELETE_FILE_MARKER) {
117
+ self.ensure_update_hunk_is_not_empty(trimmed)?;
118
+ self.state.hunks.push(DeleteFile {
119
+ path: PathBuf::from(path),
120
+ });
121
+ self.state.mode = StreamingParserMode::DeleteFile;
122
+ return Ok(true);
123
+ }
124
+ if let Some(path) = trimmed.strip_prefix(UPDATE_FILE_MARKER) {
125
+ self.ensure_update_hunk_is_not_empty(trimmed)?;
126
+ self.state.hunks.push(UpdateFile {
127
+ path: PathBuf::from(path),
128
+ move_path: None,
129
+ chunks: Vec::new(),
130
+ });
131
+ self.state.mode = StreamingParserMode::UpdateFile {
132
+ hunk_line_number: self.line_number,
133
+ };
134
+ return Ok(true);
135
+ }
136
+ Ok(false)
137
+ }
138
+
139
+ pub fn push_delta(&mut self, delta: &str) -> Result<Vec<Hunk>, ParseError> {
140
+ for ch in delta.chars() {
141
+ if ch == '\n' {
142
+ let mut line = std::mem::take(&mut self.line_buffer);
143
+ line.truncate(line.strip_suffix('\r').map_or(line.len(), str::len));
144
+ self.line_number += 1;
145
+ self.process_line(&line)?;
146
+ } else {
147
+ self.line_buffer.push(ch);
148
+ }
149
+ }
150
+
151
+ Ok(self.state.hunks.clone())
152
+ }
153
+
154
+ pub fn finish(&mut self) -> Result<Vec<Hunk>, ParseError> {
155
+ if !self.line_buffer.is_empty() {
156
+ let line = std::mem::take(&mut self.line_buffer);
157
+ self.line_number += 1;
158
+ if line.trim() == END_PATCH_MARKER {
159
+ self.ensure_update_hunk_is_not_empty(line.trim())?;
160
+ self.state.mode = StreamingParserMode::EndedPatch;
161
+ } else {
162
+ self.process_line(&line)?;
163
+ }
164
+ }
165
+
166
+ if !matches!(self.state.mode, StreamingParserMode::EndedPatch) {
167
+ return Err(InvalidPatchError(
168
+ "The last line of the patch must be '*** End Patch'".to_string(),
169
+ ));
170
+ }
171
+
172
+ Ok(self.state.hunks.clone())
173
+ }
174
+
175
+ fn process_line(&mut self, line: &str) -> Result<(), ParseError> {
176
+ let trimmed = line.trim();
177
+ match self.state.mode {
178
+ StreamingParserMode::NotStarted => {
179
+ if trimmed == BEGIN_PATCH_MARKER {
180
+ self.state.mode = StreamingParserMode::StartedPatch;
181
+ return Ok(());
182
+ }
183
+ Err(InvalidPatchError(
184
+ "The first line of the patch must be '*** Begin Patch'".to_string(),
185
+ ))
186
+ }
187
+ StreamingParserMode::StartedPatch => {
188
+ if self.handle_hunk_headers_and_end_patch(trimmed)? {
189
+ return Ok(());
190
+ }
191
+ Err(InvalidHunkError {
192
+ message: format!(
193
+ "'{trimmed}' is not a valid hunk header. Valid hunk headers: '*** Add File: {{path}}', '*** Delete File: {{path}}', '*** Update File: {{path}}'"
194
+ ),
195
+ line_number: self.line_number,
196
+ })
197
+ }
198
+ StreamingParserMode::AddFile => {
199
+ if self.handle_hunk_headers_and_end_patch(trimmed)? {
200
+ return Ok(());
201
+ }
202
+ if let Some(line_to_add) = line.strip_prefix('+')
203
+ && let Some(AddFile { contents, .. }) = self.state.hunks.last_mut()
204
+ {
205
+ contents.push_str(line_to_add);
206
+ contents.push('\n');
207
+ return Ok(());
208
+ }
209
+ Err(InvalidHunkError {
210
+ message: format!(
211
+ "'{trimmed}' is not a valid hunk header. Valid hunk headers: '*** Add File: {{path}}', '*** Delete File: {{path}}', '*** Update File: {{path}}'"
212
+ ),
213
+ line_number: self.line_number,
214
+ })
215
+ }
216
+ StreamingParserMode::DeleteFile => {
217
+ if self.handle_hunk_headers_and_end_patch(trimmed)? {
218
+ return Ok(());
219
+ }
220
+ Err(InvalidHunkError {
221
+ message: format!(
222
+ "'{trimmed}' is not a valid hunk header. Valid hunk headers: '*** Add File: {{path}}', '*** Delete File: {{path}}', '*** Update File: {{path}}'"
223
+ ),
224
+ line_number: self.line_number,
225
+ })
226
+ }
227
+ StreamingParserMode::UpdateFile { hunk_line_number } => {
228
+ let update_line = line.trim_end();
229
+ if self.handle_hunk_headers_and_end_patch(update_line)? {
230
+ return Ok(());
231
+ }
232
+
233
+ if let Some(UpdateFile {
234
+ move_path, chunks, ..
235
+ }) = self.state.hunks.last_mut()
236
+ {
237
+ if chunks.last().is_some_and(|chunk| chunk.is_end_of_file) {
238
+ if update_line.is_empty() {
239
+ return Ok(());
240
+ }
241
+ if update_line != EMPTY_CHANGE_CONTEXT_MARKER
242
+ && !update_line.starts_with(CHANGE_CONTEXT_MARKER)
243
+ {
244
+ return Err(InvalidHunkError {
245
+ message: format!(
246
+ "Expected update hunk to start with a @@ context marker, got: '{line}'"
247
+ ),
248
+ line_number: self.line_number,
249
+ });
250
+ }
251
+ }
252
+
253
+ if chunks.is_empty()
254
+ && move_path.is_none()
255
+ && let Some(move_to_path) = update_line.strip_prefix(MOVE_TO_MARKER)
256
+ {
257
+ *move_path = Some(PathBuf::from(move_to_path));
258
+ self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
259
+ return Ok(());
260
+ }
261
+
262
+ if (update_line == EMPTY_CHANGE_CONTEXT_MARKER
263
+ || update_line.starts_with(CHANGE_CONTEXT_MARKER))
264
+ && chunks.last().is_some_and(|chunk| {
265
+ chunk.old_lines.is_empty() && chunk.new_lines.is_empty()
266
+ })
267
+ {
268
+ return Err(InvalidHunkError {
269
+ message: format!(
270
+ "Unexpected line found in update hunk: '{line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)"
271
+ ),
272
+ line_number: self.line_number,
273
+ });
274
+ }
275
+
276
+ if update_line == EMPTY_CHANGE_CONTEXT_MARKER {
277
+ chunks.push(UpdateFileChunk::default());
278
+ self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
279
+ return Ok(());
280
+ }
281
+
282
+ if let Some(change_context) = update_line.strip_prefix(CHANGE_CONTEXT_MARKER) {
283
+ chunks.push(UpdateFileChunk {
284
+ change_context: Some(change_context.to_string()),
285
+ ..UpdateFileChunk::default()
286
+ });
287
+ self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
288
+ return Ok(());
289
+ }
290
+
291
+ if update_line == EOF_MARKER {
292
+ if chunks.last().is_some_and(|chunk| {
293
+ chunk.old_lines.is_empty() && chunk.new_lines.is_empty()
294
+ }) {
295
+ return Err(InvalidHunkError {
296
+ message: "Update hunk does not contain any lines".to_string(),
297
+ line_number: self.line_number,
298
+ });
299
+ }
300
+ if let Some(chunk) = chunks.last_mut() {
301
+ chunk.is_end_of_file = true;
302
+ }
303
+ self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
304
+ return Ok(());
305
+ }
306
+
307
+ if line.is_empty() {
308
+ if chunks.is_empty() {
309
+ chunks.push(UpdateFileChunk::default());
310
+ }
311
+ if let Some(chunk) = chunks.last_mut() {
312
+ chunk.push_context_line(String::new());
313
+ }
314
+ self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
315
+ return Ok(());
316
+ }
317
+
318
+ if let Some(line_to_add) = line.strip_prefix(' ') {
319
+ if chunks.is_empty() {
320
+ chunks.push(UpdateFileChunk::default());
321
+ }
322
+ if let Some(chunk) = chunks.last_mut() {
323
+ chunk.push_context_line(line_to_add.to_string());
324
+ }
325
+ self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
326
+ return Ok(());
327
+ }
328
+
329
+ if let Some(line_to_add) = line.strip_prefix('+') {
330
+ if chunks.is_empty() {
331
+ chunks.push(UpdateFileChunk::default());
332
+ }
333
+ if let Some(chunk) = chunks.last_mut() {
334
+ chunk.new_lines.push(line_to_add.to_string());
335
+ }
336
+ self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
337
+ return Ok(());
338
+ }
339
+
340
+ if let Some(line_to_remove) = line.strip_prefix('-') {
341
+ if chunks.is_empty() {
342
+ chunks.push(UpdateFileChunk::default());
343
+ }
344
+ if let Some(chunk) = chunks.last_mut() {
345
+ chunk.old_lines.push(line_to_remove.to_string());
346
+ }
347
+ self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
348
+ return Ok(());
349
+ }
350
+
351
+ if chunks.last().is_some_and(|chunk| {
352
+ !chunk.old_lines.is_empty() || !chunk.new_lines.is_empty()
353
+ }) {
354
+ return Err(InvalidHunkError {
355
+ message: format!(
356
+ "Expected update hunk to start with a @@ context marker, got: '{line}'"
357
+ ),
358
+ line_number: self.line_number,
359
+ });
360
+ }
361
+ }
362
+ Err(InvalidHunkError {
363
+ message: format!(
364
+ "Unexpected line found in update hunk: '{line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)"
365
+ ),
366
+ line_number: self.line_number,
367
+ })
368
+ }
369
+ StreamingParserMode::EndedPatch => {
370
+ if trimmed.is_empty() {
371
+ Ok(())
372
+ } else {
373
+ Err(InvalidPatchError(
374
+ "The last line of the patch must be '*** End Patch'".to_string(),
375
+ ))
376
+ }
377
+ }
378
+ }
379
+ }
380
+ }
381
+
382
+ #[cfg(test)]
383
+ mod tests {
384
+ use pretty_assertions::assert_eq;
385
+ use std::path::PathBuf;
386
+
387
+ use super::*;
388
+
389
+ #[test]
390
+ fn test_streaming_patch_parser_streams_complete_lines_before_end_patch() {
391
+ let mut parser = StreamingPatchParser::default();
392
+ assert_eq!(
393
+ parser.push_delta("*** Begin Patch\n*** Add File: src/hello.txt\n+hello\n+wor"),
394
+ Ok(vec![AddFile {
395
+ path: PathBuf::from("src/hello.txt"),
396
+ contents: "hello\n".to_string(),
397
+ }])
398
+ );
399
+ assert_eq!(
400
+ parser.push_delta("ld\n"),
401
+ Ok(vec![AddFile {
402
+ path: PathBuf::from("src/hello.txt"),
403
+ contents: "hello\nworld\n".to_string(),
404
+ }])
405
+ );
406
+
407
+ let mut parser = StreamingPatchParser::default();
408
+ assert_eq!(
409
+ parser.push_delta(
410
+ "*** Begin Patch\n*** Update File: src/old.rs\n*** Move to: src/new.rs\n@@\n-old\n+new\n",
411
+ ),
412
+ Ok(vec![UpdateFile {
413
+ path: PathBuf::from("src/old.rs"),
414
+ move_path: Some(PathBuf::from("src/new.rs")),
415
+ chunks: vec![UpdateFileChunk {
416
+ change_context: None,
417
+ old_lines: vec!["old".to_string()],
418
+ new_lines: vec!["new".to_string()],
419
+ context_line_indices: vec![],
420
+ is_end_of_file: false,
421
+ }],
422
+ }])
423
+ );
424
+
425
+ let mut parser = StreamingPatchParser::default();
426
+ assert_eq!(
427
+ parser.push_delta("*** Begin Patch\n*** Delete File: gone.txt"),
428
+ Ok(Vec::new())
429
+ );
430
+ assert_eq!(
431
+ parser.push_delta("\n"),
432
+ Ok(vec![DeleteFile {
433
+ path: PathBuf::from("gone.txt"),
434
+ }])
435
+ );
436
+
437
+ let mut parser = StreamingPatchParser::default();
438
+ assert_eq!(
439
+ parser.push_delta(
440
+ "*** Begin Patch\n*** Add File: src/one.txt\n+one\n*** Delete File: src/two.txt\n",
441
+ ),
442
+ Ok(vec![
443
+ AddFile {
444
+ path: PathBuf::from("src/one.txt"),
445
+ contents: "one\n".to_string(),
446
+ },
447
+ DeleteFile {
448
+ path: PathBuf::from("src/two.txt"),
449
+ },
450
+ ])
451
+ );
452
+ }
453
+
454
+ #[test]
455
+ fn test_streaming_patch_parser_environment_id_mode() {
456
+ let patch = "\
457
+ *** Begin Patch
458
+ *** Environment ID: remote
459
+ *** Add File: src/hello.txt
460
+ +hello
461
+ *** End Patch
462
+ ";
463
+
464
+ let mut parser = StreamingPatchParser::default();
465
+ assert_eq!(
466
+ parser.push_delta(patch),
467
+ Ok(vec![AddFile {
468
+ path: PathBuf::from("src/hello.txt"),
469
+ contents: "hello\n".to_string(),
470
+ }])
471
+ );
472
+ assert_eq!(parser.environment_id(), Some("remote"));
473
+
474
+ let mut parser = StreamingPatchParser::default();
475
+ assert_eq!(
476
+ parser.push_delta(
477
+ "*** Begin Patch\n*** Environment ID: first\n*** Environment ID: second\n",
478
+ ),
479
+ Err(InvalidPatchError(
480
+ "apply_patch environment_id cannot be specified more than once".to_string(),
481
+ ))
482
+ );
483
+
484
+ let mut parser = StreamingPatchParser::default();
485
+ assert_eq!(
486
+ parser.push_delta("*** Begin Patch\n*** Environment ID: \n"),
487
+ Err(InvalidPatchError(
488
+ "apply_patch environment_id cannot be empty".to_string(),
489
+ ))
490
+ );
491
+ }
492
+
493
+ #[test]
494
+ fn test_streaming_patch_parser_large_patch_split_by_character() {
495
+ let patch = "\
496
+ *** Begin Patch
497
+ *** Add File: docs/release-notes.md
498
+ +# Release notes
499
+ +
500
+ +## CLI
501
+ +- Surface apply_patch progress while arguments stream.
502
+ +- Keep final patch application gated on the completed tool call.
503
+ +- Include file summaries in the progress event payload.
504
+ *** Update File: src/config.rs
505
+ @@ impl Config
506
+ - pub apply_patch_progress: bool,
507
+ + pub stream_apply_patch_progress: bool,
508
+ pub include_diagnostics: bool,
509
+ @@ fn default_progress_interval()
510
+ - Duration::from_millis(500)
511
+ + Duration::from_millis(250)
512
+ *** Delete File: src/legacy_patch_progress.rs
513
+ *** Update File: crates/cli/src/main.rs
514
+ *** Move to: crates/cli/src/bin/codex.rs
515
+ @@ fn run()
516
+ - let args = Args::parse();
517
+ - dispatch(args)
518
+ + let cli = Cli::parse();
519
+ + dispatch(cli)
520
+ *** Add File: tests/fixtures/apply_patch_progress.json
521
+ +{
522
+ + \"type\": \"apply_patch_progress\",
523
+ + \"hunks\": [
524
+ + { \"operation\": \"add\", \"path\": \"docs/release-notes.md\" },
525
+ + { \"operation\": \"update\", \"path\": \"src/config.rs\" }
526
+ + ]
527
+ +}
528
+ *** Update File: README.md
529
+ @@ Development workflow
530
+ Build the Rust workspace before opening a pull request.
531
+ +When touching streamed tool calls, include parser coverage for partial input.
532
+ +Prefer tests that exercise the exact event payload shape.
533
+ *** Delete File: docs/old-apply-patch-progress.md
534
+ *** End Patch";
535
+
536
+ let mut parser = StreamingPatchParser::default();
537
+ let mut max_hunk_count = 0;
538
+ let mut saw_hunk_counts = Vec::new();
539
+ let mut hunks = Vec::new();
540
+ for ch in patch.chars() {
541
+ let updated_hunks = parser.push_delta(&ch.to_string()).unwrap();
542
+ if !updated_hunks.is_empty() {
543
+ let hunk_count = updated_hunks.len();
544
+ assert!(
545
+ hunk_count >= max_hunk_count,
546
+ "hunk count should never decrease while streaming: {hunk_count} < {max_hunk_count}",
547
+ );
548
+ if hunk_count > max_hunk_count {
549
+ saw_hunk_counts.push(hunk_count);
550
+ max_hunk_count = hunk_count;
551
+ }
552
+ hunks = updated_hunks;
553
+ }
554
+ }
555
+
556
+ assert_eq!(saw_hunk_counts, vec![1, 2, 3, 4, 5, 6, 7]);
557
+ assert_eq!(hunks.len(), 7);
558
+ assert_eq!(
559
+ hunks
560
+ .iter()
561
+ .map(|hunk| match hunk {
562
+ AddFile { .. } => "add",
563
+ DeleteFile { .. } => "delete",
564
+ UpdateFile {
565
+ move_path: Some(_), ..
566
+ } => "move-update",
567
+ UpdateFile {
568
+ move_path: None, ..
569
+ } => "update",
570
+ })
571
+ .collect::<Vec<_>>(),
572
+ vec![
573
+ "add",
574
+ "update",
575
+ "delete",
576
+ "move-update",
577
+ "add",
578
+ "update",
579
+ "delete"
580
+ ]
581
+ );
582
+ }
583
+
584
+ #[test]
585
+ fn test_streaming_patch_parser_keeps_indented_update_markers_as_context_lines() {
586
+ let mut parser = StreamingPatchParser::default();
587
+ assert_eq!(
588
+ parser.push_delta(
589
+ "\
590
+ *** Begin Patch
591
+ *** Update File: a.txt
592
+ @@
593
+ -old a
594
+ +new a
595
+ *** Update File: b.txt
596
+ @@
597
+ -old b
598
+ +new b
599
+ *** End Patch
600
+ ",
601
+ ),
602
+ Ok(vec![UpdateFile {
603
+ path: PathBuf::from("a.txt"),
604
+ move_path: None,
605
+ chunks: vec![
606
+ UpdateFileChunk {
607
+ change_context: None,
608
+ old_lines: vec!["old a".to_string(), "*** Update File: b.txt".to_string()],
609
+ new_lines: vec!["new a".to_string(), "*** Update File: b.txt".to_string()],
610
+ context_line_indices: vec![(1, 1)],
611
+ is_end_of_file: false,
612
+ },
613
+ UpdateFileChunk {
614
+ change_context: None,
615
+ old_lines: vec!["old b".to_string()],
616
+ new_lines: vec!["new b".to_string()],
617
+ context_line_indices: vec![],
618
+ is_end_of_file: false,
619
+ },
620
+ ],
621
+ }])
622
+ );
623
+ }
624
+
625
+ #[test]
626
+ fn test_streaming_patch_parser_preserves_bare_empty_update_lines() {
627
+ let mut parser = StreamingPatchParser::default();
628
+ assert_eq!(
629
+ parser.push_delta(
630
+ "\
631
+ *** Begin Patch
632
+ *** Update File: file.txt
633
+ @@
634
+ context before
635
+
636
+ context after
637
+ *** End Patch
638
+ ",
639
+ ),
640
+ Ok(vec![UpdateFile {
641
+ path: PathBuf::from("file.txt"),
642
+ move_path: None,
643
+ chunks: vec![UpdateFileChunk {
644
+ change_context: None,
645
+ // The normal parser treats a bare empty line in an update hunk as an
646
+ // empty context line. Preserve that leniency in the streaming parser.
647
+ old_lines: vec![
648
+ "context before".to_string(),
649
+ String::new(),
650
+ "context after".to_string(),
651
+ ],
652
+ new_lines: vec![
653
+ "context before".to_string(),
654
+ String::new(),
655
+ "context after".to_string(),
656
+ ],
657
+ context_line_indices: vec![(0, 0), (1, 1), (2, 2)],
658
+ is_end_of_file: false,
659
+ }],
660
+ }])
661
+ );
662
+ }
663
+
664
+ #[test]
665
+ fn test_streaming_patch_parser_ignores_empty_lines_after_end_of_file() {
666
+ let mut parser = StreamingPatchParser::default();
667
+ assert_eq!(
668
+ parser.push_delta(
669
+ "*** Begin Patch\n*** Update File: file.txt\n@@\n+quux\n*** End of File\n\n*** End Patch\n",
670
+ ),
671
+ Ok(vec![UpdateFile {
672
+ path: PathBuf::from("file.txt"),
673
+ move_path: None,
674
+ chunks: vec![UpdateFileChunk {
675
+ change_context: None,
676
+ old_lines: Vec::new(),
677
+ new_lines: vec!["quux".to_string()],
678
+ context_line_indices: vec![],
679
+ is_end_of_file: true,
680
+ }],
681
+ }])
682
+ );
683
+ }
684
+
685
+ #[test]
686
+ fn test_streaming_patch_parser_matches_line_ending_behavior() {
687
+ let mut parser = StreamingPatchParser::default();
688
+ assert_eq!(
689
+ parser.push_delta("*** Begin Patch\r\n*** Update File: file.txt\r\n@@\r\n-old\r\n+new\r\n*** End Patch\r\n"),
690
+ Ok(vec![UpdateFile {
691
+ path: PathBuf::from("file.txt"),
692
+ move_path: None,
693
+ chunks: vec![UpdateFileChunk {
694
+ change_context: None,
695
+ old_lines: vec!["old".to_string()],
696
+ new_lines: vec!["new".to_string()],
697
+ context_line_indices: vec![],
698
+ is_end_of_file: false,
699
+ }],
700
+ }])
701
+ );
702
+
703
+ let mut parser = StreamingPatchParser::default();
704
+ assert_eq!(
705
+ parser.push_delta("*** Begin Patch\r\n*** Update File: file.txt\r\n@@\r\n-old\r\r\n+new\r\n*** End Patch\r\n"),
706
+ Ok(vec![UpdateFile {
707
+ path: PathBuf::from("file.txt"),
708
+ move_path: None,
709
+ chunks: vec![UpdateFileChunk {
710
+ change_context: None,
711
+ old_lines: vec!["old\r".to_string()],
712
+ new_lines: vec!["new".to_string()],
713
+ context_line_indices: vec![],
714
+ is_end_of_file: false,
715
+ }],
716
+ }])
717
+ );
718
+ }
719
+
720
+ #[test]
721
+ fn test_streaming_patch_parser_finish_processes_final_line_without_newline() {
722
+ let mut parser = StreamingPatchParser::default();
723
+ assert_eq!(
724
+ parser.push_delta("*** Begin Patch\n*** Add File: file.txt\n+hello\n*** End Patch"),
725
+ Ok(vec![AddFile {
726
+ path: PathBuf::from("file.txt"),
727
+ contents: "hello\n".to_string(),
728
+ }])
729
+ );
730
+ assert_eq!(
731
+ parser.finish(),
732
+ Ok(vec![AddFile {
733
+ path: PathBuf::from("file.txt"),
734
+ contents: "hello\n".to_string(),
735
+ }])
736
+ );
737
+
738
+ let mut parser = StreamingPatchParser::default();
739
+ assert_eq!(
740
+ parser.push_delta(
741
+ "*** Begin Patch\n*** Update File: file.txt\n@@\n-old\n+new\n *** End Patch",
742
+ ),
743
+ Ok(vec![UpdateFile {
744
+ path: PathBuf::from("file.txt"),
745
+ move_path: None,
746
+ chunks: vec![UpdateFileChunk {
747
+ change_context: None,
748
+ old_lines: vec!["old".to_string()],
749
+ new_lines: vec!["new".to_string()],
750
+ context_line_indices: vec![],
751
+ is_end_of_file: false,
752
+ }],
753
+ }])
754
+ );
755
+ assert_eq!(
756
+ parser.finish(),
757
+ Ok(vec![UpdateFile {
758
+ path: PathBuf::from("file.txt"),
759
+ move_path: None,
760
+ chunks: vec![UpdateFileChunk {
761
+ change_context: None,
762
+ old_lines: vec!["old".to_string()],
763
+ new_lines: vec!["new".to_string()],
764
+ context_line_indices: vec![],
765
+ is_end_of_file: false,
766
+ }],
767
+ }])
768
+ );
769
+ }
770
+
771
+ #[test]
772
+ fn test_streaming_patch_parser_finish_requires_end_patch() {
773
+ let mut parser = StreamingPatchParser::default();
774
+ assert_eq!(
775
+ parser.push_delta("*** Begin Patch\n*** Add File: file.txt\n+hello\n"),
776
+ Ok(vec![AddFile {
777
+ path: PathBuf::from("file.txt"),
778
+ contents: "hello\n".to_string(),
779
+ }])
780
+ );
781
+ assert_eq!(
782
+ parser.finish(),
783
+ Err(InvalidPatchError(
784
+ "The last line of the patch must be '*** End Patch'".to_string(),
785
+ ))
786
+ );
787
+ }
788
+
789
+ #[test]
790
+ fn test_streaming_patch_parser_rejects_content_after_end_patch() {
791
+ let mut parser = StreamingPatchParser::default();
792
+ assert_eq!(
793
+ parser.push_delta(
794
+ "*** Begin Patch\n*** Add File: file.txt\n+hello\n*** End Patch\nextra\n",
795
+ ),
796
+ Err(InvalidPatchError(
797
+ "The last line of the patch must be '*** End Patch'".to_string(),
798
+ ))
799
+ );
800
+
801
+ let mut parser = StreamingPatchParser::default();
802
+ assert_eq!(
803
+ parser.push_delta(
804
+ "*** Begin Patch\n*** Add File: file.txt\n+hello\n*** End Patch\n \t\n",
805
+ ),
806
+ Ok(vec![AddFile {
807
+ path: PathBuf::from("file.txt"),
808
+ contents: "hello\n".to_string(),
809
+ }])
810
+ );
811
+ }
812
+
813
+ #[test]
814
+ fn test_streaming_patch_parser_returns_errors() {
815
+ let mut parser = StreamingPatchParser::default();
816
+ assert_eq!(
817
+ parser.push_delta("bad\n"),
818
+ Err(InvalidPatchError(
819
+ "The first line of the patch must be '*** Begin Patch'".to_string(),
820
+ ))
821
+ );
822
+
823
+ let mut parser = StreamingPatchParser::default();
824
+ assert_eq!(parser.push_delta("*** Begin Patch\n"), Ok(Vec::new()));
825
+ assert_eq!(
826
+ parser.push_delta("bad\n"),
827
+ Err(InvalidHunkError {
828
+ message: "'bad' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'"
829
+ .to_string(),
830
+ line_number: 2,
831
+ })
832
+ );
833
+
834
+ let mut parser = StreamingPatchParser::default();
835
+ assert_eq!(
836
+ parser.push_delta("*** Begin Patch\n*** Add File: file.txt\nbad\n"),
837
+ Err(InvalidHunkError {
838
+ message: "'bad' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'"
839
+ .to_string(),
840
+ line_number: 3,
841
+ })
842
+ );
843
+
844
+ let mut parser = StreamingPatchParser::default();
845
+ assert_eq!(
846
+ parser.push_delta("*** Begin Patch\n*** Delete File: file.txt\nbad\n"),
847
+ Err(InvalidHunkError {
848
+ message: "'bad' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'"
849
+ .to_string(),
850
+ line_number: 3,
851
+ })
852
+ );
853
+
854
+ let mut parser = StreamingPatchParser::default();
855
+ assert_eq!(
856
+ parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n*** End Patch\n"),
857
+ Err(InvalidHunkError {
858
+ message: "Update file hunk for path 'file.txt' is empty".to_string(),
859
+ line_number: 2,
860
+ })
861
+ );
862
+
863
+ let mut parser = StreamingPatchParser::default();
864
+ assert_eq!(
865
+ parser.push_delta(
866
+ "*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n*** Delete File: other.txt\n",
867
+ ),
868
+ Err(InvalidHunkError {
869
+ message: "Update file hunk for path 'old.txt' is empty".to_string(),
870
+ line_number: 2,
871
+ })
872
+ );
873
+
874
+ let mut parser = StreamingPatchParser::default();
875
+ assert_eq!(
876
+ parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n@@\n*** End Patch\n"),
877
+ Err(InvalidHunkError {
878
+ message: "Update hunk does not contain any lines".to_string(),
879
+ line_number: 4,
880
+ })
881
+ );
882
+
883
+ let mut parser = StreamingPatchParser::default();
884
+ assert_eq!(
885
+ parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n@@\n*** End of File\n"),
886
+ Err(InvalidHunkError {
887
+ message: "Update hunk does not contain any lines".to_string(),
888
+ line_number: 4,
889
+ })
890
+ );
891
+
892
+ let mut parser = StreamingPatchParser::default();
893
+ assert_eq!(
894
+ parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n@@\n@@\n"),
895
+ Err(InvalidHunkError {
896
+ message: "Unexpected line found in update hunk: '@@'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)"
897
+ .to_string(),
898
+ line_number: 4,
899
+ })
900
+ );
901
+
902
+ let mut parser = StreamingPatchParser::default();
903
+ assert_eq!(
904
+ parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n@@\n-old\nbad\n"),
905
+ Err(InvalidHunkError {
906
+ message: "Expected update hunk to start with a @@ context marker, got: 'bad'"
907
+ .to_string(),
908
+ line_number: 5,
909
+ })
910
+ );
911
+
912
+ let mut parser = StreamingPatchParser::default();
913
+ assert_eq!(
914
+ parser.push_delta(
915
+ "*** Begin Patch\n*** Update File: file.txt\n@@\n*** Update File: other.txt\n",
916
+ ),
917
+ Err(InvalidHunkError {
918
+ message: "Unexpected line found in update hunk: '*** Update File: other.txt'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)"
919
+ .to_string(),
920
+ line_number: 4,
921
+ })
922
+ );
923
+ }
924
+ }
codex-rs/apply-patch/src/text_file.rs ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ pub(super) type Replacement = (usize, usize, Vec<String>);
2
+
3
+ #[derive(Clone, Copy)]
4
+ enum LineEnding {
5
+ Lf,
6
+ CrLf,
7
+ Cr,
8
+ }
9
+
10
+ impl LineEnding {
11
+ fn as_str(self) -> &'static str {
12
+ match self {
13
+ Self::Lf => "\n",
14
+ Self::CrLf => "\r\n",
15
+ Self::Cr => "\r",
16
+ }
17
+ }
18
+ }
19
+
20
+ struct SourceLine {
21
+ text: String,
22
+ ending: Option<LineEnding>,
23
+ }
24
+
25
+ pub(super) struct SourceFile {
26
+ lines: Vec<SourceLine>,
27
+ preferred_ending: LineEnding,
28
+ }
29
+
30
+ impl SourceFile {
31
+ /// Splits contents into logical lines while retaining each line ending.
32
+ ///
33
+ /// The first existing ending becomes the preferred style for inserted
34
+ /// lines; files without an ending default to LF.
35
+ pub(super) fn parse(contents: &str) -> Self {
36
+ let mut lines = Vec::new();
37
+ let mut preferred_ending = None;
38
+ let mut line_start = 0;
39
+ let mut cursor = 0;
40
+
41
+ while cursor < contents.len() {
42
+ let (ending, ending_len) = match contents.as_bytes()[cursor] {
43
+ b'\r' if contents.as_bytes().get(cursor + 1) == Some(&b'\n') => {
44
+ (LineEnding::CrLf, 2)
45
+ }
46
+ b'\r' => (LineEnding::Cr, 1),
47
+ b'\n' => (LineEnding::Lf, 1),
48
+ _ => {
49
+ cursor += 1;
50
+ continue;
51
+ }
52
+ };
53
+ preferred_ending.get_or_insert(ending);
54
+ lines.push(SourceLine {
55
+ text: contents[line_start..cursor].to_string(),
56
+ ending: Some(ending),
57
+ });
58
+ cursor += ending_len;
59
+ line_start = cursor;
60
+ }
61
+
62
+ if line_start < contents.len() {
63
+ lines.push(SourceLine {
64
+ text: contents[line_start..].to_string(),
65
+ ending: None,
66
+ });
67
+ }
68
+
69
+ Self {
70
+ lines,
71
+ preferred_ending: preferred_ending.unwrap_or(LineEnding::Lf),
72
+ }
73
+ }
74
+
75
+ pub(super) fn line_texts(&self) -> Vec<String> {
76
+ self.lines.iter().map(|line| line.text.clone()).collect()
77
+ }
78
+
79
+ /// Rebuilds the file from source-ordered, non-overlapping replacements.
80
+ ///
81
+ /// Unchanged lines retain their original endings, inserted lines use the
82
+ /// preferred ending, and every resulting line receives an ending to match
83
+ /// apply-patch's historical trailing-newline behavior.
84
+ pub(super) fn apply_replacements(&mut self, replacements: &[Replacement]) {
85
+ let mut source_lines = std::mem::take(&mut self.lines).into_iter();
86
+ let mut new_lines = Vec::new();
87
+ let mut source_index = 0;
88
+
89
+ for (start_idx, old_len, new_segment) in replacements {
90
+ debug_assert!(*start_idx >= source_index);
91
+ for line in source_lines.by_ref().take(*start_idx - source_index) {
92
+ new_lines.push(line);
93
+ }
94
+ for _ in source_lines.by_ref().take(*old_len) {}
95
+ new_lines.extend(new_segment.iter().map(|text| SourceLine {
96
+ text: text.clone(),
97
+ ending: Some(self.preferred_ending),
98
+ }));
99
+ source_index = start_idx + old_len;
100
+ }
101
+ new_lines.extend(source_lines);
102
+ self.lines = new_lines;
103
+
104
+ // Updates have historically added a trailing newline. This also gives
105
+ // an unterminated last line an ending if an insertion moved it inward.
106
+ for line in &mut self.lines {
107
+ line.ending.get_or_insert(self.preferred_ending);
108
+ }
109
+ }
110
+
111
+ pub(super) fn into_contents(self) -> String {
112
+ let mut contents = String::new();
113
+ for line in self.lines {
114
+ contents.push_str(&line.text);
115
+ if let Some(ending) = line.ending {
116
+ contents.push_str(ending.as_str());
117
+ }
118
+ }
119
+ contents
120
+ }
121
+ }
codex-rs/apply-patch/tests/all.rs ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ // Single integration test binary that aggregates all test modules.
2
+ // The submodules live in `tests/suite/`.
3
+ mod suite;
codex-rs/apply-patch/tests/fixtures/scenarios/.gitattributes ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ ** text eol=lf
2
+ 023_preserves_crlf_line_endings/input/*.txt -diff -text
3
+ 023_preserves_crlf_line_endings/expected/*.txt -diff -text
4
+ 024_preserves_mixed_line_endings/input/*.txt -diff -text
5
+ 024_preserves_mixed_line_endings/expected/*.txt -diff -text
codex-rs/apply-patch/tests/fixtures/scenarios/001_add_file/expected/bar.md ADDED
@@ -0,0 +1 @@
 
 
1
+ This is a new file
codex-rs/apply-patch/tests/fixtures/scenarios/001_add_file/patch.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ *** Begin Patch
2
+ *** Add File: bar.md
3
+ +This is a new file
4
+ *** End Patch
codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/expected/modify.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ line1
2
+ changed
codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/expected/nested/new.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ created
codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/input/delete.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ obsolete
codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/input/modify.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ line1
2
+ line2
codex-rs/apply-patch/tests/fixtures/scenarios/002_multiple_operations/patch.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ *** Begin Patch
2
+ *** Add File: nested/new.txt
3
+ +created
4
+ *** Delete File: delete.txt
5
+ *** Update File: modify.txt
6
+ @@
7
+ -line2
8
+ +changed
9
+ *** End Patch
codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/expected/multi.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ line1
2
+ changed2
3
+ line3
4
+ changed4
codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/input/multi.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ line1
2
+ line2
3
+ line3
4
+ line4
codex-rs/apply-patch/tests/fixtures/scenarios/003_multiple_chunks/patch.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ *** Begin Patch
2
+ *** Update File: multi.txt
3
+ @@
4
+ -line2
5
+ +changed2
6
+ @@
7
+ -line4
8
+ +changed4
9
+ *** End Patch
codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/expected/old/other.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ unrelated file
codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/expected/renamed/dir/name.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ new content
codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/input/old/name.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ old content
codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/input/old/other.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ unrelated file
codex-rs/apply-patch/tests/fixtures/scenarios/004_move_to_new_directory/patch.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ *** Begin Patch
2
+ *** Update File: old/name.txt
3
+ *** Move to: renamed/dir/name.txt
4
+ @@
5
+ -old content
6
+ +new content
7
+ *** End Patch
codex-rs/apply-patch/tests/fixtures/scenarios/005_rejects_empty_patch/expected/foo.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ stable
codex-rs/apply-patch/tests/fixtures/scenarios/005_rejects_empty_patch/input/foo.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ stable
codex-rs/apply-patch/tests/fixtures/scenarios/005_rejects_empty_patch/patch.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ *** Begin Patch
2
+ *** End Patch
codex-rs/apply-patch/tests/fixtures/scenarios/006_rejects_missing_context/expected/modify.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ line1
2
+ line2
codex-rs/apply-patch/tests/fixtures/scenarios/006_rejects_missing_context/input/modify.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ line1
2
+ line2
codex-rs/apply-patch/tests/fixtures/scenarios/006_rejects_missing_context/patch.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ *** Begin Patch
2
+ *** Update File: modify.txt
3
+ @@
4
+ -missing
5
+ +changed
6
+ *** End Patch
codex-rs/apply-patch/tests/fixtures/scenarios/007_rejects_missing_file_delete/expected/foo.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ stable
codex-rs/apply-patch/tests/fixtures/scenarios/007_rejects_missing_file_delete/input/foo.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ stable
codex-rs/apply-patch/tests/fixtures/scenarios/007_rejects_missing_file_delete/patch.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ *** Begin Patch
2
+ *** Delete File: missing.txt
3
+ *** End Patch
codex-rs/apply-patch/tests/fixtures/scenarios/008_rejects_empty_update_hunk/expected/foo.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ stable
codex-rs/apply-patch/tests/fixtures/scenarios/008_rejects_empty_update_hunk/input/foo.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ stable
codex-rs/apply-patch/tests/fixtures/scenarios/008_rejects_empty_update_hunk/patch.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ *** Begin Patch
2
+ *** Update File: foo.txt
3
+ *** End Patch
codex-rs/apply-patch/tests/fixtures/scenarios/009_requires_existing_file_for_update/expected/foo.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ stable
codex-rs/apply-patch/tests/fixtures/scenarios/009_requires_existing_file_for_update/input/foo.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ stable
codex-rs/apply-patch/tests/fixtures/scenarios/009_requires_existing_file_for_update/patch.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ *** Begin Patch
2
+ *** Update File: missing.txt
3
+ @@
4
+ -old
5
+ +new
6
+ *** End Patch
codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/expected/old/other.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ unrelated file
codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/expected/renamed/dir/name.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ new
codex-rs/apply-patch/tests/fixtures/scenarios/010_move_overwrites_existing_destination/input/old/name.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ from