1use std::collections::BTreeMap;
22
23use serde::{Deserialize, Serialize};
24
25use crate::error::Result;
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub enum Emission {
29 WholeFiles(BTreeMap<String, String>),
31 UnifiedDiff(String),
33 Prose(String),
35}
36
37impl Emission {
38 pub fn shape_label(&self) -> &'static str {
42 match self {
43 Self::WholeFiles(_) => plugins_protocol::emission_shape::WHOLE_FILES,
44 Self::UnifiedDiff(_) => plugins_protocol::emission_shape::UNIFIED_DIFF,
45 Self::Prose(_) => plugins_protocol::emission_shape::PROSE,
46 }
47 }
48}
49
50pub fn normalize_emission(raw: &str) -> Result<Emission> {
54 let stripped = strip_outer_fences(raw);
55
56 if let Some(files) = try_parse_whole_files(&stripped) {
57 if !files.is_empty() {
58 return Ok(Emission::WholeFiles(files));
59 }
60 }
61
62 if let Some(diff) = try_parse_unified_diff(&stripped) {
63 return Ok(Emission::UnifiedDiff(diff));
64 }
65
66 Ok(Emission::Prose(stripped))
67}
68
69fn strip_outer_fences(raw: &str) -> String {
72 let trimmed = raw.trim();
73 if let Some(rest) = trimmed.strip_prefix("```") {
74 let after_tag = match rest.find('\n') {
76 Some(nl) => &rest[nl + 1..],
77 None => rest,
78 };
79 let body = after_tag
80 .strip_suffix("```")
81 .or_else(|| after_tag.strip_suffix("```\n"))
82 .unwrap_or(after_tag);
83 return body.trim_end_matches('\n').to_string();
84 }
85 trimmed.to_string()
86}
87
88fn try_parse_whole_files(body: &str) -> Option<BTreeMap<String, String>> {
104 let mut files = BTreeMap::new();
105 let mut cur_path: Option<String> = None;
106 let mut cur_buf = String::new();
107 let mut saw_header = false;
108 let mut block_body_empty = true;
111
112 for line in body.lines() {
113 if let Some(rest) = line.strip_prefix("FILE: ") {
114 saw_header = true;
115 if cur_path.is_some() && block_body_empty {
120 cur_buf.clear();
121 cur_path = Some(rest.trim().to_string());
122 block_body_empty = true;
123 continue;
124 }
125 if let Some(path) = cur_path.take() {
126 files.insert(path, cur_buf.trim_end_matches('\n').to_string());
127 cur_buf.clear();
128 }
129 cur_path = Some(rest.trim().to_string());
130 block_body_empty = true;
131 continue;
132 }
133 if line.trim() == "END-FILE" {
134 if let Some(path) = cur_path.take() {
135 files.insert(path, cur_buf.trim_end_matches('\n').to_string());
136 cur_buf.clear();
137 }
138 block_body_empty = true;
139 continue;
140 }
141 if cur_path.is_some() {
142 if !(block_body_empty && line.trim().is_empty()) {
145 block_body_empty = false;
146 }
147 cur_buf.push_str(line);
148 cur_buf.push('\n');
149 }
150 }
151
152 if let Some(path) = cur_path {
153 files.insert(path, cur_buf.trim_end_matches('\n').to_string());
154 }
155
156 if !saw_header {
157 return None;
158 }
159 Some(files)
160}
161
162fn try_parse_unified_diff(body: &str) -> Option<String> {
165 let has_minus = body.starts_with("--- ") || body.contains("\n--- ");
166 let has_plus = body.contains("\n+++ ");
167 let has_hunk = body.contains("\n@@ ") || body.contains("@@ -");
168 if has_minus && has_plus && has_hunk {
169 Some(body.to_string())
170 } else {
171 None
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn parses_single_whole_file_block() {
181 let raw = "FILE: src/lib.rs\npub fn hello() {}\nEND-FILE\n";
182 let em = normalize_emission(raw).unwrap();
183 match em {
184 Emission::WholeFiles(files) => {
185 assert_eq!(files.len(), 1);
186 assert_eq!(files.get("src/lib.rs").unwrap(), "pub fn hello() {}");
187 }
188 other => panic!("expected WholeFiles, got {other:?}"),
189 }
190 }
191
192 #[test]
193 fn parses_multi_file_whole_file_block() {
194 let raw = "\
195FILE: a.rs
196pub fn a() {}
197END-FILE
198
199FILE: b.rs
200pub fn b() {}
201END-FILE
202";
203 let em = normalize_emission(raw).unwrap();
204 match em {
205 Emission::WholeFiles(files) => {
206 assert_eq!(files.len(), 2);
207 assert_eq!(files.get("a.rs").unwrap(), "pub fn a() {}");
208 assert_eq!(files.get("b.rs").unwrap(), "pub fn b() {}");
209 }
210 other => panic!("expected WholeFiles, got {other:?}"),
211 }
212 }
213
214 #[test]
215 fn handles_outer_code_fence_around_whole_files() {
216 let raw = "```\nFILE: a.rs\npub fn x() {}\nEND-FILE\n```";
217 let em = normalize_emission(raw).unwrap();
218 if let Emission::WholeFiles(files) = em {
219 assert_eq!(files.get("a.rs").unwrap(), "pub fn x() {}");
220 } else {
221 panic!("expected whole files");
222 }
223 }
224
225 #[test]
226 fn handles_outer_code_fence_with_language_tag() {
227 let raw = "```rust\nFILE: a.rs\npub fn x() {}\nEND-FILE\n```";
228 let em = normalize_emission(raw).unwrap();
229 if let Emission::WholeFiles(files) = em {
230 assert_eq!(files.get("a.rs").unwrap(), "pub fn x() {}");
231 } else {
232 panic!("expected whole files");
233 }
234 }
235
236 #[test]
237 fn tolerates_missing_trailing_end_file() {
238 let raw = "FILE: src/lib.rs\npub fn hello() {}\n";
240 let em = normalize_emission(raw).unwrap();
241 if let Emission::WholeFiles(files) = em {
242 assert_eq!(files.get("src/lib.rs").unwrap(), "pub fn hello() {}");
243 } else {
244 panic!("expected whole files");
245 }
246 }
247
248 #[test]
249 fn parses_unified_diff_when_no_whole_files() {
250 let raw = "\
251--- a/foo.rs
252+++ b/foo.rs
253@@ -1 +1 @@
254-old
255+new
256";
257 let em = normalize_emission(raw).unwrap();
258 assert!(matches!(em, Emission::UnifiedDiff(_)));
259 }
260
261 #[test]
262 fn falls_back_to_prose_on_plain_text() {
263 let raw = "I've updated the file successfully.";
264 let em = normalize_emission(raw).unwrap();
265 assert!(matches!(em, Emission::Prose(_)));
266 }
267
268 #[test]
269 fn shape_labels_match_wire_constants() {
270 let whole = Emission::WholeFiles(BTreeMap::new());
271 assert_eq!(whole.shape_label(), "whole_files");
272 let diff = Emission::UnifiedDiff(String::new());
273 assert_eq!(diff.shape_label(), "unified_diff");
274 let prose = Emission::Prose(String::new());
275 assert_eq!(prose.shape_label(), "prose");
276 }
277
278 #[test]
279 fn empty_input_is_prose() {
280 let em = normalize_emission("").unwrap();
281 match em {
282 Emission::Prose(s) => assert!(s.is_empty()),
283 other => panic!("expected empty prose, got {other:?}"),
284 }
285 }
286
287 #[test]
288 fn whole_files_preferred_over_diff_when_both_present() {
289 let raw = "\
292FILE: src/lib.rs
293pub fn hello() {}
294END-FILE
295--- a/foo
296+++ b/foo
297@@ -1 +1 @@
298-x
299+y
300";
301 let em = normalize_emission(raw).unwrap();
302 assert!(matches!(em, Emission::WholeFiles(_)));
303 }
304
305 #[test]
306 fn strips_leaked_file_marker_restated_in_body() {
307 let raw = "FILE: src/lib.rs\nFILE: src/lib.rs\npub fn add(a: i32, b: i32) -> i32 { a + b }\nEND-FILE\n";
311 let em = normalize_emission(raw).unwrap();
312 match em {
313 Emission::WholeFiles(files) => {
314 assert_eq!(files.len(), 1);
315 assert_eq!(
316 files.get("src/lib.rs").unwrap(),
317 "pub fn add(a: i32, b: i32) -> i32 { a + b }"
318 );
319 }
320 other => panic!("expected WholeFiles, got {other:?}"),
321 }
322 }
323
324 #[test]
325 fn strips_leaked_marker_inside_peeled_fence() {
326 let raw = "```rust\nFILE: src/lib.rs\nFILE: src/lib.rs\npub fn a() {}\n```";
329 let em = normalize_emission(raw).unwrap();
330 if let Emission::WholeFiles(files) = em {
331 assert_eq!(files.get("src/lib.rs").unwrap(), "pub fn a() {}");
332 } else {
333 panic!("expected whole files");
334 }
335 }
336
337 #[test]
338 fn strips_leaked_marker_after_leading_blank() {
339 let raw = "FILE: src/lib.rs\n\nFILE: src/lib.rs\npub fn a() {}\nEND-FILE\n";
340 let em = normalize_emission(raw).unwrap();
341 if let Emission::WholeFiles(files) = em {
342 assert_eq!(files.get("src/lib.rs").unwrap(), "pub fn a() {}");
343 } else {
344 panic!("expected whole files");
345 }
346 }
347
348 #[test]
349 fn does_not_strip_second_file_block_as_leaked_marker() {
350 let raw = "\
354FILE: a.rs
355pub fn a() {}
356FILE: b.rs
357pub fn b() {}
358";
359 let em = normalize_emission(raw).unwrap();
360 match em {
361 Emission::WholeFiles(files) => {
362 assert_eq!(files.len(), 2);
363 assert_eq!(files.get("a.rs").unwrap(), "pub fn a() {}");
364 assert_eq!(files.get("b.rs").unwrap(), "pub fn b() {}");
365 }
366 other => panic!("expected two WholeFiles, got {other:?}"),
367 }
368 }
369
370 #[test]
371 fn parsed_leaked_marker_body_is_applyable() {
372 let raw = "FILE: src/lib.rs\nFILE: src/lib.rs\npub fn add() {}\nEND-FILE\n";
376 let em = normalize_emission(raw).unwrap();
377 if let Emission::WholeFiles(files) = em {
378 let contents = files.get("src/lib.rs").unwrap();
379 let first = contents.lines().find(|l| !l.trim().is_empty()).unwrap();
380 assert!(
381 !first.trim_start().starts_with("FILE:"),
382 "marker leaked: {first}"
383 );
384 } else {
385 panic!("expected whole files");
386 }
387 }
388}