Skip to main content

newt_coder/
emission.rs

1//! Parse the model's raw reply into a structured `Emission`.
2//!
3//! Three shapes (matching the failure-mode taxonomy in
4//! `~/workspaces/knowledge/board/drake/2026-05-29_newt-coder-failure-mode-taxonomy.md`):
5//!
6//! - [`Emission::WholeFiles`] — one or more `FILE: <path>\n…\nEND-FILE`
7//!   blocks. The S5 directive's preferred shape.
8//! - [`Emission::UnifiedDiff`] — a unified diff (fenced or not). Legacy
9//!   path; useful when a model ignores the directive but still lands a
10//!   valid hunk.
11//! - [`Emission::Prose`] — nothing structured was found. The model
12//!   emitted text only. Failure mode T0a in the taxonomy.
13//!
14//! The parser is **permissive**. Real Ollama replies often arrive
15//! wrapped in stray ` ``` ` fences or with a leading "Here's the
16//! updated file:" preamble — `strip_outer_fences` peels a single
17//! enclosing fence and `try_parse_whole_files` is tolerant of
18//! interleaved blank lines. We'd rather extract a valid emission
19//! from a slightly-malformed reply than crash on it.
20
21use 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    /// Map of relative-path -> full file contents.
30    WholeFiles(BTreeMap<String, String>),
31    /// Raw unified-diff text.
32    UnifiedDiff(String),
33    /// Plain prose; no structured emission detected.
34    Prose(String),
35}
36
37impl Emission {
38    /// Wire-stable label used in `TaskReply.emission_shape`. Sourced
39    /// from `plugins_protocol::emission_shape` so producers and
40    /// consumers can't drift.
41    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
50/// Parse `raw` into an [`Emission`]. Permissive: peels a single
51/// enclosing ` ``` ` fence before deciding, then tries each shape in
52/// preference order (whole-files > unified-diff > prose).
53pub 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
69/// If the entire reply is wrapped in a single ` ``` ` block (with or
70/// without a language tag), peel it. Otherwise return `raw` trimmed.
71fn strip_outer_fences(raw: &str) -> String {
72    let trimmed = raw.trim();
73    if let Some(rest) = trimmed.strip_prefix("```") {
74        // Skip an optional language tag on the same line.
75        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
88/// Try to parse one or more `FILE: <path>\n…\nEND-FILE` blocks.
89/// Returns `None` if no `FILE:` header is found at all; otherwise
90/// returns whatever it could extract (last block is allowed to omit
91/// `END-FILE` — some models miss the trailing marker).
92///
93/// Defends against the model *restating* the `FILE:` marker as the
94/// first line of a block's body (e.g. the directive's `FILE: <path>`
95/// header followed immediately by `FILE: <path>` again, then the real
96/// contents — frequently after `strip_outer_fences` peels a code
97/// fence). Such a leaked marker, if treated as content, would write a
98/// file whose first line is `FILE: …`, poisoning the apply step. We
99/// skip at most one leaked marker per block: a `FILE:` line that
100/// arrives while the current block's body is still empty (modulo a
101/// single blank line) re-targets the same/new path instead of being
102/// appended as content.
103fn 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    // True until the current block has accumulated real content. While
109    // true, a `FILE:` line is a leaked-marker restatement, not content.
110    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 we are still at the very start of the current block
116            // (no real content yet), this `FILE:` line is a leaked
117            // restatement of the marker. Re-target the path and drop
118            // any held-back blank instead of flushing an empty file.
119            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            // A single leading blank line does not count as real
143            // content yet — it may precede a leaked marker.
144            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
162/// Detect a unified diff by header pattern. A real diff has the
163/// `--- ` / `+++ ` header pair and at least one `@@ ` hunk header.
164fn 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        // Some models drop the trailing END-FILE marker.
239        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        // A model that emits a FILE: block AND a stray diff fragment
290        // should be classified as whole-files (the directive wins).
291        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        // Failures 3 & 4: the model restates the `FILE:` marker as the
308        // first body line (commonly inside a fence that gets peeled).
309        // The marker must NOT leak into the file contents.
310        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        // Whole reply wrapped in a fence; after peeling, the body opens
327        // with a leaked `FILE:` restatement.
328        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        // Two genuinely distinct files, each with real content, must
351        // both survive — the leaked-marker skip only fires while a
352        // block's body is still empty.
353        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        // End-to-end: a leaked-marker reply parses to clean contents
373        // whose first line is real code, so the writer's shape guards
374        // accept it.
375        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}