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

dcdpr / jp / 25827631389

13 May 2026 09:31PM UTC coverage: 66.696% (+0.2%) from 66.49%
25827631389

push

github

web-flow
fix(attachment_internal, cli): Skip missing conversations on query (#635)

Previously, if a `jp://` attachment referenced a conversation that had
been archived or removed, `jp query` would hard-fail with an error,
making the conversation unusable until the stale attachment was manually
cleaned up.

This change introduces a dedicated `ResolveError` enum in
`jp_attachment_internal`, replacing the opaque `Box<dyn Error>` return
type on `resolve()`. The new `ConversationMissing` variant is surfaced
separately from all other failures (`Other`), giving callers a
structured way to distinguish "conversation is gone" from real errors.

On the CLI side, a new `load_conversation_attachments` helper in
`jp_cli` uses this distinction: when a `jp://` attachment resolves to
`ConversationMissing`, it logs a warning and skips the reference instead
of aborting the query. Any other failure (invalid URI, I/O error, etc.)
is still treated as a hard error. The `query` command now calls this
helper instead of resolving attachments directly via
`register_attachment`.

A corresponding `AttachmentConversationMissing` error variant is added
to the CLI error type so the missing-conversation case can be matched
exhaustively throughout the call stack.

---------

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

32 of 51 new or added lines in 4 files covered. (62.75%)

92 existing lines in 4 files now uncovered.

26135 of 39185 relevant lines covered (66.7%)

206.41 hits per line

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

20.17
/crates/jp_cli/src/cmd.rs
1
mod attachment;
2
mod config;
3
mod conversation;
4
pub(crate) mod conversation_id;
5
mod init;
6
mod lock;
7
pub(crate) mod plugin;
8
mod query;
9
pub(crate) mod target;
10
pub(crate) mod time;
11

12
use std::{fmt, num::NonZeroU8};
13

14
use jp_config::PartialAppConfig;
15
use jp_workspace::Workspace;
16
use serde_json::Value;
17
pub(crate) use target::ConversationLoadRequest;
18

19
use super::cmd::conversation_id::format_target_help;
20
use crate::{Ctx, ctx::IntoPartialAppConfig};
21

22
#[derive(Debug, clap::Subcommand)]
23
#[command(disable_help_subcommand = true, allow_external_subcommands = true)]
24
#[expect(clippy::large_enum_variant)]
25
pub(crate) enum Commands {
26
    /// Initialize a new workspace.
27
    Init(init::Init),
28

29
    /// Configuration management.
30
    #[command(visible_alias = "cfg")]
31
    Config(config::Config),
32

33
    /// Query the assistant.
34
    #[command(visible_alias = "q")]
35
    Query(query::Query),
36

37
    /// Manage attachments.
38
    #[command(visible_alias = "a", alias = "attachments")]
39
    Attachment(attachment::Attachment),
40

41
    // TODO: Remove once we have proper customizable "command aliases".
42
    #[command(name = "aa", hide = true)]
43
    AttachmentAdd(attachment::add::Add),
44

45
    /// Manage conversations.
46
    #[command(visible_alias = "c", alias = "conversations")]
47
    Conversation(conversation::Conversation),
48

49
    /// Manage plugins.
50
    Plugin(plugin::PluginManagement),
51

52
    /// External plugin subcommand (`jp-<name>` on $PATH or registry).
53
    #[command(external_subcommand)]
54
    External(Vec<String>),
55
}
56

57
impl Commands {
58
    pub(crate) async fn run(
2✔
59
        self,
2✔
60
        ctx: &mut Ctx,
2✔
61
        handles: Vec<jp_workspace::ConversationHandle>,
2✔
62
    ) -> Output {
2✔
63
        match self {
2✔
64
            Commands::Query(args) => {
2✔
65
                debug_assert!(handles.len() < 2, "Query commands use 0 or 1 handle");
2✔
66
                Box::pin(args.run(ctx, handles.into_iter().next())).await
2✔
67
            }
68
            Commands::Config(args) => args.run(ctx, handles).await,
×
69
            Commands::Conversation(args) => args.run(ctx, handles).await,
×
70
            Commands::Attachment(args) => {
×
71
                debug_assert!(handles.is_empty(), "Attachment commands don't use handles");
×
72
                args.run(ctx).await
×
73
            }
74
            Commands::AttachmentAdd(args) => {
×
75
                debug_assert!(handles.is_empty(), "Attachment commands don't use handles");
×
76
                args.run(ctx)
×
77
            }
78
            Commands::Plugin(args) => args.run(ctx).await,
×
79
            Commands::External(args) => plugin::dispatch::run_external(&args, ctx).await,
×
80
            Commands::Init(_) => unreachable!("handled before workspace initialization"),
×
81
        }
82
    }
2✔
83

84
    /// Declare what conversations this command needs and whether any should
85
    /// participate in the config loading pipeline.
86
    pub(crate) fn conversation_load_request(&self) -> ConversationLoadRequest {
2✔
87
        match self {
2✔
88
            Commands::Query(args) => args.conversation_load_request(),
2✔
89
            Commands::Config(args) => args.conversation_load_request(),
×
90
            Commands::Conversation(args) => args.conversation_load_request(),
×
91
            Commands::Init(_)
92
            | Commands::Attachment(_)
93
            | Commands::AttachmentAdd(_)
94
            | Commands::Plugin(_)
95
            | Commands::External(_) => ConversationLoadRequest::none(),
×
96
        }
97
    }
2✔
98

99
    pub(crate) fn name(&self) -> &'static str {
×
100
        match self {
×
101
            Commands::Query(_) => "query",
×
102
            Commands::Config(_) => "config",
×
103
            Commands::Attachment(_) => "attachment",
×
104
            Commands::AttachmentAdd(_) => "attachment-add",
×
105
            Commands::Init(_) => "init",
×
106
            Commands::Conversation(_) => "conversation",
×
107
            Commands::Plugin(_) => "plugin",
×
108
            Commands::External(args) => {
×
109
                // Use first arg as the command name (it's the subcommand name).
110
                // Clap puts the subcommand name as the first element.
111
                if let Some(name) = args.first() {
×
112
                    // Leak is fine: this is called once per CLI invocation.
113
                    return Box::leak(format!("plugin:{name}").into_boxed_str());
×
114
                }
×
115
                "external"
×
116
            }
117
        }
118
    }
×
119
}
120

121
impl IntoPartialAppConfig for Commands {
122
    fn apply_cli_config(
4✔
123
        &self,
4✔
124
        workspace: Option<&Workspace>,
4✔
125
        partial: PartialAppConfig,
4✔
126
        merged_config: Option<&PartialAppConfig>,
4✔
127
    ) -> Result<PartialAppConfig, Box<dyn std::error::Error + Send + Sync>> {
4✔
128
        match self {
4✔
129
            Commands::Query(args) => args.apply_cli_config(workspace, partial, merged_config),
4✔
130
            Commands::Attachment(args) => args.apply_cli_config(workspace, partial, merged_config),
×
131
            Commands::AttachmentAdd(args) => {
×
132
                args.apply_cli_config(workspace, partial, merged_config)
×
133
            }
134
            Commands::Config(_)
135
            | Commands::Conversation(_)
136
            | Commands::Init(_)
137
            | Commands::Plugin(_)
138
            | Commands::External(_) => Ok(partial),
×
139
        }
140
    }
4✔
141

142
    fn apply_conversation_config(
2✔
143
        &self,
2✔
144
        workspace: &Workspace,
2✔
145
        partial: PartialAppConfig,
2✔
146
        merged_config: Option<&PartialAppConfig>,
2✔
147
        handle: &jp_workspace::ConversationHandle,
2✔
148
    ) -> Result<PartialAppConfig, Box<dyn std::error::Error + Send + Sync>> {
2✔
149
        match self {
2✔
150
            Commands::Query(args) => {
2✔
151
                args.apply_conversation_config(workspace, partial, merged_config, handle)
2✔
152
            }
153
            Commands::Config(_)
154
            | Commands::Attachment(_)
155
            | Commands::AttachmentAdd(_)
156
            | Commands::Conversation(_)
157
            | Commands::Init(_)
158
            | Commands::Plugin(_)
159
            | Commands::External(_) => Ok(partial),
×
160
        }
161
    }
2✔
162
}
163

164
pub(crate) type Output = std::result::Result<(), Error>;
165

166
#[derive(Debug, thiserror::Error)]
167
pub(crate) struct Error {
168
    /// The error code.
169
    ///
170
    /// Used to exit the CLI with a specific exit code. This is usually `1`.
171
    pub(super) code: NonZeroU8,
172

173
    /// The optional error message to be displayed to the user.
174
    pub(super) message: Option<String>,
175

176
    /// Metadata to be displayed to the user.
177
    ///
178
    /// This is hidden from the user in TTY mode, unless the `--verbose` flag is
179
    /// set.
180
    pub(super) metadata: Vec<(String, Value)>,
181

182
    /// Whether to disable persistence when this error is encountered.
183
    pub(super) disable_persistence: bool,
184
}
185

186
impl Error {
187
    pub(super) fn with_persistence(self, persist: bool) -> Self {
×
188
        Self {
×
189
            disable_persistence: !persist,
×
190
            ..self
×
191
        }
×
192
    }
×
193
}
194

195
impl fmt::Display for Error {
196
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2✔
197
        write!(
2✔
198
            f,
2✔
199
            "error {}: {} ({})",
200
            self.code,
201
            self.message.as_deref().unwrap_or_default(),
2✔
202
            self.metadata
2✔
203
                .iter()
2✔
204
                .map(|(k, v)| format!("{k}:{v}"))
2✔
205
                .collect::<Vec<_>>()
2✔
206
                .join(", "),
2✔
207
        )
208
    }
2✔
209
}
210

211
impl From<u8> for Error {
212
    fn from(code: u8) -> Self {
×
213
        Self {
×
214
            code: code.try_into().unwrap_or(NonZeroU8::new(1).unwrap()),
×
215
            message: None,
×
216
            metadata: vec![],
×
217
            disable_persistence: true,
×
218
        }
×
219
    }
×
220
}
221

222
impl From<Box<dyn std::error::Error>> for Error {
223
    fn from(error: Box<dyn std::error::Error>) -> Self {
×
224
        Self::from(error.to_string())
×
225
    }
×
226
}
227

228
impl From<Box<dyn std::error::Error + Send + Sync>> for Error {
229
    fn from(error: Box<dyn std::error::Error + Send + Sync>) -> Self {
×
230
        Self::from(error.to_string())
×
231
    }
×
232
}
233

234
impl From<String> for Error {
235
    fn from(error: String) -> Self {
7✔
236
        (1, error).into()
7✔
237
    }
7✔
238
}
239

240
impl From<&str> for Error {
241
    fn from(error: &str) -> Self {
3✔
242
        error.to_owned().into()
3✔
243
    }
3✔
244
}
245

246
impl From<(u8, String)> for Error {
247
    fn from((code, message): (u8, String)) -> Self {
7✔
248
        (code, message, vec![]).into()
7✔
249
    }
7✔
250
}
251

252
impl From<(u8, &str)> for Error {
253
    fn from((code, message): (u8, &str)) -> Self {
×
254
        (code, message.to_owned()).into()
×
255
    }
×
256
}
257

258
impl From<(u8, String, Vec<(String, Value)>)> for Error {
259
    fn from((code, message, metadata): (u8, String, Vec<(String, Value)>)) -> Self {
10✔
260
        Self {
10✔
261
            code: code.try_into().unwrap_or(NonZeroU8::new(1).unwrap()),
10✔
262
            message: Some(message),
10✔
263
            metadata: metadata.into_iter().collect(),
10✔
264
            disable_persistence: true,
10✔
265
        }
10✔
266
    }
10✔
267
}
268

269
impl From<(u8, &str, Vec<(String, Value)>)> for Error {
270
    fn from((code, message, metadata): (u8, &str, Vec<(String, Value)>)) -> Self {
×
271
        (code, message.to_string(), metadata).into()
×
272
    }
×
273
}
274

275
impl From<Vec<(String, Value)>> for Error {
276
    fn from(metadata: Vec<(String, Value)>) -> Self {
3✔
277
        (1, metadata).into()
3✔
278
    }
3✔
279
}
280

281
impl From<Vec<(&str, Value)>> for Error {
282
    fn from(metadata: Vec<(&str, Value)>) -> Self {
3✔
283
        metadata
3✔
284
            .into_iter()
3✔
285
            .map(|(k, v)| (k.to_owned(), v))
4✔
286
            .collect::<Vec<_>>()
3✔
287
            .into()
3✔
288
    }
3✔
289
}
290

291
impl From<Vec<(&'static str, String)>> for Error {
292
    fn from(metadata: Vec<(&'static str, String)>) -> Self {
3✔
293
        metadata
3✔
294
            .into_iter()
3✔
295
            .map(|(k, v)| (k, Value::String(v)))
4✔
296
            .collect::<Vec<_>>()
3✔
297
            .into()
3✔
298
    }
3✔
299
}
300

301
impl From<(u8, Vec<(String, Value)>)> for Error {
302
    fn from((code, mut metadata): (u8, Vec<(String, Value)>)) -> Self {
3✔
303
        let message = metadata
3✔
304
            .iter()
3✔
305
            .position(|(k, _)| k == "message")
3✔
306
            .and_then(|i| metadata.remove(i).1.as_str().map(ToString::to_string))
3✔
307
            .unwrap_or_else(|| "Application error".to_owned());
3✔
308

309
        (code, message, metadata).into()
3✔
310
    }
3✔
311
}
312

313
impl From<crate::error::Error> for Error {
314
    #[expect(clippy::too_many_lines)]
315
    fn from(error: crate::error::Error) -> Self {
3✔
316
        use crate::error::Error::*;
317

318
        let metadata: Vec<(&str, String)> = match error {
3✔
319
            Command(error) => return error,
×
320
            Config(error) => return error.into(),
×
321
            KeyValue(error) => return error.into(),
×
322
            Workspace(error) => return error.into(),
×
323
            Conversation(error) => return error.into(),
×
324
            Mcp(error) => return error.into(),
×
325
            Llm(error) => return error.into(),
×
326
            Io(error) => return error.into(),
×
327
            Url(error) => return error.into(),
×
328
            SyntaxHighlight(error) => return error.into(),
×
329
            Template(error) => return error.into(),
×
330
            Json(error) => return error.into(),
×
331
            Toml(error) => return error.into(),
×
332
            Which(error) => return error.into(),
×
333
            ConfigLoader(error) => return error.into(),
×
334
            Tool(error) => return error.into(),
×
335
            ModelId(error) => return error.into(),
×
336
            Inquire(error) => return error.into(),
×
337
            Fmt(error) => return error.into(),
×
338
            NotFound(target, id) => [
×
339
                ("message", "Not found".into()),
×
340
                ("target", target.into()),
×
341
                ("id", id),
×
342
            ]
×
343
            .into(),
×
344
            Attachment(error) => [
×
345
                ("message", "Attachment error".into()),
×
346
                ("error", error.clone()),
×
347
            ]
×
348
            .into(),
×
NEW
349
            AttachmentConversationMissing { id, uri } => [
×
NEW
350
                ("message", "Attachment conversation not found".into()),
×
NEW
351
                ("id", id.to_string()),
×
NEW
352
                ("uri", uri.to_string()),
×
NEW
353
            ]
×
NEW
354
            .into(),
×
355
            Editor(error) => [("message", "Editor error".into()), ("error", error.clone())].into(),
×
356
            Task(error) => with_cause(error.as_ref(), "Task error"),
×
357
            TemplateUndefinedVariable(var) => [
×
358
                ("message", "Undefined template variable".to_owned()),
×
359
                ("variable", var),
×
360
            ]
×
361
            .into(),
×
362
            MissingEditor => [("message", "Missing editor".to_owned())].into(),
2✔
363
            Schema(error) => [("message", "Invalid schema".to_owned()), ("error", error)].into(),
×
364
            MissingStructuredData => {
365
                [("message", "No structured data in response".to_owned())].into()
×
366
            }
367
            LockTimeout(id) => [
×
368
                (
×
369
                    "message",
×
370
                    format!("Timed out waiting for lock on conversation {id}"),
×
371
                ),
×
372
                (
×
373
                    "suggestion",
×
374
                    "Use --no-persist to skip locking, or set $JP_LOCK_DURATION to increase the \
×
375
                     timeout."
×
376
                        .to_owned(),
×
377
                ),
×
378
            ]
×
379
            .into(),
×
380
            TargetHelp { session, multi } => {
×
381
                return Self {
×
382
                    code: NonZeroU8::new(1).expect("non-zero"),
×
383
                    message: Some(format_target_help(session, multi, true)),
×
384
                    metadata: vec![],
×
385
                    disable_persistence: false,
×
386
                };
×
387
            }
388
            NoConversationTarget => {
389
                let help = super::cmd::conversation_id::format_target_help(true, true, true);
×
390
                return Self {
×
391
                    code: NonZeroU8::new(1).expect("non-zero"),
×
392
                    message: Some(format!(
×
393
                        "No conversation targeted.\n\nUse --id=<target> to target a conversation, \
×
394
                         or --new to start a new one.\n\n{help}"
×
395
                    )),
×
396
                    metadata: vec![],
×
397
                    disable_persistence: false,
×
398
                };
×
399
            }
400
            CliConfig(error) => {
1✔
401
                [("message", "CLI Config error".to_owned()), ("error", error)].into()
1✔
402
            }
403
            UnknownModel { model, available } => [
×
404
                ("message", "Unknown model".into()),
×
405
                ("model", model),
×
406
                ("available", available.join(", ")),
×
407
            ]
×
408
            .into(),
×
409
            MissingConfigFile { path, searched } => {
×
410
                let mut meta = vec![
×
411
                    ("message", "Missing config file".into()),
×
412
                    ("path", path.to_string()),
×
413
                ];
414
                if !searched.is_empty() {
×
415
                    let dirs = searched
×
416
                        .iter()
×
417
                        .map(|p| format!("  - {p}"))
×
418
                        .collect::<Vec<_>>()
×
419
                        .join("\n");
×
420
                    meta.push(("searched", format!("Searched in:\n{dirs}")));
×
421
                }
×
422
                meta
×
423
            }
424
        };
425

426
        Self::from(metadata)
3✔
427
    }
3✔
428
}
429

430
fn with_cause(
×
431
    mut error: &dyn std::error::Error,
×
432
    message: impl Into<String>,
×
433
) -> Vec<(&'static str, String)> {
×
434
    let mut causes = vec![("message", message.into()), ("", format!("{error:#}"))];
×
435
    while let Some(cause) = error.source() {
×
436
        error = cause;
×
437
        causes.push(("", format!("{error:#}")));
×
438
    }
×
439

440
    causes.into_iter().collect()
×
441
}
×
442

443
macro_rules! impl_from_error {
444
    ($error:ty, $message:expr) => {
445
        impl From<$error> for Error {
446
            fn from(error: $error) -> Self {
×
447
                with_cause(&error, $message).into()
×
448
            }
×
449
        }
450
    };
451
}
452

453
impl_from_error!(syntect::Error, "Error while formatting code");
454
impl_from_error!(
455
    jp_config::assignment::KvAssignmentError,
456
    "Key-value assignment error"
457
);
458
impl_from_error!(jp_config::Error, "Config error");
459
impl_from_error!(jp_storage::LoadError, "Storage load error");
460
impl_from_error!(jp_config::ConfigError, "Config error");
461
impl_from_error!(jp_config::fs::ConfigLoaderError, "Config loader error");
462
impl_from_error!(jp_conversation::Error, "Conversation error");
463
impl_from_error!(jp_llm::ToolError, "Tool error");
464
impl_from_error!(jp_mcp::Error, "MCP error");
465
impl_from_error!(minijinja::Error, "Template error");
466
impl_from_error!(quick_xml::SeError, "XML serialization error");
467
impl_from_error!(reqwest::Error, "Error while making HTTP request");
468
impl_from_error!(serde::de::value::Error, "Deserialization error");
469
impl_from_error!(serde_json::Error, "Error while parsing JSON");
470
impl_from_error!(std::io::Error, "IO error");
471
impl_from_error!(std::num::ParseIntError, "Error parsing integer value");
472
impl_from_error!(std::str::ParseBoolError, "Error parsing boolean value");
473
impl_from_error!(toml::de::Error, "Error while parsing TOML");
474
impl_from_error!(toml::ser::Error, "Error while serializing TOML");
475
impl_from_error!(url::ParseError, "Error while parsing URL");
476
impl_from_error!(which::Error, "Which error");
477
impl_from_error!(jp_config::model::id::ModelIdConfigError, "Model ID error");
478
impl_from_error!(jp_config::model::id::ModelIdError, "Model ID error");
479
impl_from_error!(inquire::error::InquireError, "Inquire error");
480
impl_from_error!(tokio::task::JoinError, "Join error");
481
impl_from_error!(std::fmt::Error, "fmt error");
482

483
impl From<jp_llm::Error> for Error {
484
    fn from(error: jp_llm::Error) -> Self {
×
485
        use jp_llm::Error::*;
486

487
        let metadata: Vec<(&str, String)> = match error {
×
488
            OpenRouter(error) => return error.into(),
×
489
            Conversation(error) => return error.into(),
×
490
            XmlSerialization(error) => return error.into(),
×
491
            Config(error) => return error.into(),
×
492
            Json(error) => return error.into(),
×
493
            Request(error) => return error.into(),
×
494
            Url(error) => return error.into(),
×
495
            ModelIdConfig(error) => return error.into(),
×
496
            ModelId(error) => return error.into(),
×
497
            MissingEnv(variable) => [
×
498
                ("message", "Missing environment variable".into()),
×
499
                ("variable", variable),
×
500
            ]
×
501
            .into(),
×
502
            InvalidResponse(error) => [
×
503
                ("message", "Invalid response received".into()),
×
504
                ("error", error),
×
505
            ]
×
506
            .into(),
×
507
            OpenaiClient(error) => with_cause(&error, "OpenAI client error"),
×
508
            OpenaiEvent(error) => with_cause(&error, "OpenAI stream error"),
×
509
            OpenaiResponse(error) => [
×
510
                ("message", "OpenAI response error".into()),
×
511
                ("error", error.message),
×
512
                ("code", error.code.unwrap_or_default()),
×
513
                ("type", error.r#type),
×
514
                ("param", error.param.unwrap_or_default()),
×
515
            ]
×
516
            .into(),
×
517
            OpenaiStatusCode {
518
                status_code,
×
519
                response,
×
520
            } => [
×
521
                ("message", "OpenAI status code error".into()),
×
522
                ("status_code", status_code.as_u16().to_string()),
×
523
                ("response", response),
×
524
            ]
×
525
            .into(),
×
526
            Anthropic(anthropic_error) => [
×
527
                ("message", "Anthropic error".into()),
×
528
                ("error", anthropic_error.to_string()),
×
529
            ]
×
530
            .into(),
×
531
            AnthropicRequestBuilder(error) => [
×
532
                ("message", "Anthropic request builder error".into()),
×
533
                ("error", error.to_string()),
×
534
            ]
×
535
            .into(),
×
536
            Ollama(error) => [
×
537
                ("message", "Ollama error".into()),
×
538
                ("error", error.to_string()),
×
539
            ]
×
540
            .into(),
×
541
            Gemini(error) => [
×
542
                ("message", "Gemini error".into()),
×
543
                ("error", error.to_string()),
×
544
            ]
×
545
            .into(),
×
546
            RateLimit { retry_after } => [
×
547
                ("message", "Rate limited".into()),
×
548
                (
×
549
                    "retry_after",
×
550
                    retry_after.unwrap_or_default().as_secs().to_string(),
×
551
                ),
×
552
            ]
×
553
            .into(),
×
554
            UnknownModel(model) => [("message", "Unknown model".into()), ("model", model)].into(),
×
555
            Stream(stream_error) => [
×
556
                ("message", "Stream error".into()),
×
557
                ("error", stream_error.to_string()),
×
558
                ("kind", format!("{:?}", stream_error.kind)),
×
559
            ]
×
560
            .into(),
×
561
        };
562

563
        Self::from(metadata)
×
564
    }
×
565
}
566

567
impl From<jp_openrouter::Error> for Error {
568
    fn from(error: jp_openrouter::Error) -> Self {
×
569
        use jp_openrouter::Error::*;
570

571
        let metadata: Vec<(&str, Value)> = match error {
×
572
            Request(error) => return error.into(),
×
573
            Json(error) => return error.into(),
×
574
            Io(error) => return error.into(),
×
575
            Stream(string) => [
×
576
                ("message", "Error while processing stream".into()),
×
577
                ("error", string.into()),
×
578
            ]
×
579
            .into(),
×
580
            Api { code, message } => [
×
581
                ("message", "LLM Provider API error".into()),
×
582
                ("code", code.into()),
×
583
                ("message", message.into()),
×
584
            ]
×
585
            .into(),
×
586
            Config(message) => [
×
587
                ("message", "Config error".into()),
×
588
                ("error", message.into()),
×
589
            ]
×
590
            .into(),
×
591
        };
592

593
        Self::from(metadata)
×
594
    }
×
595
}
596

597
impl From<jp_workspace::Error> for Error {
598
    fn from(error: jp_workspace::Error) -> Self {
×
599
        use jp_workspace::Error::*;
600

601
        let metadata: Vec<(&str, Value)> = match error {
×
602
            Conversation(error) => return error.into(),
×
603
            Storage(error) => return error.into(),
×
604
            Load(error) => return error.into(),
×
605
            Io(error) => return error.into(),
×
606
            Config(error) => return error.into(),
×
607
            NotDir(path) => [
×
608
                ("message", "Path is not a directory.".into()),
×
609
                ("path", path.to_string().into()),
×
610
            ]
×
611
            .into(),
×
612
            MissingStorage => [("message", "Missing storage directory".into())].into(),
×
613
            LockFailed(id) => [(
×
614
                "message",
×
615
                format!("Failed to lock conversation {id}").into(),
×
616
            )]
×
617
            .into(),
×
618
            MissingHome => [("message", "Missing home directory".into())].into(),
×
619
            NotFound(target, id) => [
×
620
                ("message", "Not found".into()),
×
621
                ("target", target.into()),
×
622
                ("id", id.into()),
×
623
            ]
×
624
            .into(),
×
625
            Exists { target, id } => [
×
626
                ("message", "Exists".into()),
×
627
                ("target", target.into()),
×
628
                ("id", id.into()),
×
629
            ]
×
630
            .into(),
×
631
            Id(error) => [
×
632
                ("message", "Invalid workspace ID".into()),
×
633
                ("error", error.clone().into()),
×
634
            ]
×
635
            .into(),
×
636
        };
637

638
        Self::from(metadata)
×
639
    }
×
640
}
641

642
impl From<jp_storage::Error> for Error {
643
    fn from(error: jp_storage::Error) -> Self {
×
644
        use jp_storage::Error;
645

646
        let metadata: Vec<(&str, Value)> = match error {
×
647
            Error::Conversation(error) => return error.into(),
×
648
            Error::Io(error) => return error.into(),
×
649
            Error::Json(error) => return error.into(),
×
650
            Error::Toml(error) => return error.into(),
×
651
            Error::Config(error) => return error.into(),
×
652
            Error::NotDir(path) => [
×
653
                ("message", "Path is not a directory.".into()),
×
654
                ("path", path.to_string().into()),
×
655
            ]
×
656
            .into(),
×
657
            Error::NotSymlink(path) => [
×
658
                ("message", "Path is not a symlink.".into()),
×
659
                ("path", path.to_string().into()),
×
660
            ]
×
661
            .into(),
×
662
            Error::ConversationNotFound(id) => [
×
663
                ("message", "Conversation not found.".into()),
×
664
                ("id", id.to_string().into()),
×
665
            ]
×
666
            .into(),
×
667
        };
668

669
        Self::from(metadata)
×
670
    }
×
671
}
672

673
impl From<jp_id::Error> for Error {
674
    fn from(error: jp_id::Error) -> Self {
×
675
        use jp_id::Error::*;
676

677
        let metadata: Vec<(&str, Value)> = match error {
×
678
            MissingPrefix(prefix) => [
×
679
                ("message", "Missing prefix".into()),
×
680
                ("prefix", prefix.into()),
×
681
            ]
×
682
            .into(),
×
683
            InvalidPrefix(expected, actual) => [
×
684
                ("message", "Invalid prefix".into()),
×
685
                ("expected", expected.into()),
×
686
                ("actual", actual.into()),
×
687
            ]
×
688
            .into(),
×
689
            MissingVariant => [("message", "Missing variant".into())].into(),
×
690
            InvalidVariant(variant) => [
×
691
                ("message", "Invalid variant".into()),
×
692
                ("variant", variant.to_string().into()),
×
693
            ]
×
694
            .into(),
×
695
            UnexpectedVariant(expected, variant) => [
×
696
                ("message", "Unexpected variant".into()),
×
697
                ("variant", variant.to_string().into()),
×
698
                ("expected", expected.to_string().into()),
×
699
            ]
×
700
            .into(),
×
701
            MissingTargetId => [("message", "Missing target ID".into())].into(),
×
702
            InvalidTimestamp(timestamp) => [
×
703
                ("message", "Invalid timestamp".into()),
×
704
                ("timestamp", timestamp.into()),
×
705
            ]
×
706
            .into(),
×
707
            MissingGlobalId => [("message", "Missing global ID".into())].into(),
×
708
            InvalidGlobalId(id) => [
×
709
                ("message", "Invalid workspace ID".into()),
×
710
                ("id", id.into()),
×
711
            ]
×
712
            .into(),
×
713
        };
714

715
        Self::from(metadata)
×
716
    }
×
717
}
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