• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

dcdpr / jp / 18356983203

08 Oct 2025 08:19PM UTC coverage: 46.105% (+0.5%) from 45.652%
18356983203

push

github

web-flow
test: Improve testability of `ResponseHandler` and `find_merge_point` (#275)

Refactored the response handling system to use `StyleConfig` instead of
full context objects, providing better separation of concerns and
cleaner interfaces. The `ResponseHandler` now accepts style
configuration directly, eliminating the need to pass the entire context
through the call chain.

Changed the `--model` flag short option from `-o` to `-m` for better
consistency and usability.

Added unit tests for the `StreamEventHandler::handle_chat_chunk` method,
covering various scenarios. Also added extensive test coverage for the
`find_merge_point` function in the Anthropic provider, testing edge
cases like unicode handling, overlap detection, and boundary conditions.

---------

Signed-off-by: Jean Mertz <git@jeanmertz.com>

95 of 142 new or added lines in 3 files covered. (66.9%)

2 existing lines in 1 file now uncovered.

6717 of 14569 relevant lines covered (46.1%)

15.64 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/crates/jp_cli/src/cmd/query/response_handler.rs
1
use std::{fs, path::PathBuf, time::Duration};
2

3
use crossterm::style::{Color, Stylize as _};
4
use jp_config::style::{LinkStyle, StyleConfig};
5
use jp_term::{code, osc::hyperlink, stdout};
6
use termimad::FmtText;
7

8
use super::{Line, LineVariant, RenderMode};
9
use crate::Error;
10

11
#[derive(Debug, Default)]
12
pub(super) struct ResponseHandler {
13
    /// How to render the response.
14
    pub render_mode: RenderMode,
15

16
    /// Whether to render tool call request/results.
17
    pub render_tool_calls: bool,
18

19
    /// The streamed, unprocessed lines received from the LLM.
20
    received: Vec<String>,
21

22
    /// The lines that have been parsed so far.
23
    ///
24
    /// If `should_stream` is `true`, these lines have been printed to the
25
    /// terminal. Otherwise they will be printed when the response handler is
26
    /// finished.
27
    pub parsed: Vec<String>,
28

29
    /// A temporary buffer of data received from the LLM.
30
    pub buffer: String,
31

32
    in_fenced_code_block: bool,
33
    // (language, code)
34
    code_buffer: (Option<String>, Vec<String>),
35
    code_line: usize,
36

37
    // The last index of the line that ends a code block.
38
    // (streamed, printed)
39
    last_fenced_code_block_end: (usize, usize),
40
}
41

42
impl ResponseHandler {
43
    pub fn new(render_mode: RenderMode, render_tool_calls: bool) -> Self {
×
44
        Self {
×
45
            render_mode,
×
46
            render_tool_calls,
×
47
            ..Default::default()
×
48
        }
×
49
    }
×
50

NEW
51
    pub fn drain(&mut self, style: &StyleConfig, raw: bool) -> Result<(), Error> {
×
NEW
52
        if self.buffer.is_empty() {
×
NEW
53
            return Ok(());
×
NEW
54
        }
×
55

NEW
56
        let line = Line::new(
×
NEW
57
            self.buffer.drain(..).collect(),
×
NEW
58
            self.in_fenced_code_block,
×
NEW
59
            raw,
×
60
        );
61

NEW
62
        self.handle_inner(line, style)
×
NEW
63
    }
×
64

NEW
65
    pub fn handle(&mut self, data: &str, style: &StyleConfig, raw: bool) -> Result<(), Error> {
×
UNCOV
66
        self.buffer.push_str(data);
×
67

NEW
68
        while let Some(line) = self.get_line(raw) {
×
NEW
69
            self.handle_inner(line, style)?;
×
70
        }
71

NEW
72
        Ok(())
×
NEW
73
    }
×
74

NEW
75
    fn handle_inner(&mut self, line: Line, style: &StyleConfig) -> Result<(), Error> {
×
NEW
76
        let Line { content, variant } = line;
×
NEW
77
        self.received.push(content);
×
78

NEW
79
        let delay = match variant {
×
NEW
80
            LineVariant::Code => style.typewriter.code_delay.into(),
×
NEW
81
            LineVariant::Raw => Duration::ZERO,
×
NEW
82
            _ => style.typewriter.text_delay.into(),
×
83
        };
84

NEW
85
        let lines = self.handle_line(&variant, style)?;
×
NEW
86
        if !matches!(self.render_mode, RenderMode::Buffered) {
×
NEW
87
            stdout::typewriter(&lines.join("\n"), delay)?;
×
UNCOV
88
        }
×
89

NEW
90
        self.parsed.extend(lines);
×
91

92
        Ok(())
×
93
    }
×
94

95
    #[expect(clippy::too_many_lines)]
NEW
96
    fn handle_line(
×
NEW
97
        &mut self,
×
NEW
98
        variant: &LineVariant,
×
NEW
99
        style: &StyleConfig,
×
NEW
100
    ) -> Result<Vec<String>, Error> {
×
101
        let Some(content) = self.received.last().map(String::as_str) else {
×
102
            return Ok(vec![]);
×
103
        };
104

105
        match variant {
×
106
            LineVariant::Raw => Ok(content.lines().map(str::to_owned).collect()),
×
107
            LineVariant::Code => {
108
                self.code_line += 1;
×
109
                self.code_buffer.1.push(content.to_owned());
×
110

111
                let mut buf = String::new();
×
112
                let config = code::Config {
×
113
                    language: self.code_buffer.0.clone(),
×
NEW
114
                    theme: style.code.color.then(|| style.code.theme.clone()),
×
115
                };
116

117
                if !code::format(content, &mut buf, &config)? {
×
118
                    let config = code::Config {
×
119
                        language: None,
×
120
                        theme: config.theme,
×
121
                    };
×
122

123
                    code::format(content, &mut buf, &config)?;
×
124
                }
×
125

NEW
126
                if style.code.line_numbers {
×
127
                    buf.insert_str(
×
128
                        0,
×
129
                        &format!("{:2} │ ", self.code_line)
×
130
                            .with(Color::AnsiValue(238))
×
131
                            .to_string(),
×
132
                    );
×
133
                }
×
134

135
                Ok(vec![buf])
×
136
            }
137
            LineVariant::FencedCodeBlockStart { language } => {
×
138
                self.code_buffer.0.clone_from(language);
×
139
                self.code_buffer.1.clear();
×
140
                self.code_line = 0;
×
141
                self.in_fenced_code_block = true;
×
142

143
                Ok(vec![content.with(Color::AnsiValue(238)).to_string()])
×
144
            }
145
            LineVariant::FencedCodeBlockEnd { indent } => {
×
146
                self.last_fenced_code_block_end = (self.received.len(), self.parsed.len() + 2);
×
147

148
                let path = self.persist_code_block()?;
×
149
                let mut links = vec![];
×
150

NEW
151
                match style.code.file_link {
×
152
                    LinkStyle::Off => {}
×
153
                    LinkStyle::Full => {
×
154
                        links.push(format!("{}see: {}", " ".repeat(*indent), path.display()));
×
155
                    }
×
156
                    LinkStyle::Osc8 => {
×
157
                        links.push(format!(
×
158
                            "{}[{}]",
×
159
                            " ".repeat(*indent),
×
160
                            hyperlink(
×
161
                                format!("file://{}", path.display()),
×
162
                                "open in editor".red().to_string()
×
163
                            )
×
164
                        ));
×
165
                    }
×
166
                }
167

NEW
168
                match style.code.copy_link {
×
169
                    LinkStyle::Off => {}
×
170
                    LinkStyle::Full => {
×
171
                        links.push(format!(
×
172
                            "{}copy: copy://{}",
×
173
                            " ".repeat(*indent),
×
174
                            path.display()
×
175
                        ));
×
176
                    }
×
177
                    LinkStyle::Osc8 => {
×
178
                        links.push(format!(
×
179
                            "{}[{}]",
×
180
                            " ".repeat(*indent),
×
181
                            hyperlink(
×
182
                                format!("copy://{}", path.display()),
×
183
                                "copy to clipboard".red().to_string()
×
184
                            )
×
185
                        ));
×
186
                    }
×
187
                }
188

189
                self.in_fenced_code_block = false;
×
190

191
                let mut lines = vec![content.with(Color::AnsiValue(238)).to_string()];
×
192
                if !links.is_empty() {
×
193
                    lines.push(links.join(" "));
×
194
                }
×
195

196
                Ok(lines)
×
197
            }
198
            LineVariant::Normal => {
199
                // We feed all the lines for markdown formatting, but only
200
                // print the last one, as the others are already printed.
201
                //
202
                // This helps the parser to use previous context to apply
203
                // the correct formatting to the current line.
204
                //
205
                // We only care about the lines after the last code block
206
                // end, because a) formatting context is reset after a code
207
                // block, and b) we dot not limit the line length of code, makes
208
                // it impossible to correctly find the non-printed lines based
209
                // on wrapped vs non-wrapped lines.
210
                let lines = self
×
211
                    .received
×
212
                    .iter()
×
213
                    .skip(self.last_fenced_code_block_end.0)
×
214
                    .cloned()
×
215
                    .collect::<Vec<_>>();
×
216

217
                // `termimad` removes empty lines at the start or end, but we
218
                // want to keep them as we will have more lines to print.
219
                let empty_lines_start_count = lines.iter().take_while(|s| s.is_empty()).count();
×
220
                let empty_lines_end_count = lines.iter().rev().take_while(|s| s.is_empty()).count();
×
221

222
                let options = comrak::Options {
×
223
                    render: comrak::RenderOptions {
×
224
                        unsafe_: true,
×
225
                        prefer_fenced: true,
×
226
                        experimental_minimize_commonmark: true,
×
227
                        ..Default::default()
×
228
                    },
×
229
                    ..Default::default()
×
230
                };
×
231

232
                let formatted = comrak::markdown_to_commonmark(&lines.join("\n"), &options);
×
233

234
                let mut formatted =
×
235
                    FmtText::from(&termimad::MadSkin::default(), &formatted, Some(100)).to_string();
×
236

237
                for _ in 0..empty_lines_start_count {
×
238
                    formatted.insert(0, '\n');
×
239
                }
×
240

241
                // Only add an extra newline if we have more than one line,
242
                // otherwise a single empty line will be interpreted as both a
243
                // missing start and end newline.
244
                if lines.iter().any(|s| !s.is_empty()) {
×
245
                    for _ in 0..empty_lines_end_count {
×
246
                        formatted.push('\n');
×
247
                    }
×
248
                }
×
249

250
                let lines = formatted
×
251
                    .lines()
×
252
                    .skip(self.parsed.len() - self.last_fenced_code_block_end.1)
×
253
                    .map(ToOwned::to_owned)
×
254
                    .collect::<Vec<_>>();
×
255

256
                Ok(lines)
×
257
            }
258
        }
259
    }
×
260

261
    fn get_line(&mut self, raw: bool) -> Option<Line> {
×
262
        let s = &mut self.buffer;
×
263
        let idx = s.find('\n')?;
×
264

265
        // Determine the end index of the actual line *content*.
266
        // Check if the character before '\n' is '\r'.
267
        let end_idx = if idx > 0 && s.as_bytes().get(idx - 1) == Some(&b'\r') {
×
268
            idx - 1
×
269
        } else {
270
            idx
×
271
        };
272

273
        // Extract the line content *before* draining.
274
        // Creating a slice and then converting to owned String.
275
        let extracted_line = s[..end_idx].to_string();
×
276

277
        // Calculate the index *after* the newline sequence to drain up to.
278
        // This ensures we remove the '\n' and potentially the preceding '\r'.
279
        let drain_end_idx = idx + 1;
×
280
        s.drain(..drain_end_idx);
×
281

282
        Some(Line::new(extracted_line, self.in_fenced_code_block, raw))
×
283
    }
×
284

285
    fn persist_code_block(&self) -> Result<PathBuf, Error> {
×
286
        let code = self.code_buffer.1.clone();
×
287
        let language = self.code_buffer.0.as_deref().unwrap_or("txt");
×
288
        let ext = match language {
×
289
            "c++" => "cpp",
×
290
            "javascript" => "js",
×
291
            "python" => "py",
×
292
            "ruby" => "rb",
×
293
            "rust" => "rs",
×
294
            "typescript" => "ts",
×
295
            lang => lang,
×
296
        };
297

298
        let millis = std::time::SystemTime::now()
×
299
            .duration_since(std::time::UNIX_EPOCH)
×
300
            .unwrap_or_default()
×
301
            .subsec_millis();
×
302
        let path = std::env::temp_dir().join(format!("code_{millis}.{ext}"));
×
303

304
        fs::write(&path, code.join("\n"))?;
×
305

306
        Ok(path)
×
307
    }
×
308
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc