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

dcdpr / jp / 22154979281

18 Feb 2026 07:44PM UTC coverage: 55.288% (+1.3%) from 54.027%
22154979281

Pull #395

github

web-flow
Merge 0b5125385 into 76444fafa
Pull Request #395: Vet

780 of 1027 new or added lines in 36 files covered. (75.95%)

3 existing lines in 3 files now uncovered.

11606 of 20992 relevant lines covered (55.29%)

117.77 hits per line

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

0.0
/crates/jp_cli/src/editor.rs
1
mod parser;
2

3
use std::{
4
    fs::{self, OpenOptions},
5
    io::{Read as _, Write as _},
6
    str::FromStr,
7
};
8

9
use camino::{Utf8Path, Utf8PathBuf};
10
use chrono::{FixedOffset, Local};
11
use duct::Expression;
12
use jp_config::{
13
    AppConfig, PartialAppConfig, ToPartial as _, model::parameters::PartialReasoningConfig,
14
};
15
use jp_conversation::{
16
    ConversationStream,
17
    event::{ChatResponse, EventKind},
18
};
19

20
use crate::{
21
    editor::parser::QueryDocument,
22
    error::{Error, Result},
23
};
24

25
/// The name of the file used to store the current query message.
26
const QUERY_FILENAME: &str = "QUERY_MESSAGE.md";
27

28
/// How to edit the query.
29
#[derive(Debug, Clone, PartialEq, Default)]
30
pub(crate) enum Editor {
31
    /// Use whatever editor is configured.
32
    #[default]
33
    Default,
34

35
    /// Use the given command.
36
    Command(String),
37

38
    /// Do not edit the query.
39
    Disabled,
40
}
41

42
impl FromStr for Editor {
43
    type Err = Error;
44

45
    fn from_str(s: &str) -> Result<Self> {
×
46
        match s {
×
47
            "true" => Ok(Self::Default),
×
48
            "false" => Ok(Self::Disabled),
×
49
            s => Ok(Self::Command(s.to_owned())),
×
50
        }
51
    }
×
52
}
53

54
/// Options for opening an editor.
55
#[derive(Debug)]
56
pub(crate) struct Options {
57
    cmd: Expression,
58

59
    /// The working directory to use.
60
    cwd: Option<Utf8PathBuf>,
61

62
    /// The initial content to use.
63
    content: Option<String>,
64

65
    /// Whether to force write the file, even if it already exists.
66
    force_write: bool,
67
}
68

69
impl Options {
70
    pub(crate) fn new(cmd: Expression) -> Self {
×
71
        Self {
×
72
            cmd,
×
73
            cwd: None,
×
74
            content: None,
×
75
            force_write: false,
×
76
        }
×
77
    }
×
78

79
    /// Add a working directory to the editor options.
80
    #[must_use]
81
    pub(crate) fn with_cwd(mut self, cwd: impl Into<Utf8PathBuf>) -> Self {
×
82
        self.cwd = Some(cwd.into());
×
83
        self
×
84
    }
×
85

86
    /// Add content to the editor options.
87
    #[must_use]
88
    pub(crate) fn with_content(mut self, content: impl Into<String>) -> Self {
×
89
        self.content = Some(content.into());
×
90
        self
×
91
    }
×
92

93
    /// Force write the file, even if it already exists.
94
    #[must_use]
95
    pub(crate) fn with_force_write(mut self, force_write: bool) -> Self {
×
96
        self.force_write = force_write;
×
97
        self
×
98
    }
×
99
}
100

101
pub(crate) struct RevertFileGuard {
102
    path: Option<Utf8PathBuf>,
103
    orig: String,
104
    exists: bool,
105
}
106

107
impl RevertFileGuard {
108
    pub(crate) fn disarm(&mut self) {
×
109
        self.path.take();
×
110
    }
×
111
}
112

113
impl Drop for RevertFileGuard {
114
    fn drop(&mut self) {
×
115
        // No path, means this guard was disarmed.
116
        let Some(path) = &self.path else {
×
117
            return;
×
118
        };
119

120
        // File did not exist, so we remove it, and any empty parent
121
        // directories.
122
        if !self.exists {
×
123
            let _rm = fs::remove_file(path);
×
124
            let mut path = path.clone();
×
125
            loop {
126
                let Some(parent) = path.parent() else {
×
127
                    break;
×
128
                };
129

130
                let Ok(mut dir) = fs::read_dir(parent) else {
×
131
                    break;
×
132
                };
133

134
                if dir.next().is_some() {
×
135
                    break;
×
136
                }
×
137

138
                let _rm = fs::remove_dir(parent);
×
139
                path = parent.to_owned();
×
140
            }
141

142
            return;
×
143
        }
×
144

145
        // File existed, so we restore the original content.
146
        let _write = fs::write(path, &self.orig);
×
147
    }
×
148
}
149

150
/// Open an editor for the given file with the given content.
151
///
152
/// If the file exists, it will be opened, but the content will not be modified
153
/// (in other words, `content` is ignored).
154
///
155
/// When the editor is closed, the contents are returned.
156
pub(crate) fn open(path: Utf8PathBuf, options: Options) -> Result<(String, RevertFileGuard)> {
×
157
    let Options {
158
        cmd,
×
159
        cwd,
×
160
        content,
×
161
        force_write,
×
162
    } = options;
×
163

164
    let exists = path.exists();
×
165
    let guard = RevertFileGuard {
×
166
        path: Some(path.clone()),
×
167
        orig: fs::read_to_string(&path).unwrap_or_default(),
×
168
        exists,
×
169
    };
×
170

171
    let existing_content = fs::read_to_string(&path).unwrap_or_default();
×
172

173
    if !exists || existing_content.is_empty() || force_write {
×
174
        if let Some(parent) = path.parent() {
×
175
            fs::create_dir_all(parent)?;
×
176
        }
×
177

178
        let mut file = OpenOptions::new()
×
179
            .read(true)
×
180
            .write(true)
×
181
            .create(true)
×
182
            .truncate(true)
×
183
            .open(&path)?;
×
184

185
        let mut current_content = String::new();
×
186
        file.read_to_string(&mut current_content)?;
×
187

188
        file.write_all(content.unwrap_or_default().as_bytes())?;
×
189
        file.write_all(current_content.as_bytes())?;
×
190
    }
×
191

192
    // Open the editor
193
    let output = cmd
×
194
        .before_spawn({
×
195
            let path = path.clone();
×
196
            move |cmd| {
×
197
                cmd.arg(path.clone());
×
198

199
                if let Some(cwd) = &cwd {
×
200
                    cmd.current_dir(cwd);
×
201
                }
×
202

203
                Ok(())
×
204
            }
×
205
        })
206
        .unchecked()
×
207
        .run()?;
×
208

209
    let status = output.status;
×
210
    if !status.success() {
×
211
        return Err(Error::Editor(format!("Editor exited with error: {status}")));
×
212
    }
×
213

214
    // Read the edited content
215
    let content = fs::read_to_string(path)?;
×
216

217
    Ok((content, guard))
×
218
}
×
219

220
/// Open an editor for the user to input or edit text using a file in the workspace
221
pub(crate) fn edit_query(
×
222
    config: &AppConfig,
×
223
    root: &Utf8Path,
×
224
    stream: &ConversationStream,
×
225
    query: &str,
×
226
    cmd: Expression,
×
227
    config_error: Option<&str>,
×
228
) -> Result<(String, Utf8PathBuf, PartialAppConfig)> {
×
229
    let query_file_path = root.join(QUERY_FILENAME);
×
230
    let existing_content = fs::read_to_string(&query_file_path).unwrap_or_default();
×
231
    let mut doc = QueryDocument::try_from(existing_content.as_str()).unwrap_or_default();
×
232

233
    if doc.query.is_empty() {
×
234
        doc.query = query;
×
235
    }
×
236

237
    let config_value = build_config_text(config);
×
238
    if doc.meta.config.value.is_empty() {
×
239
        doc.meta.config.value = &config_value;
×
240
    }
×
241

242
    if let Some(error) = config_error {
×
243
        doc.meta.config.error = Some(error);
×
244
    }
×
245

246
    let history_value = build_history_text(stream);
×
247
    doc.meta.history.value = &history_value;
×
248

249
    let options = Options::new(cmd.clone())
×
250
        .with_cwd(root)
×
251
        .with_content(doc)
×
252
        .with_force_write(true);
×
253

254
    let (content, mut guard) = open(query_file_path.clone(), options)?;
×
255

256
    let doc = QueryDocument::try_from(content.as_str()).unwrap_or_default();
×
257
    let mut partial = PartialAppConfig::empty();
×
258
    if !doc.meta.config.value.is_empty() {
×
259
        match toml::from_str::<PartialAppConfig>(doc.meta.config.value) {
×
260
            Ok(v) => partial = v,
×
261
            Err(error) => {
×
262
                let error = error.to_string();
×
263
                return edit_query(config, root, stream, "", cmd, Some(&error));
×
264
            }
265
        }
266
    }
×
267

268
    guard.disarm();
×
269
    Ok((doc.query.to_owned(), query_file_path, partial))
×
270
}
×
271

272
fn build_config_text(config: &AppConfig) -> String {
×
273
    let model_id = &config.assistant.model.id;
×
274
    let mut active_config = PartialAppConfig::empty();
×
275
    active_config.assistant.model.id = model_id.to_partial();
×
276
    active_config.assistant.model.parameters.reasoning = config
×
277
        .assistant
×
278
        .model
×
279
        .parameters
×
280
        .reasoning
×
281
        .map(|v| v.to_partial())
×
282
        .or(Some(PartialReasoningConfig::Auto));
×
283

284
    toml::to_string_pretty(&active_config).unwrap_or_default()
×
285
}
×
286

287
fn build_history_text(history: &ConversationStream) -> String {
×
288
    let mut text = String::new();
×
289

290
    if !history.is_empty() {
×
291
        text.push_str("\n# Conversation History (last 10 entries)");
×
292
    }
×
293

NEW
294
    let local_offset: FixedOffset = *Local::now().offset();
×
NEW
295
    let format = "%Y-%m-%d %H:%M:%S";
×
296

297
    let mut messages = vec![];
×
298
    for event in history.iter().rev().take(10) {
×
299
        let mut buf = String::new();
×
300
        let timestamp = event
×
301
            .timestamp
×
NEW
302
            .with_timezone(&local_offset)
×
NEW
303
            .format(format)
×
NEW
304
            .to_string();
×
305

306
        let options = comrak::Options {
×
307
            render: comrak::options::Render {
×
308
                width: 80,
×
309
                r#unsafe: true,
×
310
                prefer_fenced: true,
×
311
                experimental_minimize_commonmark: true,
×
312
                ..Default::default()
×
313
            },
×
314
            ..Default::default()
×
315
        };
×
316

317
        match &event.kind {
×
318
            EventKind::ChatRequest(request) => {
×
319
                buf.push_str(&format!("## You on {timestamp}\n\n"));
×
320
                buf.push_str(comrak::markdown_to_commonmark(&request.content, &options).trim());
×
321
            }
×
322
            EventKind::ChatResponse(response) => match response {
×
323
                ChatResponse::Message { message } => {
×
324
                    buf.push_str("\n\n## Assistant");
×
325
                    buf.push_str(&format!(" ({})", event.config.assistant.model.id));
×
326
                    buf.push_str(&format!(" on {timestamp}\n\n"));
×
327
                    buf.push_str(comrak::markdown_to_commonmark(message, &options).trim());
×
328
                }
×
329
                ChatResponse::Reasoning { reasoning, .. } => {
×
330
                    buf.push_str("\n\n## Assistant (reasoning)");
×
331
                    buf.push_str(&format!(" ({})", event.config.assistant.model.id));
×
332
                    buf.push_str(&format!(" on {timestamp}\n\n"));
×
333
                    buf.push_str(comrak::markdown_to_commonmark(reasoning, &options).trim());
×
334
                }
×
335
            },
336
            EventKind::ToolCallRequest(request) => {
×
337
                if let Ok(json) = serde_json::to_string_pretty(request) {
×
338
                    buf.push_str(&format!("\n\n## Tool Call Request on {timestamp}\n\n"));
×
339
                    buf.push_str("```json\n");
×
340
                    buf.push_str(&json);
×
341
                    buf.push_str("\n```");
×
342
                }
×
343
            }
344
            EventKind::ToolCallResponse(response) => {
×
345
                if response.result.is_ok() {
×
346
                    buf.push_str(&format!("\n\n## Tool Call Result on {timestamp}\n\n"));
×
347
                } else {
×
348
                    buf.push_str(&format!("\n\n## Tool Call **Error** on {timestamp}\n\n"));
×
349
                }
×
350
                buf.push_str("```\n");
×
351
                buf.push_str(&response.result.clone().unwrap_or_else(|err| err));
×
352
                buf.push_str("\n```");
×
353
            }
354
            EventKind::InquiryRequest(request) => {
×
355
                buf.push_str(&format!(
×
356
                    "\n\n## Inquiry Request ({:?}) on {timestamp}\n\n",
×
357
                    request.source
×
358
                ));
×
359
                buf.push_str(&request.question.text);
×
360
            }
×
361
            EventKind::InquiryResponse(response) => {
×
362
                buf.push_str(&format!("\n\n## Inquiry Response on {timestamp}\n\n"));
×
363
                buf.push_str("Answer: ");
×
364
                buf.push_str(&response.answer.to_string());
×
365
            }
×
366
        }
367

368
        buf.push_str("\n\n");
×
369
        messages.push(buf);
×
370
    }
371

372
    text.extend(messages);
×
373
    text
×
374
}
×
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