• 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/cmd.rs
1
mod attachment;
2
mod config;
3
mod conversation;
4
mod init;
5
mod query;
6

7
use std::{borrow::Cow, fmt, num::NonZeroU8};
8

9
use comfy_table::Row;
10
use jp_config::PartialAppConfig;
11
use jp_workspace::Workspace;
12
use serde_json::Value;
13

14
use crate::{Ctx, ctx::IntoPartialAppConfig};
15

16
#[derive(Debug, clap::Subcommand)]
17
#[expect(clippy::large_enum_variant)]
18
pub(crate) enum Commands {
19
    /// Initialize a new workspace.
20
    Init(init::Init),
21

22
    /// Configuration management.
23
    #[command(visible_alias = "cfg")]
24
    Config(config::Config),
25

26
    /// Query the assistant.
27
    #[command(visible_alias = "q")]
28
    Query(query::Query),
29

30
    /// Manage attachments.
31
    #[command(visible_alias = "a", alias = "attachments")]
32
    Attachment(attachment::Attachment),
33

34
    // TODO: Remove once we have proper customizable "command aliases".
35
    #[command(name = "aa", hide = true)]
36
    AttachmentAdd(attachment::add::Add),
37

38
    /// Manage conversations.
39
    #[command(visible_alias = "c", alias = "conversations")]
40
    Conversation(conversation::Conversation),
41
}
42

43
impl Commands {
44
    pub(crate) async fn run(self, ctx: &mut Ctx) -> Output {
×
45
        match self {
×
46
            Commands::Query(args) => args.run(ctx).await,
×
47
            Commands::Config(args) => args.run(ctx),
×
48
            Commands::Attachment(args) => args.run(ctx),
×
49
            Commands::AttachmentAdd(args) => args.run(ctx),
×
50
            Commands::Conversation(args) => args.run(ctx).await,
×
51
            Commands::Init(_) => unreachable!("handled before workspace initialization"),
×
52
        }
53
    }
×
54

55
    pub(crate) fn name(&self) -> &'static str {
×
56
        match self {
×
57
            Commands::Query(_) => "query",
×
58
            Commands::Config(_) => "config",
×
59
            Commands::Attachment(_) => "attachment",
×
60
            Commands::AttachmentAdd(_) => "attachment-add",
×
61
            Commands::Init(_) => "init",
×
62
            Commands::Conversation(_) => "conversation",
×
63
        }
64
    }
×
65
}
66

67
impl IntoPartialAppConfig for Commands {
68
    fn apply_cli_config(
×
69
        &self,
×
70
        workspace: Option<&Workspace>,
×
71
        partial: PartialAppConfig,
×
72
        merged_config: Option<&PartialAppConfig>,
×
73
    ) -> Result<PartialAppConfig, Box<dyn std::error::Error + Send + Sync>> {
×
74
        match self {
×
75
            Commands::Query(args) => args.apply_cli_config(workspace, partial, merged_config),
×
76
            Commands::Attachment(args) => args.apply_cli_config(workspace, partial, merged_config),
×
77
            Commands::AttachmentAdd(args) => {
×
78
                args.apply_cli_config(workspace, partial, merged_config)
×
79
            }
80
            _ => Ok(partial),
×
81
        }
82
    }
×
83

84
    fn apply_conversation_config(
×
85
        &self,
×
86
        workspace: Option<&Workspace>,
×
87
        partial: PartialAppConfig,
×
88
        merged_config: Option<&PartialAppConfig>,
×
89
    ) -> Result<PartialAppConfig, Box<dyn std::error::Error + Send + Sync>> {
×
90
        match self {
×
91
            Commands::Query(args) => {
×
92
                args.apply_conversation_config(workspace, partial, merged_config)
×
93
            }
94
            _ => Ok(partial),
×
95
        }
96
    }
×
97
}
98

99
pub(crate) type Output = std::result::Result<Success, Error>;
100

101
/// The type of output that should be printed to the screen.
102
#[derive(Debug)]
103
pub(crate) enum Success {
104
    /// The command was successful.
105
    Ok,
106

107
    /// Single message to be printed to the screen.
108
    Message(String),
109

110
    /// List of details to be printed in a table.
111
    Table { header: Row, rows: Vec<Row> },
112

113
    /// Details of a single item to be printed.
114
    Details {
115
        title: Option<String>,
116
        rows: Vec<Row>,
117
    },
118

119
    /// JSON value to be printed.
120
    Json(Value),
121
}
122

123
impl From<()> for Success {
124
    fn from(_value: ()) -> Self {
×
125
        Self::Ok
×
126
    }
×
127
}
128

129
impl From<String> for Success {
130
    fn from(value: String) -> Self {
×
131
        Self::Message(value)
×
132
    }
×
133
}
134

135
impl From<&str> for Success {
136
    fn from(value: &str) -> Self {
×
137
        value.to_string().into()
×
138
    }
×
139
}
140

141
impl From<Cow<'_, str>> for Success {
142
    fn from(value: Cow<'_, str>) -> Self {
×
143
        value.to_string().into()
×
144
    }
×
145
}
146

147
impl From<Value> for Success {
148
    fn from(value: Value) -> Self {
×
149
        Self::Json(value)
×
150
    }
×
151
}
152

153
#[derive(Debug, thiserror::Error)]
154
pub(crate) struct Error {
155
    /// The error code.
156
    ///
157
    /// Used to exit the CLI with a specific exit code. This is usually `1`.
158
    pub(super) code: NonZeroU8,
159

160
    /// The optional error message to be displayed to the user.
161
    pub(super) message: Option<String>,
162

163
    /// Metadata to be displayed to the user.
164
    ///
165
    /// This is hidden from the user in TTY mode, unless the `--verbose` flag is
166
    /// set.
167
    pub(super) metadata: Vec<(String, Value)>,
168

169
    /// Whether to disable persistence when this error is encountered.
170
    pub(super) disable_persistence: bool,
171
}
172

173
impl Error {
174
    pub(super) fn with_persistence(self, persist: bool) -> Self {
×
175
        Self {
×
176
            disable_persistence: !persist,
×
177
            ..self
×
178
        }
×
179
    }
×
180
}
181

182
impl fmt::Display for Error {
183
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
×
184
        write!(f, "{}", self.message.as_deref().unwrap_or_default())
×
185
    }
×
186
}
187

188
impl From<u8> for Error {
189
    fn from(code: u8) -> Self {
×
190
        Self {
×
191
            code: code.try_into().unwrap_or(NonZeroU8::new(1).unwrap()),
×
192
            message: None,
×
193
            metadata: vec![],
×
194
            disable_persistence: true,
×
195
        }
×
196
    }
×
197
}
198

199
impl From<Box<dyn std::error::Error>> for Error {
200
    fn from(error: Box<dyn std::error::Error>) -> Self {
×
201
        Self::from(error.to_string())
×
202
    }
×
203
}
204

205
impl From<Box<dyn std::error::Error + Send + Sync>> for Error {
206
    fn from(error: Box<dyn std::error::Error + Send + Sync>) -> Self {
×
207
        Self::from(error.to_string())
×
208
    }
×
209
}
210

211
impl From<String> for Error {
212
    fn from(error: String) -> Self {
×
213
        (1, error).into()
×
214
    }
×
215
}
216

217
impl From<&str> for Error {
218
    fn from(error: &str) -> Self {
×
219
        error.to_owned().into()
×
220
    }
×
221
}
222

223
impl From<(u8, String)> for Error {
224
    fn from((code, message): (u8, String)) -> Self {
×
225
        (code, message, vec![]).into()
×
226
    }
×
227
}
228

229
impl From<(u8, &str)> for Error {
230
    fn from((code, message): (u8, &str)) -> Self {
×
231
        (code, message.to_owned()).into()
×
232
    }
×
233
}
234

235
impl From<(u8, String, Vec<(String, Value)>)> for Error {
236
    fn from((code, message, metadata): (u8, String, Vec<(String, Value)>)) -> Self {
×
237
        Self {
×
238
            code: code.try_into().unwrap_or(NonZeroU8::new(1).unwrap()),
×
239
            message: Some(message),
×
240
            metadata: metadata.into_iter().collect(),
×
241
            disable_persistence: true,
×
242
        }
×
243
    }
×
244
}
245

246
impl From<(u8, &str, Vec<(String, Value)>)> for Error {
247
    fn from((code, message, metadata): (u8, &str, Vec<(String, Value)>)) -> Self {
×
248
        (code, message.to_string(), metadata).into()
×
249
    }
×
250
}
251

252
impl From<Vec<(String, Value)>> for Error {
253
    fn from(metadata: Vec<(String, Value)>) -> Self {
×
254
        (1, metadata).into()
×
255
    }
×
256
}
257

258
impl From<Vec<(&str, Value)>> for Error {
259
    fn from(metadata: Vec<(&str, Value)>) -> Self {
×
260
        metadata
×
261
            .into_iter()
×
262
            .map(|(k, v)| (k.to_owned(), v))
×
263
            .collect::<Vec<_>>()
×
264
            .into()
×
265
    }
×
266
}
267

268
impl From<Vec<(&'static str, String)>> for Error {
269
    fn from(metadata: Vec<(&'static str, String)>) -> Self {
×
270
        metadata
×
271
            .into_iter()
×
272
            .map(|(k, v)| (k, Value::String(v)))
×
273
            .collect::<Vec<_>>()
×
274
            .into()
×
275
    }
×
276
}
277

278
impl From<(u8, Vec<(String, Value)>)> for Error {
279
    fn from((code, mut metadata): (u8, Vec<(String, Value)>)) -> Self {
×
280
        let message = metadata
×
281
            .iter()
×
282
            .position(|(k, _)| k == "message")
×
283
            .and_then(|i| metadata.remove(i).1.as_str().map(ToString::to_string))
×
284
            .unwrap_or_else(|| "Application error".to_owned());
×
285

286
        (code, message, metadata).into()
×
287
    }
×
288
}
289

290
impl From<crate::error::Error> for Error {
291
    fn from(error: crate::error::Error) -> Self {
×
292
        use crate::error::Error::*;
293

294
        let metadata: Vec<(&str, String)> = match error {
×
295
            Command(error) => return error,
×
296
            Config(error) => return error.into(),
×
297
            KeyValue(error) => return error.into(),
×
298
            Workspace(error) => return error.into(),
×
299
            Conversation(error) => return error.into(),
×
300
            Mcp(error) => return error.into(),
×
301
            Llm(error) => return error.into(),
×
302
            Io(error) => return error.into(),
×
303
            Url(error) => return error.into(),
×
NEW
304
            SyntaxHighlight(error) => return error.into(),
×
305
            Template(error) => return error.into(),
×
306
            Json(error) => return error.into(),
×
307
            Toml(error) => return error.into(),
×
308
            Which(error) => return error.into(),
×
309
            ConfigLoader(error) => return error.into(),
×
310
            Tool(error) => return error.into(),
×
311
            ModelId(error) => return error.into(),
×
312
            Inquire(error) => return error.into(),
×
313
            Fmt(error) => return error.into(),
×
314
            NotFound(target, id) => [
×
315
                ("message", "Not found".into()),
×
316
                ("target", target.into()),
×
317
                ("id", id),
×
318
            ]
×
319
            .into(),
×
320
            Attachment(error) => [
×
321
                ("message", "Attachment error".into()),
×
322
                ("error", error.clone()),
×
323
            ]
×
324
            .into(),
×
325
            Editor(error) => [("message", "Editor error".into()), ("error", error.clone())].into(),
×
326
            Task(error) => with_cause(error.as_ref(), "Task error"),
×
327
            TemplateUndefinedVariable(var) => [
×
328
                ("message", "Undefined template variable".to_owned()),
×
329
                ("variable", var),
×
330
            ]
×
331
            .into(),
×
332
            MissingEditor => [("message", "Missing editor".to_owned())].into(),
×
333
            CliConfig(error) => {
×
334
                [("message", "CLI Config error".to_owned()), ("error", error)].into()
×
335
            }
336
            UnknownModel { model, available } => [
×
337
                ("message", "Unknown model".into()),
×
338
                ("model", model),
×
339
                ("available", available.join(", ")),
×
340
            ]
×
341
            .into(),
×
342
            MissingConfigFile(path) => [
×
343
                ("message", "Missing config file".into()),
×
344
                ("path", path.to_string()),
×
345
            ]
×
346
            .into(),
×
347
        };
348

349
        Self::from(metadata)
×
350
    }
×
351
}
352

353
fn with_cause(
×
354
    mut error: &dyn std::error::Error,
×
355
    message: impl Into<String>,
×
356
) -> Vec<(&'static str, String)> {
×
357
    let mut causes = vec![("message", message.into()), ("", format!("{error:#}"))];
×
358
    while let Some(cause) = error.source() {
×
359
        error = cause;
×
360
        causes.push(("", format!("{error:#}")));
×
361
    }
×
362

363
    causes.into_iter().collect()
×
364
}
×
365

366
macro_rules! impl_from_error {
367
    ($error:ty, $message:expr) => {
368
        impl From<$error> for Error {
369
            fn from(error: $error) -> Self {
×
370
                with_cause(&error, $message).into()
×
371
            }
×
372
        }
373
    };
374
}
375

376
impl_from_error!(syntect::Error, "Error while formatting code");
377
impl_from_error!(
378
    jp_config::assignment::KvAssignmentError,
379
    "Key-value assignment error"
380
);
381
impl_from_error!(jp_config::Error, "Config error");
382
impl_from_error!(jp_config::fs::ConfigLoaderError, "Config loader error");
383
impl_from_error!(jp_conversation::Error, "Conversation error");
384
impl_from_error!(jp_llm::ToolError, "Tool error");
385
impl_from_error!(jp_llm::AggregationError, "Tool call aggregation error");
386
impl_from_error!(jp_mcp::Error, "MCP error");
387
impl_from_error!(minijinja::Error, "Template error");
388
impl_from_error!(quick_xml::SeError, "XML serialization error");
389
impl_from_error!(reqwest::Error, "Error while making HTTP request");
390
impl_from_error!(serde::de::value::Error, "Deserialization error");
391
impl_from_error!(serde_json::Error, "Error while parsing JSON");
392
impl_from_error!(std::io::Error, "IO error");
393
impl_from_error!(std::num::ParseIntError, "Error parsing integer value");
394
impl_from_error!(std::str::ParseBoolError, "Error parsing boolean value");
395
impl_from_error!(toml::de::Error, "Error while parsing TOML");
396
impl_from_error!(toml::ser::Error, "Error while serializing TOML");
397
impl_from_error!(url::ParseError, "Error while parsing URL");
398
impl_from_error!(which::Error, "Which error");
399
impl_from_error!(jp_config::model::id::ModelIdConfigError, "Model ID error");
400
impl_from_error!(jp_config::model::id::ModelIdError, "Model ID error");
401
impl_from_error!(inquire::error::InquireError, "Inquire error");
402
impl_from_error!(tokio::task::JoinError, "Join error");
403
impl_from_error!(std::fmt::Error, "fmt error");
404

405
impl From<jp_llm::Error> for Error {
406
    fn from(error: jp_llm::Error) -> Self {
×
407
        use jp_llm::Error::*;
408

409
        let metadata: Vec<(&str, String)> = match error {
×
410
            OpenRouter(error) => return error.into(),
×
411
            Conversation(error) => return error.into(),
×
412
            XmlSerialization(error) => return error.into(),
×
413
            Config(error) => return error.into(),
×
414
            Json(error) => return error.into(),
×
415
            Request(error) => return error.into(),
×
416
            Url(error) => return error.into(),
×
417
            ModelIdConfig(error) => return error.into(),
×
418
            ModelId(error) => return error.into(),
×
419
            ToolCallRequestAggregator(error) => return error.into(),
×
420
            MissingEnv(variable) => [
×
421
                ("message", "Missing environment variable".into()),
×
422
                ("variable", variable),
×
423
            ]
×
424
            .into(),
×
425
            InvalidResponse(error) => [
×
426
                ("message", "Invalid response received".into()),
×
427
                ("error", error),
×
428
            ]
×
429
            .into(),
×
430
            MissingStructuredData => {
431
                [("message", "Missing structured data in response".into())].into()
×
432
            }
433
            OpenaiClient(error) => with_cause(&error, "OpenAI client error"),
×
434
            OpenaiEvent(error) => with_cause(&error, "OpenAI stream error"),
×
435
            OpenaiResponse(error) => [
×
436
                ("message", "OpenAI response error".into()),
×
437
                ("error", error.message),
×
438
                ("code", error.code.unwrap_or_default()),
×
439
                ("type", error.r#type),
×
440
                ("param", error.param.unwrap_or_default()),
×
441
            ]
×
442
            .into(),
×
443
            OpenaiStatusCode {
444
                status_code,
×
445
                response,
×
446
            } => [
×
447
                ("message", "OpenAI status code error".into()),
×
448
                ("status_code", status_code.as_u16().to_string()),
×
449
                ("response", response),
×
450
            ]
×
451
            .into(),
×
452
            Anthropic(anthropic_error) => [
×
453
                ("message", "Anthropic error".into()),
×
454
                ("error", anthropic_error.to_string()),
×
455
            ]
×
456
            .into(),
×
457
            AnthropicRequestBuilder(error) => [
×
458
                ("message", "Anthropic request builder error".into()),
×
459
                ("error", error.to_string()),
×
460
            ]
×
461
            .into(),
×
462
            Ollama(error) => [
×
463
                ("message", "Ollama error".into()),
×
464
                ("error", error.to_string()),
×
465
            ]
×
466
            .into(),
×
467
            Gemini(error) => [
×
468
                ("message", "Gemini error".into()),
×
469
                ("error", error.to_string()),
×
470
            ]
×
471
            .into(),
×
472
            RateLimit { retry_after } => [
×
473
                ("message", "Rate limited".into()),
×
474
                (
×
475
                    "retry_after",
×
476
                    retry_after.unwrap_or_default().as_secs().to_string(),
×
477
                ),
×
478
            ]
×
479
            .into(),
×
480
            UnknownModel(model) => [("message", "Unknown model".into()), ("model", model)].into(),
×
481
        };
482

483
        Self::from(metadata)
×
484
    }
×
485
}
486

487
impl From<jp_openrouter::Error> for Error {
488
    fn from(error: jp_openrouter::Error) -> Self {
×
489
        use jp_openrouter::Error::*;
490

491
        let metadata: Vec<(&str, Value)> = match error {
×
492
            Request(error) => return error.into(),
×
493
            Json(error) => return error.into(),
×
494
            Io(error) => return error.into(),
×
495
            Stream(string) => [
×
496
                ("message", "Error while processing stream".into()),
×
497
                ("error", string.into()),
×
498
            ]
×
499
            .into(),
×
500
            Api { code, message } => [
×
501
                ("message", "LLM Provider API error".into()),
×
502
                ("code", code.into()),
×
503
                ("message", message.into()),
×
504
            ]
×
505
            .into(),
×
506
            Config(message) => [
×
507
                ("message", "Config error".into()),
×
508
                ("error", message.into()),
×
509
            ]
×
510
            .into(),
×
511
        };
512

513
        Self::from(metadata)
×
514
    }
×
515
}
516

517
impl From<jp_workspace::Error> for Error {
518
    fn from(error: jp_workspace::Error) -> Self {
×
519
        use jp_workspace::Error::*;
520

521
        let metadata: Vec<(&str, Value)> = match error {
×
522
            Conversation(error) => return error.into(),
×
523
            Storage(error) => return error.into(),
×
524
            Io(error) => return error.into(),
×
525
            Config(error) => return error.into(),
×
526
            NotDir(path) => [
×
527
                ("message", "Path is not a directory.".into()),
×
528
                ("path", path.to_string().into()),
×
529
            ]
×
530
            .into(),
×
531
            MissingStorage => [("message", "Missing storage directory".into())].into(),
×
532
            MissingHome => [("message", "Missing home directory".into())].into(),
×
533
            NotFound(target, id) => [
×
534
                ("message", "Not found".into()),
×
535
                ("target", target.into()),
×
536
                ("id", id.into()),
×
537
            ]
×
538
            .into(),
×
539
            Exists { target, id } => [
×
540
                ("message", "Exists".into()),
×
541
                ("target", target.into()),
×
542
                ("id", id.into()),
×
543
            ]
×
544
            .into(),
×
545
            CannotRemoveActiveConversation(conversation_id) => [
×
546
                ("message", "Cannot remove active conversation".into()),
×
547
                ("conversation_id", conversation_id.to_string().into()),
×
548
            ]
×
549
            .into(),
×
550
            Id(error) => [
×
551
                ("message", "Invalid workspace ID".into()),
×
552
                ("error", error.clone().into()),
×
553
            ]
×
554
            .into(),
×
555
        };
556

557
        Self::from(metadata)
×
558
    }
×
559
}
560

561
impl From<jp_storage::Error> for Error {
562
    fn from(error: jp_storage::Error) -> Self {
×
563
        use jp_storage::Error;
564

565
        let metadata: Vec<(&str, Value)> = match error {
×
566
            Error::Conversation(error) => return error.into(),
×
567
            Error::Io(error) => return error.into(),
×
568
            Error::Json(error) => return error.into(),
×
569
            Error::Toml(error) => return error.into(),
×
570
            Error::Config(error) => return error.into(),
×
571
            Error::NotDir(path) => [
×
572
                ("message", "Path is not a directory.".into()),
×
573
                ("path", path.to_string().into()),
×
574
            ]
×
575
            .into(),
×
576
            Error::NotSymlink(path) => [
×
577
                ("message", "Path is not a symlink.".into()),
×
578
                ("path", path.to_string().into()),
×
579
            ]
×
580
            .into(),
×
581
        };
582

583
        Self::from(metadata)
×
584
    }
×
585
}
586

587
impl From<jp_id::Error> for Error {
588
    fn from(error: jp_id::Error) -> Self {
×
589
        use jp_id::Error::*;
590

591
        let metadata: Vec<(&str, Value)> = match error {
×
592
            MissingPrefix(prefix) => [
×
593
                ("message", "Missing prefix".into()),
×
594
                ("prefix", prefix.into()),
×
595
            ]
×
596
            .into(),
×
597
            InvalidPrefix(expected, actual) => [
×
598
                ("message", "Invalid prefix".into()),
×
599
                ("expected", expected.into()),
×
600
                ("actual", actual.into()),
×
601
            ]
×
602
            .into(),
×
603
            MissingVariantAndTargetId => {
604
                [("message", "Missing variant and target ID".into())].into()
×
605
            }
606
            MissingVariant => [("message", "Missing variant".into())].into(),
×
607
            InvalidVariant(variant) => [
×
608
                ("message", "Invalid variant".into()),
×
609
                ("variant", variant.to_string().into()),
×
610
            ]
×
611
            .into(),
×
612
            UnexpectedVariant(expected, variant) => [
×
613
                ("message", "Unexpected variant".into()),
×
614
                ("variant", variant.to_string().into()),
×
615
                ("expected", expected.to_string().into()),
×
616
            ]
×
617
            .into(),
×
618
            MissingTargetId => [("message", "Missing target ID".into())].into(),
×
619
            InvalidTimestamp(timestamp) => [
×
620
                ("message", "Invalid timestamp".into()),
×
621
                ("timestamp", timestamp.into()),
×
622
            ]
×
623
            .into(),
×
624
            MissingGlobalId => [("message", "Missing global ID".into())].into(),
×
625
            InvalidGlobalId(id) => [
×
626
                ("message", "Invalid workspace ID".into()),
×
627
                ("id", id.into()),
×
628
            ]
×
629
            .into(),
×
630
        };
631

632
        Self::from(metadata)
×
633
    }
×
634
}
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